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 /packages/meshbay-hub/src | |
| 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>
Diffstat (limited to 'packages/meshbay-hub/src')
| -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 |
10 files changed, 793 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(), ) |