summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
blob: 0770148c84cf9e37a63275a9085e2f38610e3d2c (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
"""Node endpoints — /v1/nodes/*"""

import base64
import time

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from meshbay_hub.auth import issue_access_token
from meshbay_hub.api.deps import get_current_user
from meshbay_hub.api.middleware import limiter
from meshbay_hub.api.netutil import client_ip
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import GroupMember, IPLog, Node, User

router = APIRouter(prefix="/v1/nodes", tags=["nodes"])

NODE_AUTH_TIMESTAMP_WINDOW = 60  # seconds


class NodeAuthRequest(BaseModel):
    username:  str
    timestamp: int       # unix epoch seconds
    signature: str       # base64 Ed25519 signature


@router.post("/auth")
@limiter.limit("10/minute")
async def node_auth(
    body: NodeAuthRequest,
    request: Request,
    db: AsyncSession = Depends(get_db),
):
    """Authenticate a node daemon via Ed25519 challenge-response. Returns node-scoped JWT."""
    now = int(time.time())
    if abs(now - body.timestamp) > NODE_AUTH_TIMESTAMP_WINDOW:
        raise HTTPException(status_code=401, detail="Timestamp too old or too far in the future")

    result = await db.execute(select(User).where(User.username == body.username))
    user = result.scalar_one_or_none()
    if not user:
        raise HTTPException(status_code=401, detail="Invalid credentials")
    if user.status != "active":
        raise HTTPException(status_code=403, detail=f"Account {user.status}")

    if not user.pk_node_ed25519:
        raise HTTPException(
            status_code=401,
            detail="No node key registered — link your node from the browser first",
        )

    message = f"meshbay:node_auth:{body.username}:{body.timestamp}".encode()
    try:
        pk_raw = base64.b64decode(user.pk_node_ed25519)
        pk = Ed25519PublicKey.from_public_bytes(pk_raw)
        sig = base64.b64decode(body.signature)
        pk.verify(sig, message)
    except (InvalidSignature, Exception):
        db.add(IPLog(event="node_auth_fail", ip_address=client_ip(request), detail=body.username))
        await db.commit()
        raise HTTPException(status_code=401, detail="Invalid signature")

    memberships = await db.execute(
        select(GroupMember.group_id).where(GroupMember.user_id == user.id))
    group_ids = [gid for (gid,) in memberships.all()]

    access_token = issue_access_token(
        user.id, ttl=3600, groups=group_ids, scope="node")

    db.add(IPLog(user_id=user.id, event="node_auth", ip_address=client_ip(request)))
    await db.commit()

    return {
        "access_token": access_token,
        "token_type":   "bearer",
        "expires_in":   3600,
    }


class NodeAnnounceRequest(BaseModel):
    pk_node:       str
    endpoint_hint: str | None = None
    timestamp:     int | None = None   # unix seconds
    signature:     str | None = None   # base64 Ed25519 over the announce message


@router.post("/announce", status_code=201)
async def announce_node(
    body: NodeAnnounceRequest,
    request: Request,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """
    Register a node record.

    Finding M8: this accepted any pk_node with no proof the announcer held the
    matching private key, so a user could announce a record carrying someone
    else's node key — useful for muddying node identity, and records accumulated
    without limit. The announcer must now sign a domain-separated message binding
    the key to their account, the same pattern already used by /v1/nodes/auth.
    """
    if body.timestamp is None or not body.signature:
        raise HTTPException(
            status_code=400,
            detail="announce requires timestamp and signature (proof of possession)")

    now = int(time.time())
    if abs(now - body.timestamp) > NODE_AUTH_TIMESTAMP_WINDOW:
        raise HTTPException(status_code=401, detail="Timestamp too old or too far ahead")

    message = (f"meshbay:node_announce:{current_user.id}:"
               f"{body.pk_node}:{body.timestamp}").encode()
    try:
        pk = Ed25519PublicKey.from_public_bytes(base64.b64decode(body.pk_node))
        pk.verify(base64.b64decode(body.signature), message)
    except Exception:
        db.add(IPLog(user_id=current_user.id, event="node_announce_fail",
                     ip_address=client_ip(request), detail=body.pk_node[:16]))
        await db.commit()
        raise HTTPException(status_code=401, detail="Invalid node key proof of possession")

    # One active record per key per account — announcing again updates in place
    # instead of accumulating rows.
    existing = await db.execute(
        select(Node).where(Node.user_id == current_user.id,
                           Node.pk_node == body.pk_node))
    node = existing.scalar_one_or_none()
    if node is not None:
        node.endpoint_hint = body.endpoint_hint
        db.add(IPLog(user_id=current_user.id, event="node_announce",
                     ip_address=client_ip(request), detail=body.endpoint_hint))
        await db.commit()
        return {"node_id": node.id}

    node = Node(
        user_id=current_user.id,
        pk_node=body.pk_node,
        endpoint_hint=body.endpoint_hint,
    )
    db.add(node)
    db.add(IPLog(
        user_id=current_user.id,
        event="node_announce",
        ip_address=client_ip(request),
        detail=body.endpoint_hint,
    ))
    await db.commit()
    await db.refresh(node)
    return {"node_id": node.id}


@router.get("/{node_id}")
async def get_node(
    node_id: str,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    node = await db.get(Node, node_id)
    if not node:
        raise HTTPException(status_code=404, detail="Node not found")
    owner = await db.get(User, node.user_id)
    return {
        "node_id":       node.id,
        "username":      owner.username if owner else "",
        "pk_node":       node.pk_node,
        "endpoint_hint": node.endpoint_hint,
        "announced_at":  node.announced_at.isoformat(),
    }