diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/hub.py | 23 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/users.py | 184 |
2 files changed, 206 insertions, 1 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py index 6e2db9e..4aef93d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py @@ -26,6 +26,22 @@ async def hub_pubkey(): return {"pk_hub_pem": hub_public_key_pem().decode()} +# What an installed client must be, to talk to this hub. +# +# Until a client ships, the SPA and the hub deploy together and are always in +# sync: a /v1/ response shape changes and app.js is fixed in the same commit. +# The moment the interface is installed rather than served, an old client meets +# a new hub — for the first time in this project's life — and there is no way to +# fix it from here. +# +# `minimum` refuses; `recommended` warns. Both are stated so a client can tell a +# user "update to keep using this" before it becomes "this stopped working". +# Raise `minimum` only for a change a client genuinely cannot survive, and +# remember store review latency makes that expensive on Android. +MIN_CLIENT_VERSION = "0.1.0" +RECOMMENDED_CLIENT_VERSION = "0.1.0" + + @router.get("/version") async def hub_version(): """Version check endpoint for clients to detect updates.""" @@ -33,4 +49,11 @@ async def hub_version(): "hub": __version__, "mnp": MNP_VERSION, "mhp": MHP_VERSION, + # A client compares its own version against these before doing anything + # else. The browser SPA always matches the hub by construction and can + # ignore them. + "client": { + "minimum": MIN_CLIENT_VERSION, + "recommended": RECOMMENDED_CLIENT_VERSION, + }, } 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( |