aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
blob: 74781737cab5294d21108cfa8cb0234bfcb3a7d7 (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
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
"""Node endpoints — /v1/nodes/*"""

import base64
import time
from datetime import UTC, datetime

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

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.auth import issue_access_token
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


# How many distinct node keys one account may announce.
#
# M8 closed the half of this that was about *whose* key it is: the announcer now
# proves possession. What it did not close is *how many*. Each new key is a row
# in `nodes` plus a row in the IP log, and the IP log is kept for a year — so an
# account in a loop writes a year of storage on somebody else's disk, having paid
# only for the signatures.
#
# Ten is past what the feature is for. A node is a machine left running: a
# desktop, a laptop, a box in a cupboard, a second home. Someone who genuinely
# reaches it deletes one, which is a thing the operator surface already does —
# and an account that wants an eleventh *identity* rather than an eleventh
# machine is the case this refuses.
MAX_NODES_PER_ACCOUNT = 10


@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()
    # The address is taken from the connection, never from the body: the
    # signature above proves who is announcing, and this is where they are
    # announcing from. What the node believes its address to be — endpoint_hint,
    # learned from a STUN server — is kept separately and is not evidence.
    seen_from = client_ip(request)

    if node is not None:
        node.endpoint_hint = body.endpoint_hint
        node.observed_ip = seen_from
        node.last_seen = datetime.now(UTC)
        db.add(IPLog(user_id=current_user.id, event="node_announce",
                     ip_address=seen_from, detail=body.endpoint_hint))
        await db.commit()
        return {"node_id": node.id}

    # Counted only where a row is actually added: re-announcing a key this
    # account already holds takes the branch above and must keep working at the
    # ceiling, or a node that has reached it can never refresh its address again.
    held = (await db.execute(
        select(func.count()).select_from(Node)
        .where(Node.user_id == current_user.id))).scalar() or 0
    if held >= MAX_NODES_PER_ACCOUNT:
        db.add(IPLog(user_id=current_user.id, event="node_announce_refused",
                     ip_address=seen_from, detail=f"{held} nodes"))
        await db.commit()
        raise HTTPException(
            status_code=409,
            detail=f"This account already has {held} nodes, which is the limit of "
                   f"{MAX_NODES_PER_ACCOUNT}. Remove one you no longer run.")

    node = Node(
        user_id=current_user.id,
        pk_node=body.pk_node,
        endpoint_hint=body.endpoint_hint,
        observed_ip=seen_from,
        last_seen=datetime.now(UTC),
    )
    db.add(node)
    db.add(IPLog(
        user_id=current_user.id,
        event="node_announce",
        ip_address=seen_from,
        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(),
    }