"""Node endpoints — /v1/nodes/*""" from datetime import datetime, timezone 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() # 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(timezone.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} 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(timezone.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(), }