summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/users.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 03:24:55 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 03:24:55 +0200
commit768e07046368819b8a8f15c8b21e5a8bbfcdf282 (patch)
treefba4fa5f85e3963b2281004b503be05f552aff2c /packages/meshbay-hub/src/meshbay_hub/api/users.py
parente9d5e979fdab9a1cc3c729d602e6f27207b9480c (diff)
downloadmeshbay-768e07046368819b8a8f15c8b21e5a8bbfcdf282.tar.gz
feat: device linking, and signing in to the hub with a device key
Stage C. Identity keys are per node, so a browser and a desktop client are two keys on one account there — and the node refused the second where it accepted the first. Without this, an account created natively could never be opened in a browser without an operator code per node, and "a native client must not prevent web use" would have been dead on arrival. Device linking (node) --------------------- `identities` is keyed by `(user_id, pk_ed25519)` instead of `user_id` alone. The old shape did `INSERT OR REPLACE`, so a second device overwrote the first silently; SQLite cannot change a primary key in place, so the table is rebuilt. Existing pins are carried over — verified against a live roster with 10 of them, nobody re-pairs. A new device files a request bound by `sha256(code ‖ its own keys)`, and a key the node **already pinned** countersigns it. The hub cannot: it has stored no user keys since 2026-08-14, which is what makes this safe to do without an operator in the loop. **The code never reaches the node.** It lists this account's pending requests with their stored hashes; the approver recomputes and keeps the match. A node offering fabricated keys would have to produce a hash over a code it has never seen. Nothing rests on a human comparing digits — that ritual was dropped in 12.1 as "correct, unusable as the default" and must not return by the back door. The design document had the approver look a request up *by* its hash, which is circular: computing it needs the keys being asked about. Corrected in both. Revocation marks rather than deletes, because a deleted row is a key the node would happily pin again — which is the laptop somebody just reported lost. Your last device cannot be revoked: coming back would need an operator's code. Hub — the only change in the whole plan --------------------------------------- `POST /v1/users/auth` signs in with a device Ed25519 key, on the same pattern as `/v1/nodes/auth`, plus `/v1/users/devices` to register, list and retire. New `user_devices` table with an Alembic migration, because `create_all()` is not one. This is **not** the key directory that was H3, and the tests say so: nothing reads it but the hub, no group key is ever wrapped for one, and it is a different key from the per-node identities. What it does cost is metadata — the hub now knows how many devices an account has and when each last signed in. Also `client.minimum` / `client.recommended` in `GET /v1/hub/version`: an installed client meets a newer hub the day the interface ships in a package, and that is cheap now and awkward to retrofit. Browser ------- The `key_changed` refusal becomes `unknown_device` and offers a linking code instead of telling someone to find their operator. The Members panel lists this account's devices here, approves one by code, and retires one. 773 tests pass. `e2e.py` gained a step that links a device end to end against the live deployment — file, list, recompute, countersign, then open the group with the new keys and no code — and it also gained `recv_type`, because a step that assumes the next message is its own answer reads an ack left by the step before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/users.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py184
1 files changed, 183 insertions, 1 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 0bfbcac..b5fa205 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -1,10 +1,12 @@
"""User endpoints — /v1/users/*"""
import base64
+import time
import logging
import uuid
from datetime import datetime, timezone, timedelta
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, field_validator
from sqlalchemy import delete, select, update
@@ -27,7 +29,7 @@ from meshbay_hub.api.netutil import client_ip
from meshbay_hub.config import HubConfig
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import (
- Group, GroupMember, IPLog, Node, Notification, RefreshToken, User,
+ Group, GroupMember, IPLog, Node, Notification, RefreshToken, User, UserDevice,
)
from meshbay_hub.api.deps import get_current_user, require_user_scope
@@ -232,6 +234,186 @@ async def login(
}
+# ── Device authentication ────────────────────────────────────────────────────
+#
+# A device signs in with an Ed25519 key instead of re-deriving one from the
+# passphrase every time. The passphrase remains the account's credential and its
+# only recovery path; this is the day-to-day path once a device is registered.
+#
+# This is **not** the key directory that was H3, and the difference matters:
+# nothing reads these but the hub, no group key is ever wrapped for one, and it
+# is a different key from the per-node identity keys, which never leave the
+# device-node relationship. What it does cost is metadata — the hub now knows
+# how many devices an account has and when each last signed in.
+
+DEVICE_AUTH_TIMESTAMP_WINDOW = 60 # seconds, as for node auth
+
+
+class DeviceRegisterRequest(BaseModel):
+ pk_auth_ed25519: str # base64 raw 32 bytes
+ label: str = ""
+
+
+class DeviceAuthRequest(BaseModel):
+ username: str
+ timestamp: int # unix epoch seconds
+ signature: str # base64 Ed25519 over the message below
+
+
+@router.post("/devices", status_code=201)
+async def register_device(
+ body: DeviceRegisterRequest,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ Register a device's hub authentication key.
+
+ Requires an existing session, which in practice means the passphrase was
+ entered on this device a moment ago. A device cannot enrol itself.
+ """
+ try:
+ raw = base64.b64decode(body.pk_auth_ed25519)
+ Ed25519PublicKey.from_public_bytes(raw)
+ except Exception:
+ raise HTTPException(status_code=400, detail="Invalid Ed25519 public key")
+
+ existing = await db.execute(
+ select(UserDevice).where(
+ UserDevice.pk_auth_ed25519 == body.pk_auth_ed25519))
+ found = existing.scalar_one_or_none()
+ if found:
+ if found.user_id != current_user.id:
+ # One key, one account. Sharing it would make "who signed in" a
+ # question with two answers.
+ raise HTTPException(status_code=409,
+ detail="That key belongs to another account")
+ return {"id": found.id, "label": found.label, "existing": True}
+
+ count = await db.execute(
+ select(UserDevice).where(UserDevice.user_id == current_user.id))
+ if len(count.scalars().all()) >= 10:
+ raise HTTPException(status_code=409,
+ detail="Too many devices — remove one first")
+
+ device = UserDevice(user_id=current_user.id,
+ pk_auth_ed25519=body.pk_auth_ed25519,
+ label=body.label[:64])
+ db.add(device)
+ await db.commit()
+ await db.refresh(device)
+ return {"id": device.id, "label": device.label, "existing": False}
+
+
+@router.get("/devices")
+async def list_devices(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(UserDevice).where(UserDevice.user_id == current_user.id)
+ .order_by(UserDevice.created_at))
+ return {"devices": [
+ {"id": d.id, "label": d.label,
+ "created_at": d.created_at.isoformat() if d.created_at else None,
+ "last_seen": d.last_seen.isoformat() if d.last_seen else None}
+ for d in result.scalars().all()
+ ]}
+
+
+@router.delete("/devices/{device_id}")
+async def delete_device(
+ device_id: str,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """Retire a device's hub key. Its per-node identities are separate and are
+ revoked on each node, which the hub cannot do and should not be able to."""
+ result = await db.execute(
+ select(UserDevice).where(UserDevice.id == device_id,
+ UserDevice.user_id == current_user.id))
+ device = result.scalar_one_or_none()
+ if not device:
+ raise HTTPException(status_code=404, detail="No such device")
+ await db.delete(device)
+ await db.commit()
+ return {"status": "deleted", "id": device_id}
+
+
+@router.post("/auth")
+@limiter.limit("10/minute")
+async def device_auth(
+ body: DeviceAuthRequest,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ Sign in with a registered device key. Same shape as `/v1/nodes/auth`.
+
+ The timestamp window is what stops a captured signature being replayed
+ later; the signature covers the username as well, so one collected for a
+ different account is not usable here.
+ """
+ now = int(time.time())
+ if abs(now - body.timestamp) > DEVICE_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}")
+
+ devices = await db.execute(
+ select(UserDevice).where(UserDevice.user_id == user.id))
+ message = f"meshbay:user_auth:{body.username}:{body.timestamp}".encode()
+ try:
+ sig = base64.b64decode(body.signature)
+ except Exception:
+ raise HTTPException(status_code=401, detail="Invalid signature encoding")
+
+ matched = None
+ for device in devices.scalars().all():
+ try:
+ pk = Ed25519PublicKey.from_public_bytes(
+ base64.b64decode(device.pk_auth_ed25519))
+ pk.verify(sig, message)
+ except Exception:
+ continue
+ matched = device
+ break
+
+ if matched is None:
+ db.add(IPLog(user_id=user.id, event="device_auth_fail",
+ ip_address=client_ip(request), detail=body.username))
+ await db.commit()
+ raise HTTPException(status_code=401, detail="Invalid signature")
+
+ matched.last_seen = datetime.now(timezone.utc)
+
+ 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=_ttl(), groups=group_ids)
+ raw_rt, rt_hash = generate_refresh_token()
+ expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl())
+ db.add(RefreshToken(user_id=user.id, token_hash=rt_hash,
+ family_id=str(uuid.uuid4()), expires_at=expires_at))
+ db.add(IPLog(user_id=user.id, event="device_auth",
+ ip_address=client_ip(request)))
+ await db.commit()
+
+ return {
+ "access_token": access_token,
+ "refresh_token": raw_rt,
+ "token_type": "bearer",
+ "expires_in": _ttl(),
+ "device_id": matched.id,
+ }
+
+
@router.post("/token/refresh")
@limiter.limit("20/minute")
async def token_refresh(