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