aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/users.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/users.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py154
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