"""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, require_user_scope from meshbay_hub.api.middleware import limiter from meshbay_hub.api.netutil import client_ip from meshbay_hub.auth import issue_access_token, issue_mnp_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"]) class MnpTokenRequest(BaseModel): # The base64 Ed25519 key of the node this token is for. The token is bound # to it (E10), so it cannot be replayed to another node. The client knows it # from `/v1/groups/{id}/nodes` before it connects. node_pk: str = "" @router.post("/mnp-token") @limiter.limit("60/minute") async def mnp_token( request: Request, body: MnpTokenRequest | None = None, current_user: User = Depends(require_user_scope), db: AsyncSession = Depends(get_db), ): """Mint the short-lived token a member presents to a node in the MNP handshake. Asked for with the member's own session token (require_user_scope, so a node daemon token cannot mint one). The result carries the member's current group membership, `aud=MNP_AUD` and the target node's key, so it authorises the member to **that** node only and is refused by the hub API and by any other node. Short-lived on purpose; the client refetches it for a new connection or a reconnect, and it is checked only at the handshake, so a film already playing is never interrupted by its expiry. The hub does not verify the node key it is handed — binding the token to it only *restricts* the token to whatever node holds that key, which is the one the client is connecting to; a wrong key yields a token no node will accept. """ rows = await db.execute( select(GroupMember.group_id).where(GroupMember.user_id == current_user.id)) group_ids = [gid for (gid,) in rows.all()] return { "mnp_token": issue_mnp_token(current_user.id, groups=group_ids, node_pk=(body.node_pk if body else "")), "expires_in": 900, } 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(), }