aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-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
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(),
)