aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 09:33:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 09:33:28 +0200
commit1e8eae1f3de765fcd8163716de6e5fe5aab5a034 (patch)
treedfdb004229c8b0f57ea1a64a726ed2752c3d18e5 /packages/meshbay-hub/src/meshbay_hub/api
parentadda9613fad48a68239e2dbd93321818a835c286 (diff)
downloadmeshbay-1e8eae1f3de765fcd8163716de6e5fe5aab5a034.tar.gz
feat(hub): one account, several linked nodes
An account links up to MAX_NODES_PER_ACCOUNT node keys (node_keys table) instead of the one users.pk_node_ed25519; the migration carries each account's key over and the downgrade keeps the last one linked. /v1/nodes/auth accepts any linked key, and its token names the key that won it (nk). PUT /me/node_key adds a key, idempotently, 409 past the limit; DELETE /me/node_key names the key: a browser session any of its own, a node only its own. Unlinking a key closes that node's socket and refuses its tokens at once, on REST and on the socket; the account's other nodes are untouched. /pubkeys no longer gives a node key to other accounts, and gives the caller its last linked one, which installed desktop apps read. The node reports waiting_for_node_key while its key is unlinked and signs in again as soon as the hub says so, so a key linked again is back within seconds; unlinking itself signs in first to hold a token naming its key. Profile -> Linked nodes lists each node (connected, last seen, IP, linked since, This machine in the desktop app) with Copy and Unlink, and adds a key by hand. The Node page banner, Create group and the desktop app add this machine's key instead of replacing another's. Messages that pointed at Settings -> Link Node point at Profile -> Linked nodes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/deps.py23
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py154
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