diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-13 03:56:30 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-13 03:56:30 +0200 |
| commit | f0248975908ad670fa8a820f865bf22ea8d0172d (patch) | |
| tree | f4af64d36cacaccb4f6d13436e001aeb57e861e3 /packages/meshbay-hub/src/meshbay_hub/api | |
| parent | 35130e5528a52161630fd1c93572e1b2b7cd911b (diff) | |
| download | meshbay-f0248975908ad670fa8a820f865bf22ea8d0172d.tar.gz | |
feat: Phase 12 — P2P crypto material, password split, node Ed25519 auth
Baseline commit capturing in-progress Phase 12 work that was already present
in the working tree (uncommitted) before the Phase 11.5 security remediation
begins. Committed as-is, without review or modification, so that remediation
changes arrive as a separable diff.
Contents: BundleStore (P2P GEK + keypair bundles), password split
(auth_key / bundle_key), node Ed25519 auth (POST /v1/nodes/auth, node-scoped
JWT), GEK-HMAC handshake proof with DTLS channel binding, Ed25519 admin
challenge-response, node local admin UI rewrite, browser key persistence.
Not authored in this session — captured to establish a baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/admin.py | 12 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/deps.py | 40 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/groups.py | 77 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/nodes.py | 71 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/users.py | 140 |
5 files changed, 256 insertions, 84 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py index 88e231a..164885d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py @@ -211,6 +211,7 @@ async def admin_list_groups( "name": g.name, "admin_id": g.admin_id, "visibility": g.visibility, + "description": g.description or "", "status": g.status, "created_at": g.created_at.isoformat(), "member_count": mc, @@ -265,25 +266,30 @@ async def admin_list_logs( offset: int = 0, limit: int = Query(default=50, le=200), ): - query = select(IPLog).order_by(IPLog.timestamp.desc()) + query = ( + select(IPLog, User.username) + .outerjoin(User, IPLog.user_id == User.id) + .order_by(IPLog.timestamp.desc()) + ) if user_id: query = query.where(IPLog.user_id == user_id) if event: query = query.where(IPLog.event == event) query = query.offset(offset).limit(limit) result = await db.execute(query) - logs = result.scalars().all() + rows = result.all() return { "logs": [ { "id": lg.id, "user_id": lg.user_id, + "username": uname or "", "event": lg.event, "ip_address": lg.ip_address, "detail": lg.detail, "timestamp": lg.timestamp.isoformat(), } - for lg in logs + for lg, uname in rows ], } diff --git a/packages/meshbay-hub/src/meshbay_hub/api/deps.py b/packages/meshbay-hub/src/meshbay_hub/api/deps.py index addba30..1bf57a4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/deps.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/deps.py @@ -1,5 +1,10 @@ """ FastAPI shared dependencies — injected via Depends(). + +JWT scope enforcement: + - "user" scope (browser login): full access to all endpoints + - "node" scope (Ed25519 daemon auth): read-only group access + node operations + Node-scoped tokens CANNOT create/delete groups or manage membership. """ from fastapi import Depends, Header, HTTPException, status @@ -18,20 +23,13 @@ def set_admin_usernames(usernames: list[str]) -> None: _admin_usernames = set(usernames) -async def get_current_user( - authorization: str = Header(...), - db: AsyncSession = Depends(get_db), -) -> User: - """ - Verify the JWT bearer token and return the User from the database. - Node clients: verified locally with hub PK — no DB round-trip needed. - Hub API (web): must confirm user still exists and is active. - """ +async def _decode_token(authorization: str = Header(...)) -> dict: + """Decode and verify JWT bearer token. Returns full payload.""" try: scheme, token = authorization.split(None, 1) if scheme.lower() != "bearer": raise ValueError - payload = decode_access_token(token) + return decode_access_token(token) except Exception: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -39,6 +37,15 @@ async def get_current_user( headers={"WWW-Authenticate": "Bearer"}, ) + +async def get_current_user( + payload: dict = Depends(_decode_token), + db: AsyncSession = Depends(get_db), +) -> User: + """ + Verify the JWT bearer token and return the User from the database. + Accepts both user-scoped and node-scoped tokens. + """ result = await db.execute( select(User).where(User.id == payload["sub"])) user = result.scalar_one_or_none() @@ -52,6 +59,19 @@ async def get_current_user( return user +async def require_user_scope( + payload: dict = Depends(_decode_token), + current_user: User = Depends(get_current_user), +) -> User: + """Reject node-scoped tokens — only browser (user-scope) can mutate groups.""" + if payload.get("scope") == "node": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Node-scoped token cannot perform this operation — use browser", + ) + return current_user + + async def require_moderator( current_user: User = Depends(get_current_user), ) -> User: diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index e0ea016..88af764 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -5,10 +5,10 @@ from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from meshbay_hub.api.deps import get_current_user +from meshbay_hub.api.deps import get_current_user, require_user_scope from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import ( - FederatedGroup, GEKBundle, Group, GroupMember, + FederatedGroup, Group, GroupMember, IPLog, SwarmSource, User, ) @@ -37,6 +37,7 @@ async def my_groups( "join_policy": g.join_policy, "created_at": g.created_at.isoformat(), "is_admin": g.admin_id == current_user.id, + "description": g.description or "", } for g in groups ] @@ -56,6 +57,8 @@ async def group_online_nodes( group = await db.get(Group, group_id) if not group: raise HTTPException(status_code=404, detail="Group not found") + if group.status != "active": + raise HTTPException(status_code=403, detail="Group is suspended") node_ids = get_online_nodes_for_group(group_id) nodes = [] @@ -85,6 +88,7 @@ async def list_public_groups( groups = [ { "id": g.id, "name": g.name, "join_policy": g.join_policy, + "description": g.description or "", "created_at": g.created_at.isoformat(), "source": "local", } for g in local @@ -193,7 +197,7 @@ async def group_members( async def join_group( group_id: str, request: Request, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_user_scope), db: AsyncSession = Depends(get_db), ): group = await db.get(Group, group_id) @@ -219,26 +223,23 @@ class GroupCreateRequest(BaseModel): name: str visibility: str = "private" # public|private join_policy: str = "invite" # open|request|invite - - -class GEKBundleRequest(BaseModel): - pk_eph_b64: str - nonce_b64: str - wrapped_b64: str + description: str | None = None @router.post("", status_code=201) async def create_group( body: GroupCreateRequest, request: Request, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_user_scope), db: AsyncSession = Depends(get_db), ): + desc = (body.description or "")[:512] if body.description else None group = Group( name=body.name, admin_id=current_user.id, visibility=body.visibility, join_policy=body.join_policy, + description=desc, ) db.add(group) await db.flush() # get group.id @@ -251,12 +252,11 @@ async def create_group( return {"group_id": group.id, "name": group.name} -@router.post("/{group_id}/members/{username}/gek", status_code=201) -async def store_gek_bundle( +@router.post("/{group_id}/members/{username}", status_code=201) +async def add_group_member( group_id: str, username: str, - body: GEKBundleRequest, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_user_scope), db: AsyncSession = Depends(get_db), ): group = await db.get(Group, group_id) @@ -270,26 +270,11 @@ async def store_gek_bundle( if not target: raise HTTPException(status_code=404, detail="User not found") - # Upsert GEK bundle - existing = await db.get(GEKBundle, (group_id, target.id)) new_member = False - if existing: - existing.pk_eph_b64 = body.pk_eph_b64 - existing.nonce_b64 = body.nonce_b64 - existing.wrapped_b64 = body.wrapped_b64 - else: - db.add(GEKBundle( - group_id=group_id, - user_id=target.id, - pk_eph_b64=body.pk_eph_b64, - nonce_b64=body.nonce_b64, - wrapped_b64=body.wrapped_b64, - )) - # Add member if not already in group - mem = await db.get(GroupMember, (group_id, target.id)) - if not mem: - db.add(GroupMember(group_id=group_id, user_id=target.id)) - new_member = True + mem = await db.get(GroupMember, (group_id, target.id)) + if not mem: + db.add(GroupMember(group_id=group_id, user_id=target.id)) + new_member = True if new_member: from meshbay_hub.api.notifications import create_notification @@ -303,26 +288,26 @@ async def store_gek_bundle( return {"status": "stored", "group_id": group_id, "username": username} -@router.get("/{group_id}/gek") -async def get_my_gek_bundle( +@router.delete("/{group_id}") +async def delete_group( group_id: str, - current_user: User = Depends(get_current_user), + request: Request, + current_user: User = Depends(require_user_scope), db: AsyncSession = Depends(get_db), ): group = await db.get(Group, group_id) if not group: raise HTTPException(status_code=404, detail="Group not found") + if group.admin_id != current_user.id: + raise HTTPException(status_code=403, detail="Only the group creator can delete") - bundle = await db.get(GEKBundle, (group_id, current_user.id)) - if not bundle: - raise HTTPException(status_code=404, detail="No GEK bundle for this user in this group") - - return { - "group_id": group_id, - "pk_eph_b64": bundle.pk_eph_b64, - "nonce_b64": bundle.nonce_b64, - "wrapped_b64": bundle.wrapped_b64, - } + from sqlalchemy import delete as sa_delete + await db.execute(sa_delete(GroupMember).where(GroupMember.group_id == group_id)) + db.add(IPLog(user_id=current_user.id, event="group_delete", + ip_address=_ip(request), detail=group.name)) + await db.delete(group) + await db.commit() + return {"status": "deleted", "group_id": group_id} def _ip(request: Request) -> str: diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py index b970aa8..321e43c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py @@ -1,15 +1,84 @@ """Node endpoints — /v1/nodes/*""" +import base64 +import time + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from cryptography.exceptions import InvalidSignature from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from meshbay_hub.auth import issue_access_token from meshbay_hub.api.deps import get_current_user +from meshbay_hub.api.middleware import limiter from meshbay_hub.db.engine import get_db -from meshbay_hub.db.models import IPLog, Node, User +from meshbay_hub.db.models import GroupMember, IPLog, Node, User router = APIRouter(prefix="/v1/nodes", tags=["nodes"]) +NODE_AUTH_TIMESTAMP_WINDOW = 60 # seconds + + +class NodeAuthRequest(BaseModel): + username: str + timestamp: int # unix epoch seconds + signature: str # base64 Ed25519 signature + + +@router.post("/auth") +@limiter.limit("10/minute") +async def node_auth( + body: NodeAuthRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + """Authenticate a node daemon via Ed25519 challenge-response. Returns node-scoped JWT.""" + now = int(time.time()) + if abs(now - body.timestamp) > NODE_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}") + + if not user.pk_node_ed25519: + raise HTTPException( + status_code=401, + detail="No node key registered — link your node from the browser first", + ) + + 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): + db.add(IPLog(event="node_auth_fail", ip_address=_ip(request), detail=body.username)) + await db.commit() + raise HTTPException(status_code=401, detail="Invalid signature") + + 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, user.pk_node_ed25519, ttl=3600, groups=group_ids, scope="node") + + db.add(IPLog(user_id=user.id, event="node_auth", ip_address=_ip(request))) + await db.commit() + + return { + "access_token": access_token, + "token_type": "bearer", + "expires_in": 3600, + } + class NodeAnnounceRequest(BaseModel): pk_node: str diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 2c2eede..53238de 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -1,5 +1,6 @@ """User endpoints — /v1/users/*""" +import base64 import uuid from datetime import datetime, timezone, timedelta @@ -24,7 +25,7 @@ from meshbay_hub.api.middleware import limiter from meshbay_hub.config import HubConfig from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import GroupMember, IPLog, RefreshToken, User -from meshbay_hub.api.deps import get_current_user +from meshbay_hub.api.deps import get_current_user, require_user_scope router = APIRouter(prefix="/v1/users", tags=["users"]) @@ -46,10 +47,10 @@ def _refresh_ttl() -> int: class RegisterRequest(BaseModel): username: str email: str - password: str + password: str | None = None # deprecated — legacy native clients + auth_key: str | None = None # PBKDF2-derived, new clients pk_user_ed25519: str # base64 raw 32B pk_user_x25519: str # base64 raw 32B - keypair_bundle: str | None = None # AES-GCM encrypted bundle (web clients) @field_validator("username") @classmethod @@ -61,17 +62,11 @@ class RegisterRequest(BaseModel): raise ValueError("username: only letters, digits, -, _, .") return v - @field_validator("password") - @classmethod - def password_strength(cls, v: str) -> str: - if len(v) < 8: - raise ValueError("password must be at least 8 characters") - return v - class LoginRequest(BaseModel): username: str - password: str + password: str | None = None # legacy (raw password) for migration + auth_key: str | None = None # PBKDF2-derived auth key (new scheme) class RefreshRequest(BaseModel): @@ -92,18 +87,23 @@ async def register( if existing.scalar_one_or_none(): raise HTTPException(status_code=409, detail="Username already taken") - pw_hash, pw_salt = hash_password(body.password) + credential = body.auth_key or body.password + if not credential: + raise HTTPException(status_code=400, detail="auth_key or password required") + + pw_hash, pw_salt = hash_password(credential) + # auth_key → pw_version 3 (password split); raw password → pw_version 2 (legacy) + pw_ver = current_pw_version() if body.auth_key else 2 hub_id = _cfg.identity.id if _cfg else "meshbay.org" user = User( username=body.username, email=encrypt_email(body.email), pw_hash=pw_hash, pw_salt=pw_salt, - pw_version=current_pw_version(), + pw_version=pw_ver, pk_ed25519=body.pk_user_ed25519, pk_x25519=body.pk_user_x25519, hub_id=hub_id, - keypair_bundle=body.keypair_bundle, ) db.add(user) db.add(IPLog( @@ -136,18 +136,52 @@ async def login( user = result.scalar_one_or_none() ip = _client_ip(request) - if not user or not verify_password( - body.password, user.pw_hash, user.pw_salt, version=user.pw_version - ): + + if not body.auth_key and not body.password: + raise HTTPException(status_code=401, detail="No credentials provided") + + if not user: db.add(IPLog(event="login_fail", ip_address=ip, detail=body.username)) await db.commit() raise HTTPException(status_code=401, detail="Invalid credentials") + if user.pw_version >= 3: + # New scheme: verify auth_key + if not body.auth_key or not verify_password( + body.auth_key, user.pw_hash, user.pw_salt, version=user.pw_version + ): + db.add(IPLog(event="login_fail", ip_address=ip, detail=body.username)) + await db.commit() + raise HTTPException(status_code=401, detail="Invalid credentials") + else: + # Legacy scheme: need raw password + if not body.password: + raise HTTPException(status_code=401, detail="auth_upgrade_required") + if not verify_password( + body.password, user.pw_hash, user.pw_salt, version=user.pw_version + ): + db.add(IPLog(event="login_fail", ip_address=ip, detail=body.username)) + await db.commit() + raise HTTPException(status_code=401, detail="Invalid credentials") + # Migrate to new scheme if auth_key provided alongside password + if body.auth_key: + new_hash, new_salt = hash_password(body.auth_key) + user.pw_hash = new_hash + user.pw_salt = new_salt + user.pw_version = current_pw_version() + elif user.pw_version < 2: + # Legacy rehash: upgrade Argon2 params within the password scheme (v1 -> v2) + new_hash, new_salt = hash_password(body.password) + user.pw_hash = new_hash + user.pw_salt = new_salt + user.pw_version = 2 + if user.status != "active": raise HTTPException(status_code=403, detail=f"Account {user.status}") - if pw_needs_rehash(user.pw_version): - new_hash, new_salt = hash_password(body.password) + # Rehash within the auth_key scheme if Argon2 params upgraded beyond v3 + if user.pw_version >= 3 and pw_needs_rehash(user.pw_version): + new_hash, new_salt = hash_password(body.auth_key) user.pw_hash = new_hash user.pw_salt = new_salt user.pw_version = current_pw_version() @@ -168,15 +202,12 @@ async def login( db.add(IPLog(user_id=user.id, event="login", ip_address=ip)) await db.commit() - resp = { + return { "access_token": access_token, "refresh_token": raw_rt, "token_type": "bearer", "expires_in": _ttl(), } - if user.keypair_bundle: - resp["keypair_bundle"] = user.keypair_bundle # encrypted, for web clients - return resp @router.post("/token/refresh") @@ -248,6 +279,64 @@ async def get_current_user_info( } +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.""" + try: + raw = base64.b64decode(body.pk_node_ed25519) + if len(raw) != 32: + raise ValueError + except Exception: + raise HTTPException(status_code=400, detail="Invalid Ed25519 public key (need 32 bytes base64)") + + current_user.pk_node_ed25519 = body.pk_node_ed25519 + await db.commit() + return {"status": "stored", "pk_node_ed25519": body.pk_node_ed25519} + + +class RotateKeysRequest(BaseModel): + pk_user_ed25519: str # base64 raw 32B + pk_user_x25519: str # base64 raw 32B + + +@router.put("/me/keys") +async def rotate_browser_keys( + body: RotateKeysRequest, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + for field, label in [ + (body.pk_user_ed25519, "Ed25519"), + (body.pk_user_x25519, "X25519"), + ]: + try: + raw = base64.b64decode(field) + if len(raw) != 32: + raise ValueError + except Exception: + raise HTTPException( + status_code=400, + detail=f"Invalid {label} public key (need 32 bytes base64)", + ) + + current_user.pk_ed25519 = body.pk_user_ed25519 + current_user.pk_x25519 = body.pk_user_x25519 + await db.commit() + return { + "status": "updated", + "pk_ed25519": body.pk_user_ed25519, + "pk_x25519": body.pk_user_x25519, + } + + @router.get("/{username}/pubkeys") async def get_user_pubkeys( username: str, @@ -258,12 +347,15 @@ async def get_user_pubkeys( target = result.scalar_one_or_none() if not target: raise HTTPException(status_code=404, detail="User not found") - return { + resp = { "user_id": target.id, "username": target.username, "pk_ed25519": target.pk_ed25519, "pk_x25519": target.pk_x25519, } + if target.pk_node_ed25519: + resp["pk_node_ed25519"] = target.pk_node_ed25519 + return resp def _client_ip(request: Request) -> str: |