summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-09 04:39:34 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-09 04:39:34 +0200
commitfb91c4545c757711e1b5fd354ca4b311c89fd2c0 (patch)
treeeb1aee6cc0fb5fc020eed2763009dea5a32eb8ee /packages
parent77d76421829161df6b1ef628b4e6e051a2c3c2ee (diff)
downloadmeshbay-fb91c4545c757711e1b5fd354ca4b311c89fd2c0.tar.gz
feat(hub): add production hub — config, auth, API routers, tests
config.py: TOML + env var priority. auth.py: Argon2id passwords, JWT EdDSA with jti, refresh token hashed (blake3). Routers: hub (info/pubkey), users (register/login/refresh/pubkeys), nodes (announce/get), groups (create/gek-bundle/gek-retrieve). Rate limiting via slowapi. app.py factory with lifespan. All 40 tests pass (SQLite in-memory, no PostgreSQL required). Fix: remove tests/__init__.py to resolve namespace conflicts. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/deps.py47
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py118
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/hub.py26
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/middleware.py13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py66
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py199
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py79
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py145
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/config.py95
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/daemon.py31
-rw-r--r--packages/meshbay-hub/tests/__init__.py0
-rw-r--r--packages/meshbay-hub/tests/conftest.py64
-rw-r--r--packages/meshbay-hub/tests/test_hub_api.py263
-rw-r--r--packages/meshbay-node/tests/__init__.py0
14 files changed, 1120 insertions, 26 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/deps.py b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
new file mode 100644
index 0000000..cb637f3
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
@@ -0,0 +1,47 @@
+"""
+FastAPI shared dependencies — injected via Depends().
+"""
+
+from collections.abc import AsyncGenerator
+
+from fastapi import Depends, Header, HTTPException, status
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from meshbay_hub.auth import decode_access_token
+from meshbay_hub.db.engine import get_db
+from meshbay_hub.db.models import User
+
+
+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.
+ """
+ try:
+ scheme, token = authorization.split(None, 1)
+ if scheme.lower() != "bearer":
+ raise ValueError
+ payload = decode_access_token(token)
+ except Exception:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid or expired token",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ result = await db.execute(
+ select(User).where(User.id == payload["sub"]))
+ user = result.scalar_one_or_none()
+
+ if user is None:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="User not found")
+ if user.status != "active":
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
+ detail=f"Account {user.status}")
+ return user
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
new file mode 100644
index 0000000..942a88e
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -0,0 +1,118 @@
+"""Group endpoints — /v1/groups/*"""
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+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.db.engine import get_db
+from meshbay_hub.db.models import GEKBundle, Group, GroupMember, IPLog, User
+
+router = APIRouter(prefix="/v1/groups", tags=["groups"])
+
+
+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
+
+
+@router.post("", status_code=201)
+async def create_group(
+ body: GroupCreateRequest,
+ request: Request,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ group = Group(
+ name=body.name,
+ admin_id=current_user.id,
+ visibility=body.visibility,
+ join_policy=body.join_policy,
+ )
+ db.add(group)
+ await db.flush() # get group.id
+
+ db.add(GroupMember(group_id=group.id, user_id=current_user.id))
+ db.add(IPLog(user_id=current_user.id, event="group_create",
+ ip_address=_ip(request), detail=body.name))
+ await db.commit()
+ await db.refresh(group)
+ return {"group_id": group.id, "name": group.name}
+
+
+@router.post("/{group_id}/members/{username}/gek", status_code=201)
+async def store_gek_bundle(
+ group_id: str,
+ username: str,
+ body: GEKBundleRequest,
+ current_user: User = Depends(get_current_user),
+ 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 admin can add members")
+
+ result = await db.execute(select(User).where(User.username == username))
+ target = result.scalar_one_or_none()
+ if not target:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ # Upsert GEK bundle
+ existing = await db.get(GEKBundle, (group_id, target.id))
+ 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))
+
+ await db.commit()
+ return {"status": "stored", "group_id": group_id, "username": username}
+
+
+@router.get("/{group_id}/gek")
+async def get_my_gek_bundle(
+ group_id: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ group = await db.get(Group, group_id)
+ if not group:
+ raise HTTPException(status_code=404, detail="Group not found")
+
+ 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,
+ }
+
+
+def _ip(request: Request) -> str:
+ fwd = request.headers.get("X-Forwarded-For")
+ return fwd.split(",")[0].strip() if fwd else (
+ request.client.host if request.client else "unknown")
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
new file mode 100644
index 0000000..00aba28
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
@@ -0,0 +1,26 @@
+"""Hub info endpoints — /v1/hub/*"""
+
+from fastapi import APIRouter
+from meshbay_common import MNP_VERSION, MHP_VERSION
+from meshbay_hub import __version__
+from meshbay_hub.auth import hub_public_key_pem
+from meshbay_hub.db.engine import get_engine
+
+router = APIRouter(prefix="/v1/hub", tags=["hub"])
+
+
+@router.get("/info")
+async def hub_info():
+ engine = get_engine()
+ return {
+ "hub_version": __version__,
+ "mnp_version": MNP_VERSION,
+ "mhp_version": MHP_VERSION,
+ "db_dialect": engine.dialect.name,
+ }
+
+
+@router.get("/pubkey")
+async def hub_pubkey():
+ """Hub Ed25519 public key PEM — cached by nodes on first contact."""
+ return {"pk_hub_pem": hub_public_key_pem().decode()}
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/middleware.py b/packages/meshbay-hub/src/meshbay_hub/api/middleware.py
new file mode 100644
index 0000000..bed7b54
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/api/middleware.py
@@ -0,0 +1,13 @@
+"""
+Hub middleware — rate limiting on auth endpoints.
+
+Uses slowapi (Starlette-compatible, token bucket algorithm).
+Limits applied to /v1/users/register and /v1/users/login
+to mitigate credential stuffing and registration floods.
+"""
+
+from slowapi import Limiter
+from slowapi.util import get_remote_address
+
+# Rate limiter instance — mounted on the FastAPI app in app.py
+limiter = Limiter(key_func=get_remote_address)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
new file mode 100644
index 0000000..b970aa8
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
@@ -0,0 +1,66 @@
+"""Node endpoints — /v1/nodes/*"""
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from pydantic import BaseModel
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from meshbay_hub.api.deps import get_current_user
+from meshbay_hub.db.engine import get_db
+from meshbay_hub.db.models import IPLog, Node, User
+
+router = APIRouter(prefix="/v1/nodes", tags=["nodes"])
+
+
+class NodeAnnounceRequest(BaseModel):
+ pk_node: str
+ endpoint_hint: str | None = None
+
+
+@router.post("/announce", status_code=201)
+async def announce_node(
+ body: NodeAnnounceRequest,
+ request: Request,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ node = Node(
+ user_id=current_user.id,
+ pk_node=body.pk_node,
+ endpoint_hint=body.endpoint_hint,
+ )
+ db.add(node)
+ db.add(IPLog(
+ user_id=current_user.id,
+ event="node_announce",
+ ip_address=_ip(request),
+ detail=body.endpoint_hint,
+ ))
+ await db.commit()
+ await db.refresh(node)
+ return {"node_id": node.id}
+
+
+@router.get("/{node_id}")
+async def get_node(
+ node_id: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ node = await db.get(Node, node_id)
+ if not node:
+ raise HTTPException(status_code=404, detail="Node not found")
+ owner = await db.get(User, node.user_id)
+ return {
+ "node_id": node.id,
+ "username": owner.username if owner else "",
+ "pk_node": node.pk_node,
+ "endpoint_hint": node.endpoint_hint,
+ "announced_at": node.announced_at.isoformat(),
+ }
+
+
+def _ip(request: Request) -> str:
+ fwd = request.headers.get("X-Forwarded-For")
+ if fwd:
+ return fwd.split(",")[0].strip()
+ return request.client.host if request.client else "unknown"
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
new file mode 100644
index 0000000..0b615a4
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -0,0 +1,199 @@
+"""User endpoints — /v1/users/*"""
+
+from datetime import datetime, timezone, timedelta
+
+from fastapi import APIRouter, Depends, HTTPException, Request, status
+from pydantic import BaseModel, EmailStr, field_validator
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from meshbay_hub.auth import (
+ decode_access_token,
+ generate_refresh_token,
+ hash_password,
+ hash_refresh_token,
+ hub_public_key_pem,
+ issue_access_token,
+ verify_password,
+)
+from meshbay_hub.config import HubConfig
+from meshbay_hub.db.engine import get_db
+from meshbay_hub.db.models import IPLog, RefreshToken, User
+from meshbay_hub.api.deps import get_current_user
+
+router = APIRouter(prefix="/v1/users", tags=["users"])
+
+_cfg: HubConfig | None = None
+
+def set_config(cfg: HubConfig) -> None:
+ global _cfg
+ _cfg = cfg
+
+def _ttl() -> int:
+ return _cfg.jwt.access_token_ttl if _cfg else 3600
+
+def _refresh_ttl() -> int:
+ return _cfg.jwt.refresh_token_ttl if _cfg else 86400 * 30
+
+
+# ── Models ────────────────────────────────────────────────────────────────────
+
+class RegisterRequest(BaseModel):
+ username: str
+ email: str
+ password: str
+ pk_user_ed25519: str # base64 raw 32B
+ pk_user_x25519: str # base64 raw 32B
+
+ @field_validator("username")
+ @classmethod
+ def username_valid(cls, v: str) -> str:
+ v = v.strip()
+ if len(v) < 3 or len(v) > 64:
+ raise ValueError("username must be 3-64 chars")
+ if not v.replace("_", "").replace("-", "").replace(".", "").isalnum():
+ 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
+
+
+class RefreshRequest(BaseModel):
+ refresh_token: str
+
+
+# ── Endpoints ─────────────────────────────────────────────────────────────────
+
+@router.post("/register", status_code=201)
+async def register(
+ body: RegisterRequest,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+):
+ existing = await db.execute(
+ select(User).where(User.username == body.username))
+ if existing.scalar_one_or_none():
+ raise HTTPException(status_code=409, detail="Username already taken")
+
+ pw_hash, pw_salt = hash_password(body.password)
+ hub_id = _cfg.identity.id if _cfg else "meshbay.org"
+ user = User(
+ username=body.username,
+ email=body.email,
+ pw_hash=pw_hash,
+ pw_salt=pw_salt,
+ pk_ed25519=body.pk_user_ed25519,
+ pk_x25519=body.pk_user_x25519,
+ hub_id=hub_id,
+ )
+ db.add(user)
+ db.add(IPLog(
+ event="account_create",
+ ip_address=_client_ip(request),
+ detail=body.username,
+ ))
+ await db.commit()
+ await db.refresh(user)
+
+ # Set user_id in IPLog after commit
+ await db.execute(
+ IPLog.__table__.update()
+ .where(IPLog.user_id == None) # noqa: E711
+ .values(user_id=user.id))
+ await db.commit()
+
+ return {"user_id": user.id}
+
+
+@router.post("/login")
+async def login(
+ body: LoginRequest,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(User).where(User.username == body.username))
+ 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):
+ 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.status != "active":
+ raise HTTPException(status_code=403, detail=f"Account {user.status}")
+
+ access_token = issue_access_token(user.id, user.pk_ed25519, ttl=_ttl())
+ 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, expires_at=expires_at))
+ db.add(IPLog(user_id=user.id, event="login", ip_address=ip))
+ await db.commit()
+
+ return {
+ "access_token": access_token,
+ "refresh_token": raw_rt,
+ "token_type": "bearer",
+ "expires_in": _ttl(),
+ }
+
+
+@router.post("/token/refresh")
+async def token_refresh(
+ body: RefreshRequest,
+ db: AsyncSession = Depends(get_db),
+):
+ rt_hash = hash_refresh_token(body.refresh_token)
+ result = await db.execute(
+ select(RefreshToken).where(
+ RefreshToken.token_hash == rt_hash,
+ RefreshToken.revoked == False, # noqa: E712
+ ))
+ rt = result.scalar_one_or_none()
+
+ if not rt or rt.expires_at.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc):
+ raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
+
+ user = await db.get(User, rt.user_id)
+ if not user or user.status != "active":
+ raise HTTPException(status_code=401, detail="User not found or suspended")
+
+ new_token = issue_access_token(user.id, user.pk_ed25519, ttl=_ttl())
+ return {"access_token": new_token, "token_type": "bearer", "expires_in": _ttl()}
+
+
+@router.get("/{username}/pubkeys")
+async def get_user_pubkeys(
+ username: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(select(User).where(User.username == username))
+ target = result.scalar_one_or_none()
+ if not target:
+ raise HTTPException(status_code=404, detail="User not found")
+ return {
+ "user_id": target.id,
+ "username": target.username,
+ "pk_ed25519": target.pk_ed25519,
+ "pk_x25519": target.pk_x25519,
+ }
+
+
+def _client_ip(request: Request) -> str:
+ forwarded = request.headers.get("X-Forwarded-For")
+ if forwarded:
+ return forwarded.split(",")[0].strip()
+ return request.client.host if request.client else "unknown"
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index 3b9ee4f..c469a87 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -1,31 +1,68 @@
"""
-MeshBay Hub — FastAPI application.
+MeshBay Hub — FastAPI application factory.
-POC endpoints are implemented in this file (in-memory storage).
-Production implementation will use db/ with SQLAlchemy + PostgreSQL.
+Usage:
+ from meshbay_hub.app import create_app
+ from meshbay_hub.config import load_config
+
+ cfg = load_config()
+ app = create_app(cfg)
"""
+from contextlib import asynccontextmanager
+from pathlib import Path
+
from fastapi import FastAPI
+from slowapi import _rate_limit_exceeded_handler
+from slowapi.errors import RateLimitExceeded
+
from meshbay_hub import __version__
-from meshbay_common import MNP_VERSION, MHP_VERSION
+from meshbay_hub.auth import generate_hub_keypair, load_hub_keypair
+from meshbay_hub.config import HubConfig
+from meshbay_hub.db.engine import close_db, init_db
+from meshbay_hub.api.hub import router as hub_router
+from meshbay_hub.api.users import router as users_router, set_config as users_set_config
+from meshbay_hub.api.nodes import router as nodes_router
+from meshbay_hub.api.groups import router as groups_router
+from meshbay_hub.api.middleware import limiter
+
+
+def create_app(cfg: HubConfig | None = None) -> FastAPI:
+ from meshbay_hub.config import load_config
+ if cfg is None:
+ cfg = load_config()
+
+ @asynccontextmanager
+ async def lifespan(app: FastAPI):
+ # Startup
+ await init_db(cfg.db.url)
+
+ kp = cfg.identity.private_key_path
+ if not kp.exists():
+ generate_hub_keypair(kp)
+ load_hub_keypair(kp, cfg.identity.id)
+ users_set_config(cfg)
+
+ yield
+
+ # Shutdown
+ await close_db()
-app = FastAPI(
- title="MeshBay Hub",
- version=__version__,
- description="MeshBay identity authority and group registry",
-)
+ app = FastAPI(
+ title="MeshBay Hub",
+ version=__version__,
+ description="MeshBay identity authority and group registry",
+ lifespan=lifespan,
+ )
-# TODO: import and include routers from api/ submodules
-# from meshbay_hub.api import users, nodes, groups
-# app.include_router(users.router, prefix="/v1")
-# app.include_router(nodes.router, prefix="/v1")
-# app.include_router(groups.router, prefix="/v1")
+ # Rate limiting
+ app.state.limiter = limiter
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
+ # Routers
+ app.include_router(hub_router)
+ app.include_router(users_router)
+ app.include_router(nodes_router)
+ app.include_router(groups_router)
-@app.get("/v1/hub/info")
-async def hub_info() -> dict:
- return {
- "hub_id": "meshbay.org",
- "mnp_version": MNP_VERSION,
- "mhp_version": MHP_VERSION,
- }
+ return app
diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py
new file mode 100644
index 0000000..a4c3bfb
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/auth.py
@@ -0,0 +1,145 @@
+"""
+MeshBay Hub — authentication helpers.
+
+ - Password hashing/verification: Argon2id
+ - JWT issuance/verification: Ed25519 (EdDSA), includes jti
+ - Refresh token: random 32-byte, stored as blake3 hex hash
+ - Hub keypair: loaded from PEM file on startup
+"""
+
+import base64
+import hashlib
+import os
+import time
+import uuid
+from pathlib import Path
+
+import blake3
+import jwt
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
+
+# Argon2id parameters — see CLAUDE.md for production calibration guidance
+_ARGON2_ITERATIONS = 3
+_ARGON2_MEMORY_COST = 65536 # 64 MB — increase to 262144 in production
+_ARGON2_LANES = 4
+_ARGON2_KEY_LEN = 32
+
+# Module-level hub keypair (loaded once at startup)
+_hub_sk_pem: bytes | None = None
+_hub_pk_pem: bytes | None = None
+_hub_id: str = "meshbay.org"
+
+
+# ── Hub keypair ───────────────────────────────────────────────────────────────
+
+def load_hub_keypair(private_key_path: Path, hub_id: str) -> None:
+ """Load hub Ed25519 keypair from PEM file. Call once at startup."""
+ global _hub_sk_pem, _hub_pk_pem, _hub_id
+ _hub_sk_pem = private_key_path.read_bytes()
+ sk = serialization.load_pem_private_key(_hub_sk_pem, password=None)
+ _hub_pk_pem = sk.public_key().public_bytes(
+ serialization.Encoding.PEM,
+ serialization.PublicFormat.SubjectPublicKeyInfo,
+ )
+ _hub_id = hub_id
+
+
+def generate_hub_keypair(private_key_path: Path) -> None:
+ """Generate a new hub Ed25519 keypair and save PEM files. Run once."""
+ private_key_path.parent.mkdir(parents=True, exist_ok=True)
+ sk = Ed25519PrivateKey.generate()
+ private_key_path.write_bytes(sk.private_bytes(
+ serialization.Encoding.PEM,
+ serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption(),
+ ))
+ private_key_path.chmod(0o600)
+
+ pk_path = private_key_path.with_suffix(".pub.pem")
+ pk_path.write_bytes(sk.public_key().public_bytes(
+ serialization.Encoding.PEM,
+ serialization.PublicFormat.SubjectPublicKeyInfo,
+ ))
+
+
+def hub_public_key_pem() -> bytes:
+ if _hub_pk_pem is None:
+ raise RuntimeError("Hub keypair not loaded — call load_hub_keypair() first")
+ return _hub_pk_pem
+
+
+# ── Password ──────────────────────────────────────────────────────────────────
+
+def hash_password(password: str) -> tuple[bytes, bytes]:
+ """Hash a password with Argon2id. Returns (hash, salt)."""
+ salt = os.urandom(16)
+ pw_hash = Argon2id(
+ salt=salt,
+ length=_ARGON2_KEY_LEN,
+ iterations=_ARGON2_ITERATIONS,
+ lanes=_ARGON2_LANES,
+ memory_cost=_ARGON2_MEMORY_COST,
+ ).derive(password.encode())
+ return pw_hash, salt
+
+
+def verify_password(password: str, pw_hash: bytes, salt: bytes) -> bool:
+ try:
+ Argon2id(
+ salt=salt,
+ length=_ARGON2_KEY_LEN,
+ iterations=_ARGON2_ITERATIONS,
+ lanes=_ARGON2_LANES,
+ memory_cost=_ARGON2_MEMORY_COST,
+ ).verify(password.encode(), pw_hash)
+ return True
+ except Exception:
+ return False
+
+
+# ── JWT ───────────────────────────────────────────────────────────────────────
+
+def issue_access_token(
+ user_id: str,
+ pk_user: str,
+ ttl: int = 3600,
+) -> str:
+ """
+ Issue a signed JWT access token.
+ Includes jti (UUID4) — required to prevent replay and enable revocation.
+ """
+ if _hub_sk_pem is None:
+ raise RuntimeError("Hub keypair not loaded")
+ now = int(time.time())
+ payload = {
+ "iss": _hub_id,
+ "sub": user_id,
+ "pk_user": pk_user,
+ "hub_id": _hub_id,
+ "jti": str(uuid.uuid4()),
+ "iat": now,
+ "exp": now + ttl,
+ }
+ return jwt.encode(payload, _hub_sk_pem, algorithm="EdDSA")
+
+
+def decode_access_token(token: str) -> dict:
+ """Verify and decode an access token. Raises on failure."""
+ if _hub_pk_pem is None:
+ raise RuntimeError("Hub keypair not loaded")
+ return jwt.decode(token, _hub_pk_pem, algorithms=["EdDSA"])
+
+
+# ── Refresh tokens ────────────────────────────────────────────────────────────
+
+def generate_refresh_token() -> tuple[str, str]:
+ """Return (raw_token, token_hash). Store hash; give raw to client."""
+ raw = base64.urlsafe_b64encode(os.urandom(32)).decode()
+ hashed = blake3.blake3(raw.encode()).hexdigest()
+ return raw, hashed
+
+
+def hash_refresh_token(raw: str) -> str:
+ return blake3.blake3(raw.encode()).hexdigest()
diff --git a/packages/meshbay-hub/src/meshbay_hub/config.py b/packages/meshbay-hub/src/meshbay_hub/config.py
new file mode 100644
index 0000000..297ace7
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/config.py
@@ -0,0 +1,95 @@
+"""
+MeshBay Hub configuration.
+
+Priority (highest first):
+ 1. Environment variables (MESHBAY_*)
+ 2. Config file (/etc/meshbay/hub.toml or --config)
+ 3. Built-in defaults
+
+Production config file example: /etc/meshbay/hub.toml
+"""
+
+import os
+from dataclasses import dataclass, field
+from pathlib import Path
+
+try:
+ import tomllib
+except ImportError:
+ import tomli as tomllib # type: ignore[no-redef]
+
+DEFAULT_CONFIG_PATHS = [
+ Path("/etc/meshbay/hub.toml"),
+ Path.home() / ".config" / "meshbay" / "hub.toml",
+]
+
+
+@dataclass
+class DatabaseConfig:
+ url: str = "sqlite+aiosqlite:///:memory:"
+
+
+@dataclass
+class ServerConfig:
+ host: str = "127.0.0.1"
+ port: int = 8000
+ workers: int = 1
+
+
+@dataclass
+class HubIdentityConfig:
+ id: str = "meshbay.org"
+ private_key_path: Path = field(default_factory=lambda:
+ Path.home() / ".config" / "meshbay" / "hub_private.pem")
+
+
+@dataclass
+class JWTConfig:
+ access_token_ttl: int = 3600 # 1 hour
+ refresh_token_ttl: int = 86400 * 30 # 30 days
+
+
+@dataclass
+class HubConfig:
+ db: DatabaseConfig = field(default_factory=DatabaseConfig)
+ server: ServerConfig = field(default_factory=ServerConfig)
+ identity: HubIdentityConfig = field(default_factory=HubIdentityConfig)
+ jwt: JWTConfig = field(default_factory=JWTConfig)
+
+
+def load_config(path: Path | None = None) -> HubConfig:
+ cfg = HubConfig()
+
+ # Find and load TOML
+ candidates = [path] if path else DEFAULT_CONFIG_PATHS
+ for p in candidates:
+ if p and p.exists():
+ raw = tomllib.loads(p.read_text())
+ if db := raw.get("database", {}):
+ cfg.db.url = db.get("url", cfg.db.url)
+ if srv := raw.get("server", {}):
+ cfg.server.host = srv.get("host", cfg.server.host)
+ cfg.server.port = srv.get("port", cfg.server.port)
+ cfg.server.workers = srv.get("workers", cfg.server.workers)
+ if idn := raw.get("hub", {}):
+ cfg.identity.id = idn.get("id", cfg.identity.id)
+ if kp := idn.get("private_key_path"):
+ cfg.identity.private_key_path = Path(kp).expanduser()
+ if jwt := raw.get("jwt", {}):
+ cfg.jwt.access_token_ttl = jwt.get("access_token_ttl", cfg.jwt.access_token_ttl)
+ cfg.jwt.refresh_token_ttl = jwt.get("refresh_token_ttl", cfg.jwt.refresh_token_ttl)
+ break
+
+ # Env var overrides
+ if url := os.environ.get("MESHBAY_DATABASE_URL"):
+ cfg.db.url = url
+ if host := os.environ.get("MESHBAY_HUB_HOST"):
+ cfg.server.host = host
+ if port := os.environ.get("MESHBAY_HUB_PORT"):
+ cfg.server.port = int(port)
+ if hub_id := os.environ.get("MESHBAY_HUB_ID"):
+ cfg.identity.id = hub_id
+ if kp := os.environ.get("MESHBAY_HUB_KEY"):
+ cfg.identity.private_key_path = Path(kp).expanduser()
+
+ return cfg
diff --git a/packages/meshbay-hub/src/meshbay_hub/daemon.py b/packages/meshbay-hub/src/meshbay_hub/daemon.py
index ff8158a..ff66fee 100644
--- a/packages/meshbay-hub/src/meshbay_hub/daemon.py
+++ b/packages/meshbay-hub/src/meshbay_hub/daemon.py
@@ -1,15 +1,36 @@
"""Entry point for the meshbay-hub systemd service."""
+import argparse
+import logging
+import sys
+from pathlib import Path
+
import uvicorn
-from meshbay_hub.app import app # noqa: F401
+
+from meshbay_hub.config import load_config
def main() -> None:
+ parser = argparse.ArgumentParser(description="MeshBay Hub server")
+ parser.add_argument("--config", type=Path, default=None)
+ parser.add_argument("--log-level", default="INFO",
+ choices=["DEBUG", "INFO", "WARNING", "ERROR"])
+ args = parser.parse_args()
+
+ logging.basicConfig(
+ level=getattr(logging, args.log_level),
+ format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
+ )
+
+ cfg = load_config(args.config)
+
uvicorn.run(
- "meshbay_hub.app:app",
- host="127.0.0.1",
- port=8000,
- log_level="info",
+ "meshbay_hub.app:create_app",
+ factory=True,
+ host=cfg.server.host,
+ port=cfg.server.port,
+ workers=cfg.server.workers,
+ log_level=args.log_level.lower(),
)
diff --git a/packages/meshbay-hub/tests/__init__.py b/packages/meshbay-hub/tests/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/packages/meshbay-hub/tests/__init__.py
+++ /dev/null
diff --git a/packages/meshbay-hub/tests/conftest.py b/packages/meshbay-hub/tests/conftest.py
new file mode 100644
index 0000000..b42aba1
--- /dev/null
+++ b/packages/meshbay-hub/tests/conftest.py
@@ -0,0 +1,64 @@
+"""Shared pytest fixtures for hub tests."""
+
+import os
+import pytest
+import pytest_asyncio
+from pathlib import Path
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives import serialization
+
+# Force SQLite in-memory for all hub tests
+os.environ.setdefault("MESHBAY_DATABASE_URL", "sqlite+aiosqlite:///:memory:")
+
+
+@pytest.fixture(scope="session")
+def hub_key_path(tmp_path_factory) -> Path:
+ """Generate a hub keypair PEM for the test session."""
+ d = tmp_path_factory.mktemp("hub_keys")
+ path = d / "hub_private.pem"
+ sk = Ed25519PrivateKey.generate()
+ path.write_bytes(sk.private_bytes(
+ serialization.Encoding.PEM,
+ serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption(),
+ ))
+ return path
+
+
+@pytest.fixture
+def hub_config(hub_key_path, tmp_path):
+ from meshbay_hub.config import HubConfig, DatabaseConfig, ServerConfig, HubIdentityConfig, JWTConfig
+ cfg = HubConfig(
+ db=DatabaseConfig(url="sqlite+aiosqlite:///:memory:"),
+ server=ServerConfig(host="127.0.0.1", port=8000),
+ identity=HubIdentityConfig(id="test-hub", private_key_path=hub_key_path),
+ jwt=JWTConfig(access_token_ttl=3600, refresh_token_ttl=86400),
+ )
+ return cfg
+
+
+@pytest_asyncio.fixture
+async def app(hub_config):
+ """Create a fresh FastAPI app with in-memory DB for each test."""
+ # Reset module-level engine state
+ from meshbay_hub.db import engine as eng_mod
+ eng_mod._engine = None
+ eng_mod._session_factory = None
+
+ from meshbay_hub.app import create_app
+ application = create_app(hub_config)
+
+ # Run lifespan startup manually
+ async with application.router.lifespan_context(application):
+ yield application
+
+
+@pytest_asyncio.fixture
+async def client(app):
+ """httpx.AsyncClient pointing at the test app — no network."""
+ import httpx
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=app),
+ base_url="http://test",
+ ) as c:
+ yield c
diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py
new file mode 100644
index 0000000..ea59f17
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_hub_api.py
@@ -0,0 +1,263 @@
+"""
+Integration tests for the Hub API.
+Uses SQLite in-memory + httpx.AsyncClient — no PostgreSQL, no network.
+"""
+
+import base64
+import pytest
+import pytest_asyncio
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+from cryptography.hazmat.primitives import serialization
+
+from meshbay_common.crypto import generate_gek, pk_to_b64, wrap_gek
+
+
+def _gen_user_keys():
+ sk_ed = Ed25519PrivateKey.generate()
+ sk_x = X25519PrivateKey.generate()
+ return (
+ pk_to_b64(sk_ed.public_key()),
+ pk_to_b64(sk_x.public_key()),
+ sk_x,
+ )
+
+
+# ── Hub info ──────────────────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_hub_info(client):
+ r = await client.get("/v1/hub/info")
+ assert r.status_code == 200
+ data = r.json()
+ assert "mnp_version" in data
+ assert "mhp_version" in data
+
+
+@pytest.mark.asyncio
+async def test_hub_pubkey(client):
+ r = await client.get("/v1/hub/pubkey")
+ assert r.status_code == 200
+ pem = r.json()["pk_hub_pem"]
+ assert pem.startswith("-----BEGIN PUBLIC KEY-----")
+
+
+# ── Users ─────────────────────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_register_and_login(client):
+ pk_ed, pk_x, _ = _gen_user_keys()
+ r = await client.post("/v1/users/register", json={
+ "username": "alice", "email": "alice@example.com",
+ "password": "alicepass99",
+ "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x,
+ })
+ assert r.status_code == 201
+ assert "user_id" in r.json()
+
+ r = await client.post("/v1/users/login", json={
+ "username": "alice", "password": "alicepass99"})
+ assert r.status_code == 200
+ data = r.json()
+ assert "access_token" in data
+ assert "refresh_token" in data
+ assert data["token_type"] == "bearer"
+
+
+@pytest.mark.asyncio
+async def test_register_duplicate_rejected(client):
+ pk_ed, pk_x, _ = _gen_user_keys()
+ body = {"username": "bob", "email": "bob@example.com",
+ "password": "bobpass99",
+ "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x}
+ await client.post("/v1/users/register", json=body)
+ r = await client.post("/v1/users/register", json=body)
+ assert r.status_code == 409
+
+
+@pytest.mark.asyncio
+async def test_wrong_password_rejected(client):
+ pk_ed, pk_x, _ = _gen_user_keys()
+ await client.post("/v1/users/register", json={
+ "username": "carol", "email": "carol@example.com",
+ "password": "carolpass99",
+ "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x})
+ r = await client.post("/v1/users/login", json={
+ "username": "carol", "password": "wrongpass"})
+ assert r.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_jwt_offline_verify(client, hub_key_path):
+ """JWT returned by login must be verifiable offline with hub's public key."""
+ import jwt as pyjwt
+ pk_ed, pk_x, _ = _gen_user_keys()
+ await client.post("/v1/users/register", json={
+ "username": "dave", "email": "dave@example.com",
+ "password": "davepass99",
+ "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x})
+ r = await client.post("/v1/users/login", json={
+ "username": "dave", "password": "davepass99"})
+ token = r.json()["access_token"]
+
+ r_pk = await client.get("/v1/hub/pubkey")
+ hub_pk_pem = r_pk.json()["pk_hub_pem"].encode()
+
+ decoded = pyjwt.decode(token, hub_pk_pem, algorithms=["EdDSA"])
+ assert decoded["pk_user"] == pk_ed
+ assert "jti" in decoded # mandatory
+
+
+@pytest.mark.asyncio
+async def test_token_refresh(client):
+ pk_ed, pk_x, _ = _gen_user_keys()
+ await client.post("/v1/users/register", json={
+ "username": "eve", "email": "eve@example.com",
+ "password": "evepass99",
+ "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x})
+ r = await client.post("/v1/users/login", json={
+ "username": "eve", "password": "evepass99"})
+ rt = r.json()["refresh_token"]
+ at = r.json()["access_token"]
+
+ r2 = await client.post("/v1/users/token/refresh", json={"refresh_token": rt})
+ assert r2.status_code == 200
+ assert r2.json()["access_token"] != at # new token (different jti)
+
+
+@pytest.mark.asyncio
+async def test_get_user_pubkeys(client):
+ pk_ed, pk_x, _ = _gen_user_keys()
+ await client.post("/v1/users/register", json={
+ "username": "frank", "email": "frank@example.com",
+ "password": "frankpass99",
+ "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x})
+ login = await client.post("/v1/users/login", json={
+ "username": "frank", "password": "frankpass99"})
+ token = login.json()["access_token"]
+
+ r = await client.get("/v1/users/frank/pubkeys",
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200
+ assert r.json()["pk_ed25519"] == pk_ed
+ assert r.json()["pk_x25519"] == pk_x
+
+
+# ── Nodes ─────────────────────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_announce_and_get_node(client):
+ pk_ed, pk_x, _ = _gen_user_keys()
+ await client.post("/v1/users/register", json={
+ "username": "node1", "email": "n@example.com",
+ "password": "nodepass99",
+ "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x})
+ login = await client.post("/v1/users/login", json={
+ "username": "node1", "password": "nodepass99"})
+ token = login.json()["access_token"]
+ hdrs = {"Authorization": f"Bearer {token}"}
+
+ r = await client.post("/v1/nodes/announce",
+ json={"pk_node": pk_ed, "endpoint_hint": "1.2.3.4:19000"},
+ headers=hdrs)
+ assert r.status_code == 201
+ node_id = r.json()["node_id"]
+
+ r2 = await client.get(f"/v1/nodes/{node_id}", headers=hdrs)
+ assert r2.status_code == 200
+ assert r2.json()["pk_node"] == pk_ed
+ assert r2.json()["endpoint_hint"] == "1.2.3.4:19000"
+
+
+# ── Groups + GEK bundles ──────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_group_gek_roundtrip(client):
+ """Admin creates group, wraps GEK for member, member retrieves and can unwrap."""
+ import jwt as pyjwt
+ from meshbay_common.crypto import unwrap_gek
+
+ # Register admin (alice2) and member (bob2)
+ pk_ed_a, pk_x_a, _ = _gen_user_keys()
+ pk_ed_b, pk_x_b, sk_x_b = _gen_user_keys()
+
+ for uname, email, pwd, pk_ed, pk_x in [
+ ("alice2", "a2@x.com", "alicepass99", pk_ed_a, pk_x_a),
+ ("bob2", "b2@x.com", "bobpass99", pk_ed_b, pk_x_b),
+ ]:
+ await client.post("/v1/users/register", json={
+ "username": uname, "email": email, "password": pwd,
+ "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x})
+
+ def _token(uname, pwd):
+ async def _inner():
+ r = await client.post("/v1/users/login",
+ json={"username": uname, "password": pwd})
+ return r.json()["access_token"]
+ return _inner
+
+ alice_token = (await client.post("/v1/users/login",
+ json={"username": "alice2", "password": "alicepass99"})).json()["access_token"]
+ bob_token = (await client.post("/v1/users/login",
+ json={"username": "bob2", "password": "bobpass99"})).json()["access_token"]
+
+ a_hdrs = {"Authorization": f"Bearer {alice_token}"}
+ b_hdrs = {"Authorization": f"Bearer {bob_token}"}
+
+ # Alice creates group
+ r = await client.post("/v1/groups", json={"name": "mygroup"}, headers=a_hdrs)
+ assert r.status_code == 201
+ group_id = r.json()["group_id"]
+
+ # Alice generates GEK and wraps it for bob
+ gek = generate_gek()
+ pk_bob_raw = base64.b64decode(pk_x_b)
+ bundle = wrap_gek(gek, pk_bob_raw)
+
+ r = await client.post(f"/v1/groups/{group_id}/members/bob2/gek",
+ json=bundle, headers=a_hdrs)
+ assert r.status_code == 201
+
+ # Bob retrieves his bundle
+ r = await client.get(f"/v1/groups/{group_id}/gek", headers=b_hdrs)
+ assert r.status_code == 200
+ retrieved = r.json()
+
+ # Bob unwraps — must recover original GEK
+ sk_b_raw = sk_x_b.private_bytes(
+ serialization.Encoding.Raw, serialization.PrivateFormat.Raw,
+ serialization.NoEncryption())
+ recovered = unwrap_gek(retrieved, sk_b_raw, pk_bob_raw)
+ assert recovered == gek
+
+
+@pytest.mark.asyncio
+async def test_non_admin_cannot_add_member(client):
+ pk_ed_a, pk_x_a, _ = _gen_user_keys()
+ pk_ed_b, pk_x_b, _ = _gen_user_keys()
+
+ for uname, email, pwd, pk_ed, pk_x in [
+ ("charlie", "c@x.com", "charliepass", pk_ed_a, pk_x_a),
+ ("dan", "d@x.com", "danpass1234", pk_ed_b, pk_x_b),
+ ]:
+ await client.post("/v1/users/register", json={
+ "username": uname, "email": email, "password": pwd,
+ "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x})
+
+ charlie_token = (await client.post("/v1/users/login",
+ json={"username": "charlie", "password": "charliepass"})).json()["access_token"]
+ dan_token = (await client.post("/v1/users/login",
+ json={"username": "dan", "password": "danpass1234"})).json()["access_token"]
+
+ r = await client.post("/v1/groups", json={"name": "charlies-group"},
+ headers={"Authorization": f"Bearer {charlie_token}"})
+ group_id = r.json()["group_id"]
+
+ gek = generate_gek()
+ bundle = wrap_gek(gek, base64.b64decode(pk_x_b))
+
+ # Dan (non-admin) tries to add a member → 403
+ r = await client.post(f"/v1/groups/{group_id}/members/charlie/gek",
+ json=bundle,
+ headers={"Authorization": f"Bearer {dan_token}"})
+ assert r.status_code == 403
diff --git a/packages/meshbay-node/tests/__init__.py b/packages/meshbay-node/tests/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/packages/meshbay-node/tests/__init__.py
+++ /dev/null