diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 03:52:58 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 03:52:58 +0200 |
| commit | 271adc8504aad32075d75d06fd42023877a649ec (patch) | |
| tree | fe8697761d635a5cac7e0693f2e588a38a7968b9 /poc/spike2_hub.py | |
| download | meshbay-271adc8504aad32075d75d06fd42023877a649ec.tar.gz | |
chore: initialize monorepo structure for MeshBay
3-package layout: meshbay-common (shared crypto/protocol),
meshbay-hub (FastAPI server), meshbay-node (local daemon).
Includes validated POC spikes 1-6 in poc/, architecture drafts
v1/v2 in docs/, and CLAUDE.md project conventions.
All cryptographic primitives extracted from POC into
meshbay_common/crypto.py (GEK wrap/unwrap, chunk key derivation,
keystore encryption, chunk signing).
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'poc/spike2_hub.py')
| -rw-r--r-- | poc/spike2_hub.py | 268 |
1 files changed, 268 insertions, 0 deletions
diff --git a/poc/spike2_hub.py b/poc/spike2_hub.py new file mode 100644 index 0000000..f8c1b08 --- /dev/null +++ b/poc/spike2_hub.py @@ -0,0 +1,268 @@ +""" +MeshBay Hub POC v1 +Minimal FastAPI hub: user registration, JWT issuance, node announcement. +In-memory storage only — not persistent across restarts. +""" + +from fastapi import FastAPI, HTTPException, Depends, Header +from pydantic import BaseModel +from cryptography.hazmat.primitives.kdf.argon2 import Argon2id +import jwt, uuid, os, time, base64 + +app = FastAPI(title="MeshBay Hub POC", version="0.1.0") + +HUB_ID = "meshbay.org" +MNP_VERSION = "0.1" +MHP_VERSION = "0.1" +ACCESS_TOKEN_TTL = 3600 # 1 hour +REFRESH_TOKEN_TTL = 86400 * 30 # 30 days + +# Load hub keypair (generated once with gen_hub_keys.py) +with open("hub_private.pem", "rb") as f: + HUB_SK_PEM = f.read() +with open("hub_public.pem", "rb") as f: + HUB_PK_PEM = f.read() + +# In-memory stores (POC — lost on restart) +users: dict = {} # username → user record +nodes: dict = {} # node_id → node record +refresh_tokens: dict = {} # token → user_id +groups: dict = {} # group_id → group record +gek_bundles: dict = {} # (group_id, user_id) → encrypted GEK bundle + + +# ── Models ──────────────────────────────────────────────────────────────────── + +class UserRegister(BaseModel): + username: str + password: str + pk_user_ed25519: str # base64 raw 32 bytes + pk_user_x25519: str # base64 raw 32 bytes + +class UserLogin(BaseModel): + username: str + password: str + +class RefreshRequest(BaseModel): + refresh_token: str + +class NodeAnnounce(BaseModel): + pk_node: str # base64 Ed25519 raw public key + endpoint_hint: str | None = None # "ip:port" discovered via STUN/UPnP + +class GroupCreate(BaseModel): + name: str + +class GEKBundle(BaseModel): + pk_eph_b64: str # ephemeral X25519 public key used during wrapping + nonce_b64: str # ChaCha20-Poly1305 nonce + wrapped_b64: str # encrypted GEK (opaque to hub) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _hash_password(password: str) -> tuple[bytes, bytes]: + salt = os.urandom(16) + kdf = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536) + return kdf.derive(password.encode()), salt + +def _verify_password(password: str, pw_hash: bytes, salt: bytes) -> bool: + try: + Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536 + ).verify(password.encode(), pw_hash) + return True + except Exception: + return False + +def _issue_access_token(user: dict) -> str: + now = int(time.time()) + payload = { + "iss": HUB_ID, + "sub": user["user_id"], + "pk_user": user["pk_ed25519"], + "hub_id": HUB_ID, + "jti": str(uuid.uuid4()), # unique per token — enables revocation, prevents replay + "iat": now, + "exp": now + ACCESS_TOKEN_TTL, + } + return jwt.encode(payload, HUB_SK_PEM, algorithm="EdDSA") + +def _get_current_user(authorization: str = Header(...)) -> dict: + try: + scheme, token = authorization.split(None, 1) + if scheme.lower() != "bearer": + raise ValueError("Not bearer") + payload = jwt.decode(token, HUB_PK_PEM, algorithms=["EdDSA"]) + user = next((u for u in users.values() if u["user_id"] == payload["sub"]), None) + if not user: + raise HTTPException(status_code=401, detail="User not found") + return user + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=401, detail="Invalid or expired token") + + +# ── Endpoints ───────────────────────────────────────────────────────────────── + +@app.get("/v1/hub/info") +def hub_info(): + return { + "hub_id": HUB_ID, + "mnp_version": MNP_VERSION, + "mhp_version": MHP_VERSION, + "users": len(users), + "nodes": len(nodes), + } + +@app.get("/v1/hub/pubkey") +def hub_pubkey(): + """Return hub Ed25519 public key PEM — nodes cache this on first contact.""" + return {"pk_hub_pem": HUB_PK_PEM.decode()} + +@app.post("/v1/users/register", status_code=201) +def register(body: UserRegister): + if body.username in users: + raise HTTPException(status_code=409, detail="Username already taken") + if len(body.password) < 8: + raise HTTPException(status_code=422, detail="Password too short") + pw_hash, pw_salt = _hash_password(body.password) + user_id = str(uuid.uuid4()) + users[body.username] = { + "user_id": user_id, + "username": body.username, + "pw_hash": pw_hash, + "pw_salt": pw_salt, + "pk_ed25519": body.pk_user_ed25519, + "pk_x25519": body.pk_user_x25519, + "created_at": int(time.time()), + } + return {"user_id": user_id} + +@app.post("/v1/users/login") +def login(body: UserLogin): + user = users.get(body.username) + if not user or not _verify_password(body.password, user["pw_hash"], user["pw_salt"]): + raise HTTPException(status_code=401, detail="Invalid credentials") + access_token = _issue_access_token(user) + refresh_token = base64.urlsafe_b64encode(os.urandom(32)).decode() + refresh_tokens[refresh_token] = user["user_id"] + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + "expires_in": ACCESS_TOKEN_TTL, + } + +@app.post("/v1/users/token/refresh") +def token_refresh(body: RefreshRequest): + user_id = refresh_tokens.get(body.refresh_token) + if not user_id: + raise HTTPException(status_code=401, detail="Invalid refresh token") + user = next((u for u in users.values() if u["user_id"] == user_id), None) + if not user: + raise HTTPException(status_code=401, detail="User not found") + return { + "access_token": _issue_access_token(user), + "token_type": "bearer", + "expires_in": ACCESS_TOKEN_TTL, + } + +@app.post("/v1/nodes/announce", status_code=201) +def announce_node(body: NodeAnnounce, user: dict = Depends(_get_current_user)): + node_id = str(uuid.uuid4()) + nodes[node_id] = { + "node_id": node_id, + "user_id": user["user_id"], + "username": user["username"], + "pk_node": body.pk_node, + "endpoint_hint": body.endpoint_hint, + "announced_at": int(time.time()), + } + return {"node_id": node_id} + +@app.get("/v1/nodes/{node_id}") +def get_node(node_id: str, user: dict = Depends(_get_current_user)): + node = nodes.get(node_id) + if not node: + raise HTTPException(status_code=404, detail="Node not found") + return { + "node_id": node["node_id"], + "username": node["username"], + "pk_node": node["pk_node"], + "endpoint_hint": node["endpoint_hint"], + "announced_at": node["announced_at"], + } + +@app.get("/v1/users/{username}/pubkeys") +def get_user_pubkeys(username: str, user: dict = Depends(_get_current_user)): + """Return a user's public keys so the admin can wrap the GEK for them.""" + target = users.get(username) + if not target: + raise HTTPException(status_code=404, detail="User not found") + return { + "user_id": target["user_id"], + "username": target["username"], + "pk_ed25519": target["pk_ed25519"], + "pk_x25519": target["pk_x25519"], + } + +@app.post("/v1/groups", status_code=201) +def create_group(body: GroupCreate, user: dict = Depends(_get_current_user)): + group_id = str(uuid.uuid4()) + groups[group_id] = { + "group_id": group_id, + "name": body.name, + "admin_id": user["user_id"], + "admin_name": user["username"], + "created_at": int(time.time()), + "members": [user["user_id"]], + } + return {"group_id": group_id, "name": body.name} + +@app.post("/v1/groups/{group_id}/members/{username}/gek", status_code=201) +def store_gek_bundle( + group_id: str, username: str, + body: GEKBundle, + user: dict = Depends(_get_current_user) +): + """Admin stores an encrypted GEK bundle for a group member. + The hub stores the bundle opaquely — it cannot decrypt it.""" + group = groups.get(group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + if group["admin_id"] != user["user_id"]: + raise HTTPException(status_code=403, detail="Only group admin can add members") + target = users.get(username) + if not target: + raise HTTPException(status_code=404, detail="User not found") + + key = (group_id, target["user_id"]) + gek_bundles[key] = { + "group_id": group_id, + "user_id": target["user_id"], + "pk_eph_b64": body.pk_eph_b64, + "nonce_b64": body.nonce_b64, + "wrapped_b64": body.wrapped_b64, + "stored_at": int(time.time()), + } + if target["user_id"] not in group["members"]: + group["members"].append(target["user_id"]) + return {"status": "stored", "group_id": group_id, "username": username} + +@app.get("/v1/groups/{group_id}/gek") +def get_my_gek_bundle(group_id: str, user: dict = Depends(_get_current_user)): + """Authenticated member retrieves their own encrypted GEK bundle.""" + group = groups.get(group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + key = (group_id, user["user_id"]) + bundle = gek_bundles.get(key) + 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"], + } |