diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/deps.py | 23 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/nodes.py | 40 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/revocation.py | 24 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/users.py | 154 |
4 files changed, 207 insertions, 34 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/deps.py b/packages/meshbay-hub/src/meshbay_hub/api/deps.py index 56af023..36c3c64 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/deps.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/deps.py @@ -16,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.auth import decode_access_token from meshbay_hub.db.engine import get_db -from meshbay_hub.db.models import AdminPin, User +from meshbay_hub.db.models import AdminPin, NodeKey, User log = logging.getLogger(__name__) @@ -106,10 +106,31 @@ async def get_current_user( if user.status != "active": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Account {user.status}") + if not await node_token_key_linked(db, payload): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, + detail="Node key no longer linked to this account", + headers={"WWW-Authenticate": "Bearer"}) await _pin_if_listed(db, user) return user +async def node_token_key_linked(db: AsyncSession, payload: dict) -> bool: + """False for a node token whose key the account has since unlinked. + + A node token names the key that signed in (`nk`), so unlinking one machine + ends that machine's tokens at once and no other. A node token without `nk` + predates the claim; none outlives the hour `/v1/nodes/auth` gives it, and + refusing it would strand every node mid-hour on the deploy that adds this, + since a node only signs in again when its token is about to expire. + """ + if payload.get("scope") != "node" or "nk" not in payload: + return True + row = await db.execute( + select(NodeKey.id).where(NodeKey.user_id == payload.get("sub", ""), + NodeKey.pk_node_ed25519 == payload["nk"])) + return row.first() is not None + + def _reject_node_scope(payload: dict) -> None: """Refuse a node-scoped daemon token on a route meant for a person. diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py index b35f11e..367b37f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py @@ -16,7 +16,7 @@ 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 +from meshbay_hub.db.models import GroupMember, IPLog, Node, NodeKey, User router = APIRouter(prefix="/v1/nodes", tags=["nodes"]) @@ -72,6 +72,21 @@ async def mnp_token( NODE_AUTH_TIMESTAMP_WINDOW = 60 # seconds +def _signing_key(linked: list[str], message: bytes, signature_b64: str) -> str | None: + """The linked key that signed `message`, or None.""" + try: + sig = base64.b64decode(signature_b64) + except Exception: + return None + for pk_b64 in linked: + try: + Ed25519PublicKey.from_public_bytes(base64.b64decode(pk_b64)).verify(sig, message) + return pk_b64 + except (InvalidSignature, ValueError): + continue + return None + + class NodeAuthRequest(BaseModel): username: str timestamp: int # unix epoch seconds @@ -97,29 +112,34 @@ async def node_auth( if user.status != "active": raise HTTPException(status_code=403, detail=f"Account {user.status}") - if not user.pk_node_ed25519: + linked = (await db.execute( + select(NodeKey.pk_node_ed25519).where(NodeKey.user_id == user.id))).scalars().all() + if not linked: raise HTTPException( status_code=401, detail="No node key registered — link your node from the browser first", ) + # Whichever linked key signed it: one per machine the person runs a node on. + # A key that is not linked gets the same "No node key" answer as an account + # with none, which is what the daemon reads to wait for its link + # (`daemon._login_with_retry`) rather than for an account. 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): + signer = _signing_key(linked, message, body.signature) + if signer is None: 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") + raise HTTPException( + status_code=401, + detail="No node key registered for this node — link it from the browser first", + ) 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") + user.id, ttl=3600, groups=group_ids, scope="node", node_key=signer) db.add(IPLog(user_id=user.id, event="node_auth", ip_address=client_ip(request))) await db.commit() diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py index 2499374..c03d582 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py @@ -39,7 +39,7 @@ from pydantic import BaseModel from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession -from meshbay_hub.api.deps import get_current_user, require_admin +from meshbay_hub.api.deps import get_current_user, node_token_key_linked, require_admin from meshbay_hub.auth import decode_access_token from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, User @@ -116,6 +116,18 @@ def forget_node(node_id: str) -> None: _node_users.pop(node_id, None) +async def disconnect_node(node_id: str, detail: str) -> None: + """Close a node's live socket now, with the reason it is given.""" + ws = _connected_nodes.get(node_id) + if ws is None: + return + forget_node(node_id) + try: + await _reject(ws, detail, 4003) + except Exception: + pass # already gone; it is forgotten either way + + def _spend(window: dict[str, tuple[float, int]], node_id: str, burst: int) -> bool: """True if this node may send one more message of a budgeted kind now.""" now = time.monotonic() @@ -429,6 +441,16 @@ async def _authorize_node_ws(token: str, claimed_id: str, claimed_groups) -> tup if user is None or user.status != "active": return None, "Account not active" + # The token names the key that signed in (`nk`): it registers that + # machine's node and no other, and only while the key is still linked. + # A token from before the claim names no key, so the node's own key is + # checked instead: unlinking a node closes its socket for good, not + # until a token issued before the upgrade runs out. + if "nk" in decoded and node.pk_node != decoded["nk"]: + return None, "node_id is not the node this token was issued to" + if not await node_token_key_linked(db, {**decoded, "nk": node.pk_node}): + return None, "Node key no longer linked to this account" + # Groups come from the database. The node may narrow the set to what it # actually hosts, but it cannot widen it past what its account belongs to # (C2) — nor, within that, past what its account owns or the owner approved. diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index fbba6d7..904d7a0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -13,10 +13,11 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, field_validator from sqlalchemy import delete, func, select, update +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub import hub_settings, login_throttle, mail -from meshbay_hub.api.deps import get_current_user, require_user_scope +from meshbay_hub.api.deps import _decode_token, 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 ( @@ -45,6 +46,7 @@ from meshbay_hub.db.models import ( IPLog, KnownBrowser, Node, + NodeKey, Notification, PushSubscription, RefreshToken, @@ -1491,36 +1493,136 @@ class NodeKeyRequest(BaseModel): pk_node_ed25519: str # base64 raw 32B Ed25519 public key -@router.put("/me/node_key") -async def register_node_key( - body: NodeKeyRequest, - current_user: User = Depends(require_user_scope), - db: AsyncSession = Depends(get_db), -): - """Link a node daemon's Ed25519 public key to the operator's account.""" +def _node_key(value: str) -> str: + """The key in the one spelling `/v1/nodes/announce` and `/auth` compare against.""" try: - raw = base64.b64decode(body.pk_node_ed25519) + raw = base64.b64decode(value.strip(), validate=True) if len(raw) != 32: raise ValueError except Exception: raise HTTPException( status_code=400, detail="Invalid Ed25519 public key (need 32 bytes base64)") + return base64.b64encode(raw).decode() - current_user.pk_node_ed25519 = body.pk_node_ed25519 - await db.commit() - return {"status": "stored", "pk_node_ed25519": body.pk_node_ed25519} + +@router.get("/me/node_keys") +async def list_node_keys( + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """The node keys linked to your account, oldest first, with what the hub saw of each node. + + `last_seen` and `observed_ip` come from that node's own announce, so they are + empty for a key that has never signed in. `connected` says whether it holds + its socket to the hub right now. Your own account only: another account's + keys are never listed anywhere. + """ + from meshbay_hub.api.nodes import MAX_NODES_PER_ACCOUNT + from meshbay_hub.api.revocation import _connected_nodes + + keys = (await db.execute( + select(NodeKey).where(NodeKey.user_id == current_user.id) + .order_by(NodeKey.linked_at))).scalars().all() + nodes = (await db.execute( + select(Node).where(Node.user_id == current_user.id))).scalars().all() + by_key: dict[str, list[Node]] = {} + for n in nodes: + by_key.setdefault(n.pk_node, []).append(n) + + out = [] + for k in keys: + seen = [n for n in by_key.get(k.pk_node_ed25519, []) if n.last_seen] + latest = max(seen, key=lambda n: n.last_seen) if seen else None + out.append({ + "pk_node_ed25519": k.pk_node_ed25519, + "linked_at": k.linked_at.isoformat() if k.linked_at else None, + "last_seen": latest.last_seen.isoformat() if latest else None, + "observed_ip": latest.observed_ip if latest else None, + "connected": any(n.id in _connected_nodes + for n in by_key.get(k.pk_node_ed25519, [])), + }) + return {"keys": out, "limit": MAX_NODES_PER_ACCOUNT} + + +@router.put("/me/node_key") +async def register_node_key( + body: NodeKeyRequest, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """Link a node daemon's Ed25519 public key to your account, alongside any already linked. + + One key per machine you run a node on; linking one never unlinks another. + Linking a key that is already linked changes nothing and answers the same. + Refused with 409 past the per-account limit (`limit` in `GET /me/node_keys`). + """ + from meshbay_hub.api.nodes import MAX_NODES_PER_ACCOUNT + + pk = _node_key(body.pk_node_ed25519) + existing = await db.execute( + select(NodeKey.id).where(NodeKey.user_id == current_user.id, + NodeKey.pk_node_ed25519 == pk)) + if existing.first() is None: + held = (await db.execute( + select(func.count()).select_from(NodeKey) + .where(NodeKey.user_id == current_user.id))).scalar() or 0 + if held >= MAX_NODES_PER_ACCOUNT: + raise HTTPException( + status_code=409, + detail=f"This account already has {held} linked nodes, which is the " + f"limit of {MAX_NODES_PER_ACCOUNT}. Unlink one you no longer run.") + db.add(NodeKey(user_id=current_user.id, pk_node_ed25519=pk)) + try: + await db.commit() + except IntegrityError: + await db.rollback() # the same key, linked by a concurrent request + return {"status": "stored", "pk_node_ed25519": pk} @router.delete("/me/node_key") async def unlink_node_key( + body: NodeKeyRequest | None = None, + payload: dict = Depends(_decode_token), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - """Remove the linked node key from the operator's account.""" - current_user.pk_node_ed25519 = None + """Unlink one node key from your account. That node is cut off at once; the others are not. + + Its socket to the hub is closed and the tokens it holds stop working, so it + serves no group until its key is linked again. From a browser session, name + the key. A node's own token may unlink that node and nothing else — the key + its token was issued to, named or not — so a node can let go of itself + (`meshbay-node setup --reset`, the Node page) but never of a sibling. + """ + from meshbay_hub.api.revocation import disconnect_node + + if payload.get("scope") == "node": + own = payload.get("nk") + if not own: + raise HTTPException(status_code=403, + detail="This node token predates per-node keys; sign in again") + pk = _node_key(body.pk_node_ed25519) if body else own + if pk != own: + raise HTTPException(status_code=403, + detail="A node may unlink its own key and no other") + else: + if body is None: + raise HTTPException(status_code=422, detail="Name the node key to unlink") + pk = _node_key(body.pk_node_ed25519) + + result = await db.execute( + delete(NodeKey).where(NodeKey.user_id == current_user.id, + NodeKey.pk_node_ed25519 == pk)) + if not result.rowcount: + raise HTTPException(status_code=404, detail="That node key is not linked to this account") + node_ids = (await db.execute( + select(Node.id).where(Node.user_id == current_user.id, + Node.pk_node == pk))).scalars().all() await db.commit() - return {"status": "unlinked"} + for node_id in node_ids: + await disconnect_node(node_id, "Node key unlinked from this account") + return {"status": "unlinked", "pk_node_ed25519": pk} # Key rotation used to live here (`PUT /me/keys`). Identity keys are per node @@ -1585,6 +1687,7 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus await db.execute(delete(GroupHost).where( GroupHost.node_id.in_(select(Node.id).where(Node.user_id == user.id)))) await db.execute(delete(Node).where(Node.user_id == user.id)) + await db.execute(delete(NodeKey).where(NodeKey.user_id == user.id)) await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id)) await db.execute(delete(KnownBrowser).where(KnownBrowser.user_id == user.id)) await db.execute(delete(PushSubscription).where(PushSubscription.user_id == user.id)) @@ -1605,7 +1708,6 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus user.email_hash = None user.pw_hash = b"" user.pw_salt = b"" - user.pk_node_ed25519 = None user.bundle_pepper = None user.status = "deleted" user.role = "user" @@ -1647,21 +1749,29 @@ async def get_user_pubkeys( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - """Resolve a username to its account id, and its node's linking key. Not a key directory.""" + """Resolve a username to its account id. Not a key directory.""" result = await db.execute(select(User).where(User.username == username)) target = result.scalar_one_or_none() if not target: raise HTTPException(status_code=404, detail="User not found") # Account lookup, not a key directory. `user_id` is how a username is resolved - # for an invitation, and `pk_node_ed25519` is a node's own linking key. The - # user identity keys this used to return were H3: whoever asked wrapped the - # group key for whatever came back. + # for an invitation. The user identity keys this used to return were H3: + # whoever asked wrapped the group key for whatever came back. Node keys are + # not here for anyone else — an account lists its own with `GET + # /me/node_keys`, and nobody else learns which machines it runs. resp = { "user_id": target.id, "username": target.username, } - if target.pk_node_ed25519: - resp["pk_node_ed25519"] = target.pk_node_ed25519 + # Asking about yourself: the latest key you linked, for the desktop + # applications already installed, whose interface reads it here to show + # the Node and Create group entries. + if target.id == current_user.id: + latest = (await db.execute( + select(NodeKey.pk_node_ed25519).where(NodeKey.user_id == target.id) + .order_by(NodeKey.linked_at.desc()).limit(1))).scalar_one_or_none() + if latest: + resp["pk_node_ed25519"] = latest return resp |