diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/users.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/users.py | 154 |
1 files changed, 132 insertions, 22 deletions
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 |