aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-13 03:56:30 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-13 03:56:30 +0200
commitf0248975908ad670fa8a820f865bf22ea8d0172d (patch)
treef4af64d36cacaccb4f6d13436e001aeb57e861e3 /packages/meshbay-hub/src/meshbay_hub/api
parent35130e5528a52161630fd1c93572e1b2b7cd911b (diff)
downloadmeshbay-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.py12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/deps.py40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py77
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py71
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py140
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: