"""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(), }