diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:39:34 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:39:34 +0200 |
| commit | fb91c4545c757711e1b5fd354ca4b311c89fd2c0 (patch) | |
| tree | eb1aee6cc0fb5fc020eed2763009dea5a32eb8ee | |
| parent | 77d76421829161df6b1ef628b4e6e051a2c3c2ee (diff) | |
| download | meshbay-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>
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/deps.py | 47 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/groups.py | 118 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/hub.py | 26 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/middleware.py | 13 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/nodes.py | 66 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/users.py | 199 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/app.py | 79 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/auth.py | 145 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/config.py | 95 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/daemon.py | 31 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/__init__.py | 0 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/conftest.py | 64 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_hub_api.py | 263 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/__init__.py | 0 |
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 |