From f0248975908ad670fa8a820f865bf22ea8d0172d Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 13 Aug 2026 03:56:30 +0200 Subject: feat: Phase 12 — P2P crypto material, password split, node Ed25519 auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Baseline commit capturing in-progress Phase 12 work that was already present in the working tree (uncommitted) before the Phase 11.5 security remediation begins. Committed as-is, without review or modification, so that remediation changes arrive as a separable diff. Contents: BundleStore (P2P GEK + keypair bundles), password split (auth_key / bundle_key), node Ed25519 auth (POST /v1/nodes/auth, node-scoped JWT), GEK-HMAC handshake proof with DTLS channel binding, Ed25519 admin challenge-response, node local admin UI rewrite, browser key persistence. Not authored in this session — captured to establish a baseline. Co-Authored-By: Claude Opus 5 --- docs/meshbay-draft-v4.md | 221 +++- .../meshbay-common/src/meshbay_common/protocol.py | 12 + packages/meshbay-hub/src/meshbay_hub/api/admin.py | 12 +- packages/meshbay-hub/src/meshbay_hub/api/deps.py | 40 +- packages/meshbay-hub/src/meshbay_hub/api/groups.py | 77 +- packages/meshbay-hub/src/meshbay_hub/api/nodes.py | 71 +- packages/meshbay-hub/src/meshbay_hub/api/users.py | 140 ++- packages/meshbay-hub/src/meshbay_hub/auth.py | 6 +- .../meshbay-hub/src/meshbay_hub/db/__init__.py | 4 +- ...4060b3c_add_keypair_bundle_federated_groups_.py | 2 - .../versions/d28b9caf9f07_initial_schema.py | 12 - packages/meshbay-hub/src/meshbay_hub/db/models.py | 25 +- packages/meshbay-hub/src/meshbay_hub/static/app.js | 551 +++++++--- .../meshbay-hub/src/meshbay_hub/static/crypto.js | 16 + .../meshbay-hub/src/meshbay_hub/static/i18n.js | 19 +- .../src/meshbay_hub/static/keyderive.js | 127 ++- .../meshbay-hub/src/meshbay_hub/static/style.css | 192 +++- .../src/meshbay_hub/static/transport.js | 178 ++- packages/meshbay-hub/tests/test_hub_api.py | 279 ++++- packages/meshbay-hub/tests/test_node_auth.py | 281 +++++ packages/meshbay-node/pyproject.toml | 1 + .../meshbay-node/src/meshbay_node/bundle_store.py | 105 ++ packages/meshbay-node/src/meshbay_node/config.py | 12 +- packages/meshbay-node/src/meshbay_node/daemon.py | 160 ++- .../meshbay-node/src/meshbay_node/hub_client.py | 130 +-- .../src/meshbay_node/transport/webrtc_server.py | 353 +++++- packages/meshbay-node/src/meshbay_node/ui/app.py | 163 ++- packages/meshbay-node/tests/test_daemon.py | 44 +- packages/meshbay-node/tests/test_hub_client.py | 76 +- .../meshbay-node/tests/test_webrtc_transport.py | 1152 ++++++++++++++++---- 30 files changed, 3629 insertions(+), 832 deletions(-) create mode 100644 packages/meshbay-hub/tests/test_node_auth.py create mode 100644 packages/meshbay-node/src/meshbay_node/bundle_store.py diff --git a/docs/meshbay-draft-v4.md b/docs/meshbay-draft-v4.md index 7fea132..26c7bf6 100644 --- a/docs/meshbay-draft-v4.md +++ b/docs/meshbay-draft-v4.md @@ -1,7 +1,7 @@ # MeshBay — Architecture Draft v4 -> Status: active development — Phases 1–10b complete (except 10.9 → Phase 13), 166 tests. -> Changes from v3: web client transport (WebRTC DataChannel), web UI architecture, hub roles (admin/moderator), hub mirror design, browser-specific NAT traversal, chat storage clarified, Phase 8 security items resolved, Phase 10 site overlay + admin/moderation UI + notifications + group search + version endpoint, Phase 10b self-service UI (group create/join/invite, file upload, IndexedDB caching, cross-group search). +> Status: active development — Phases 1–12 complete (except 10.9 → Phase 13), 191 tests. +> Changes from v3: web client transport (WebRTC DataChannel), web UI architecture, hub roles (admin/moderator), hub mirror design, browser-specific NAT traversal, chat storage clarified, Phase 8 security items resolved, Phase 10 site overlay + admin/moderation UI + notifications + group search + version endpoint, Phase 10b self-service UI (group create/join/invite, file upload, IndexedDB caching, cross-group search), **node sovereignty model** (§4.2.x — node operator is sole content authority, deny-by-default, uploader_id tracking), **cryptographic sovereignty enforcement** (GEK-HMAC handshake challenge, Ed25519 admin challenge-response, gek_req removed), **Phase 12 — P2P crypto material** (GEK+keypair bundles moved off hub to node BundleStore, password split, key persistence in IndexedDB/sessionStorage, DTLS channel binding fix). --- @@ -21,6 +21,11 @@ The following items are **architectural decisions** driven by Phase 8 implementa | 8 | Hub mirror | Design defined (future implementation): active-active with shared signing key, PostgreSQL logical replication, DNS round-robin. Not implemented yet. | Web client design session | | 9 | Security items | S1 (admin authz), S2 (email encryption), S5 (refresh token rotation) resolved in Phase 8. Argon2id bumped to 256 MB with transparent rehash. | Phase 8 implementation | | 10 | File search | Client-side search on cached indexes (IndexedDB). No hub involvement. Private group indexes are GEK-encrypted — hub stores opaque, client decrypts locally. | Web client design session | +| 11 | P2P crypto material | **ALL crypto material moved off hub to P2P channel.** GEK bundles and keypair bundles stored on node (`BundleStore` SQLite), exchanged via MNP DataChannel. Hub `GEKBundle` model and `/gek` endpoint removed. Hub never touches, stores, or proxies any crypto material. | Phase 12 — T3 attack surface reduction | +| 12 | Password split | Hub receives `auth_key` (PBKDF2-SHA512, auth salt), never raw password. Separate `bundle_key` (PBKDF2-SHA512, bundle salt) encrypts keypair bundles on the node. Hub cannot derive `bundle_key` from `auth_key`. | Phase 12 — T1 | +| 13 | Node auth | Node daemon authenticates to hub via Ed25519 signed timestamp (`POST /v1/nodes/auth`), not password. JWT `scope: "node"` blocks group mutation endpoints. | Phase 12 — NS7 | +| 14 | Key persistence | Browser stores `_bundleKey` (CryptoKey) in IndexedDB and `_sessionKeys` in sessionStorage. Survives page refresh without re-login. Public key derived from recovered private key via JWK export (`_pkFromSk`), no hub dependency. | Phase 12 — browser hardening | +| 15 | DTLS channel binding | Browser saves raw answer SDP before `setRemoteDescription` (Chrome may drop sha-256 fingerprint). GEK-HMAC uses `_rawAnswerSdp` for fingerprint extraction. | Phase 12 — handshake fix | --- @@ -284,6 +289,157 @@ contains the requested group_id before serving any content. Without this check, any authenticated user could access any group on the node. This is enforced at the MNP handshake layer, not the transport layer. +#### 4.2.x Node Sovereignty — Content Authorization Model + +The node operator is the **sole authority** over content stored on their machine. +No external actor — including the hub admin — can modify, delete, or control +files on a node they do not operate. This is a non-negotiable design invariant, +enforced by **cryptography**, not just policy. + +**Two trust domains, strictly separated:** + +| Domain | Authority | Scope | +|---|---|---| +| **Hub** | Hub admin / moderator | User accounts, group registry, group membership, GEK distribution, moderation (suspend user/group at hub level) | +| **Node** | Node operator | Files on disk, file deletion, upload acceptance, chat storage, who can do what with node content | + +The hub certifies **identity** (JWT) and **group membership** (`groups` claim). +The node decides **authorization for content operations** based on that identity. +These two concerns must never be conflated. + +##### Cryptographic enforcement — two defense layers + +A malicious hub admin controls the JWT signing key and could forge JWTs to +impersonate any user, including the node operator. Policy-only checks (comparing +`user_id` to `node_user_id`) are insufficient because the hub controls the +identity layer. Two cryptographic mechanisms make this impossible: + +**Layer 1 — GEK proof in handshake (membership verification):** + +After JWT verification, the node challenges the connecting user to prove they +possess the Group Encryption Key (GEK). The hub never has the GEK — it only +stores opaque ECIES-wrapped bundles. Without the GEK, a hub admin who forges +a JWT still cannot access any group content. + +``` +Client → Node: handshake { token, group_id } +Node: verify JWT, verify group_id in claims + nonce = random(32) +Node → Client: handshake_challenge { nonce: base64(nonce) } +Client: proof = HMAC-SHA256(GEK, nonce) +Client → Node: handshake_response { proof: base64(proof) } +Node: verify HMAC — if wrong, reject connection +Node → Client: handshake_ack { is_node_admin, node_pk, v } +``` + +This blocks: content reading, index reading, chat reading, file upload, chat +injection — ALL operations require passing the GEK proof first. + +**Layer 2 — Ed25519 challenge-response for admin operations:** + +The node operator's Ed25519 public key is pinned locally in `node.toml` +(auto-pinned from keystore on first startup). Destructive operations (file +deletion) require the user to sign a random challenge with their Ed25519 +private key. The hub cannot forge this signature. + +``` +Client → Node: file_delete { file_id } +Node: (if uploader → allow immediately) + (else) challenge = random(32) +Node → Client: admin_challenge { challenge: base64(challenge), file_id } +Client: signature = Ed25519.sign(sk_ed, challenge) +Client → Node: admin_response { signature: base64(signature), file_id } +Node: verify(admin_pk_ed25519, signature, challenge) + if valid → delete file +``` + +**Node configuration — admin key pinning:** + +```toml +# node.toml +admin_pk_ed25519 = "base64-encoded-32-bytes-raw-Ed25519-public-key" +``` + +Auto-pinned from the node operator's keystore on first startup. The daemon +logs: "Admin Ed25519 key pinned for node sovereignty". + +**GEK distribution — browser flow (node no longer serves GEK):** + +The node NEVER serves the GEK in plaintext. Browser clients obtain the GEK +from their hub-stored encrypted bundle: + +1. `GET /v1/groups/{id}/gek` → encrypted ECIES bundle (AES-256-GCM variant) +2. Browser unwraps with its X25519 private key (from keypair bundle) +3. Browser uses raw GEK bytes for the handshake HMAC proof +4. Browser imports GEK as HKDF key for chunk decryption + +This eliminates the `gek_req`/`gek_resp` MNP messages from the protocol. + +**Authorization rules for destructive file operations (enforced by the node):** + +| Action | Who can do it | Enforcement point | +|---|---|---| +| Delete a file | Node operator (Ed25519 challenge-response) OR the user who uploaded it | Node (`_do_file_delete`) | +| Delete any file | Node operator only (Ed25519 challenge-response) | Node (`_do_file_delete`) | + +Default posture: **deny.** If the admin key is not pinned, all admin operations +are refused. If the GEK proof fails, the connection is refused entirely. + +**Protocol enforcement — MNP handshake_ack:** + +The handshake_ack message carries `is_node_admin: bool` — the node tells the +client whether the authenticated user is the node operator. Clients MUST use +this node-reported flag (not the hub's `group.admin_id`) to decide whether +to show destructive operations like file deletion. + +``` +handshake_ack: + v: "0.1" + node_pk: "" + is_node_admin: true | false # node-side authorization, NOT hub-side +``` + +**Index entry — uploader tracking:** + +Each `IndexEntry` carries an `uploader_id` field (user_id of who uploaded the +file, or null for files that pre-existed on disk). This enables the "uploader +can delete their own files" rule without granting node-admin privileges. + +**What the hub admin CANNOT do on a node they don't operate:** +- Delete files (requires Ed25519 key pinned on node — hub can't forge) +- Read files (requires GEK — hub never has it) +- Read index / chat (requires GEK proof in handshake) +- Upload files (requires GEK proof in handshake) +- Impersonate the node operator (JWT forgery blocked by Ed25519 challenge) + +**What the hub admin CAN do (hub-level only):** +- Suspend a user account (blocks JWT issuance → user loses access everywhere) +- Suspend a group (blocks signaling → no new P2P connections to nodes for that group) +- These are hub-level actions that don't touch node content + +**Remaining trust assumptions:** +- The hub serves the SPA code to browsers (a malicious hub could inject JS — fundamentally unsolvable in browser; native client or browser extension required for full integrity) +- The hub relays WebRTC signaling — ✅ MITIGATED: DTLS channel binding in GEK-HMAC proof (`HMAC(GEK, nonce || offer_fp || answer_fp)`) detects fingerprint substitution (MitM) +- The hub receives raw password at login — ✅ MITIGATED: password split (auth_key ≠ bundle_key, independent PBKDF2 derivations). Hub receives auth_key only, cannot derive bundle_key to decrypt keypair bundle. Legacy accounts migrated on first login. +- The hub controls public key distribution — can substitute keys during invite to intercept GEK. Fix: out-of-band key verification (safety numbers) — Phase 12 + +> **Design lesson (2026-08-12):** The initial implementation conflated hub +> `group.admin_id` (who created the group on the hub) with node operator +> authority (who runs the machine). The SPA used the hub's `is_admin` flag +> to show file deletion controls, and the node's delete handler used a +> fail-open check (`if node_user_id and ...` — allowed everyone when +> `node_user_id` was not set). Both violated node sovereignty. Fixed by: +> (1) deny-by-default on the node, (2) `is_node_admin` in handshake_ack, +> (3) `uploader_id` tracking in the index, (4) SPA uses node-reported +> permissions only. Then hardened with cryptographic enforcement: +> (5) GEK-HMAC proof in handshake (blocks forged-JWT access), +> (6) Ed25519 challenge-response for admin ops (blocks identity impersonation), +> (7) removal of `gek_req` endpoint (node never serves GEK in plaintext), +> (8) DTLS channel binding in GEK-HMAC proof to detect WebRTC signaling MitM, +> (9) chat `sender_id` fixed to authenticated identity (prevents impersonation), +> (10) Ed25519 challenge for ALL file deletions — uploaders verified by stored pk, not JWT sub, +> (11) password split — hub receives PBKDF2 auth_key, never raw password (cannot derive bundle_key). + **Platform:** Linux primary, cross-platform from the start (Windows/macOS). Python ensures portability. #### 4.2.1 Keystore and Unlock @@ -477,7 +633,8 @@ Groups are the core organizational unit. |---|---| | Visibility | Public / Private | | Join policy | Open / On request / By invitation only | -| Admin | The hosting node operator (legal host) | +| Node admin | The hosting node operator — sovereign over content, sole delete authority (see §4.2.x) | +| Hub group creator | The user who registered the group on the hub — manages membership and GEK distribution | A public group functions like a themed forum: files, chat thread, member list. Join policy is independent of visibility (a public group can require approval to join). @@ -803,11 +960,26 @@ Application-level protocol over TCP+TLS 1.3 (v1) or QUIC (v2). All messages carr | Type | Description | |---|---| | `handshake` | Key exchange, JWT presentation, version negotiation | +| `handshake_challenge` | Node sends GEK proof nonce (base64, 32 bytes random) — see §4.2.x | +| `handshake_response` | Client proves GEK possession: HMAC-SHA256(GEK, nonce) | +| `handshake_ack` | Node response: version, node public key, `is_node_admin` (node-level authorization) | | `index_sync` | Encrypted Mesh Group Index delta | | `file_request` | Request chunk(s) of a file by hash + chunk index | | `file_chunk` | Chunk data + Ed25519 signature | +| `file_delete` | Client requests file deletion by file_id | +| `file_delete_ack` | Node confirms deletion | +| `file_upload` | Client pushes file chunk to node | +| `file_upload_ack` | Node acknowledges chunk receipt | +| `admin_challenge` | Node sends Ed25519 sign challenge for admin ops (base64, 32 bytes) | +| `admin_response` | Client returns Ed25519 signature over the challenge | +| `stream_request` | Client requests MSE video stream | +| `stream_init` | Node sends codec info + signals stream start | +| `stream_data` | Node sends encrypted fMP4 segment | +| `stream_end` | Node signals end of stream | | `stream_segment` | HLS/DASH segment (VOD), encrypted with GEK-derived key | -| `chat_message` | Double Ratchet encrypted message frame | +| `chat_message` | Sender Keys encrypted message frame (group chat) | +| `chat_history` | Client requests chat history | +| `chat_history_response` | Node responds with stored messages | | `chat_attachment` | Attachment metadata + key; data transferred as file chunks | | `ephemeral_stream` | [reserved, future] Ephemeral video with TTL metadata | @@ -843,15 +1015,16 @@ Format: `msgpack` → `zstd` → GEK-encrypted (private groups) or plaintext + E Entry structure: ```python { - "version": 1, - "id": "", - "name": "filename.mkv", - "path": "Movies/2024/", - "size": 4294967296, - "type": "video", # video | audio | image | document | archive | other - "duration": 7245, # seconds, for media - "thumb_hash": "", # thumbnail also GEK-encrypted - "added_at": 1720000000 + "version": 1, + "id": "", + "name": "filename.mkv", + "path": "Movies/2024/", + "size": 4294967296, + "type": "video", # video | audio | image | document | archive | other + "duration": 7245, # seconds, for media + "thumb_hash": "", # thumbnail also GEK-encrypted + "added_at": 1720000000, + "uploader_id": "" # who uploaded this file (null = pre-existing on disk) } ``` @@ -926,7 +1099,8 @@ esbuild. ESM modules loaded natively by modern browsers. - Actions: download, stream (for media files) - Files fetched directly from node via DataChannel - Upload: photos/videos posted to the group's shared upload folder - (only the uploader or group admin can modify/delete) +- Delete: node operator can delete any file; uploader can delete their own uploads. + Hub admin has NO delete authority on nodes they don't operate (see §4.2.x). **Group view — Chat/Forum:** - Sender Keys encrypted messages, fetched from node @@ -1167,11 +1341,28 @@ The node loads extension modules (Python) in a sandboxed subprocess. **Chat is a |---|---|---|---| | R20 | WebRTC DataChannel validation? | Confirmed: browser→NAT→node file transfer works. Tested 3 scenarios on SFR residential (Port-Restricted Cone NAT) + 4G CGNAT: WiFi LAN (IPv6 direct, ~100ms), 4G IPv6 inter-network (~600ms), 4G IPv4 STUN hole-punch (~650ms). No TURN relay needed. | Phase 9.5 spike | +**Resolved by node sovereignty fix (2026-08-12):** + +| # | Question | Resolution | Source | +|---|---|---|---| +| R21 | Who authorizes file deletion on a node? | Node operator (sovereign) OR original uploader. Hub admin has no authority over node content. Enforced: deny-by-default in MNP `file_delete`, `is_node_admin` in handshake_ack, `uploader_id` in IndexEntry. | Security fix — §4.2.x | +| R22 | Can a malicious hub admin access node content? | No. Two cryptographic layers: (1) GEK-HMAC proof in handshake — hub never has the GEK, can't pass the challenge. (2) Ed25519 challenge-response for admin ops — hub can't forge the node operator's signature. `gek_req` endpoint removed. | Crypto enforcement — §4.2.x | + +**Resolved by Phase 12 — P2P crypto material (2026-08-13):** + +| # | Question | Resolution | Source | +|---|---|---|---| +| R23 | Where are GEK bundles stored? | On node only (`BundleStore` SQLite, `data_dir/bundles.db`). Hub `GEKBundle` model removed. Exchanged via MNP `gek_bundle_store`/`gek_bundle_fetch`/`gek_bundle_resp` over WebRTC DataChannel. | Phase 12 — T3 | +| R24 | Where are keypair bundles stored? | On node only (`BundleStore`). Encrypted with password-derived AES key (`bundle_key`). Browser pushes after registration, recovers during handshake. Hub `keypair_bundle` column removed. | Phase 12 — T3 | +| R25 | How does the browser recover keys after localStorage cleared? | Transport fetches `keypair_bundle` from node during handshake, decrypts with `_bundleKey` (PBKDF2 from password). Public key derived from private key via JWK export — no hub fetch needed. `_bundleKey` persisted in IndexedDB, `_sessionKeys` in sessionStorage. | Phase 12 | +| R26 | How does the browser handle Chrome SDP re-serialization? | `this._rawAnswerSdp = answer.sdp` saved before `setRemoteDescription`. DTLS fingerprint extracted from raw SDP, not `pc.remoteDescription.sdp` (Chrome may drop sha-256 line when re-serializing multi-hash SDP from aiortc). | Phase 12 | +| R27 | Should the browser auto-regenerate keys on login? | No. Auto-regeneration silently rotates hub keys, breaking GEK unwrap (GEK wrapped for old keys). Keys recovered from node via `_bundleKey`. Regeneration only on explicit user request. | Phase 12 | + **Still open:** 1. **Refresh token validity:** 30 or 90 days? 2. **Group address scheme:** final URL format confirmation -3. **GEK bundle location for groups with mixed access** (public-restricted): hub or node? +3. **GEK bundle location for groups with mixed access** (public-restricted): hub or node? → Resolved: always on node. 4. **MHP federation sync frequency and conflict resolution** 5. **Chat attachment storage:** stored on node like regular files, or separate store? 6. **Relay registration protocol design** (when implemented) diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index bf83906..55dcdde 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -40,6 +40,16 @@ class MNP: STREAM_DATA = "stream_data" # node sends encrypted fMP4 segment STREAM_END = "stream_end" # node signals end of stream EPHEMERAL_STREAM = "ephemeral_stream" # reserved — mobile live push + HANDSHAKE_CHALLENGE = "handshake_challenge" # node → client: GEK proof nonce + HANDSHAKE_RESPONSE = "handshake_response" # client → node: HMAC(GEK, nonce) + ADMIN_CHALLENGE = "admin_challenge" # node → client: Ed25519 sign challenge + ADMIN_RESPONSE = "admin_response" # client → node: Ed25519 signature + GEK_BUNDLE_STORE = "gek_bundle_store" # client → node: store wrapped GEK for a user + GEK_BUNDLE_FETCH = "gek_bundle_fetch" # client → node: request own wrapped GEK + GEK_BUNDLE_RESP = "gek_bundle_resp" # node → client: wrapped GEK bundle + KEYPAIR_BUNDLE_STORE = "keypair_bundle_store" # client → node: store encrypted keypair bundle + KEYPAIR_BUNDLE_FETCH = "keypair_bundle_fetch" # client → node: request own keypair bundle + KEYPAIR_BUNDLE_RESP = "keypair_bundle_resp" # node → client: encrypted keypair bundle # ── Index entry ─────────────────────────────────────────────────────────────── @@ -54,6 +64,8 @@ class IndexEntry: added_at: int # unix timestamp duration: int | None = None # seconds, for media thumb_hash: str | None = None # blake3 of thumbnail + uploader_id: str | None = None # user_id of who uploaded (None = pre-existing on disk) + uploader_pk: str | None = None # Ed25519 public key of uploader (base64 raw 32 bytes) @dataclass diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py index 88e231a..164885d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py @@ -211,6 +211,7 @@ async def admin_list_groups( "name": g.name, "admin_id": g.admin_id, "visibility": g.visibility, + "description": g.description or "", "status": g.status, "created_at": g.created_at.isoformat(), "member_count": mc, @@ -265,25 +266,30 @@ async def admin_list_logs( offset: int = 0, limit: int = Query(default=50, le=200), ): - query = select(IPLog).order_by(IPLog.timestamp.desc()) + query = ( + select(IPLog, User.username) + .outerjoin(User, IPLog.user_id == User.id) + .order_by(IPLog.timestamp.desc()) + ) if user_id: query = query.where(IPLog.user_id == user_id) if event: query = query.where(IPLog.event == event) query = query.offset(offset).limit(limit) result = await db.execute(query) - logs = result.scalars().all() + rows = result.all() return { "logs": [ { "id": lg.id, "user_id": lg.user_id, + "username": uname or "", "event": lg.event, "ip_address": lg.ip_address, "detail": lg.detail, "timestamp": lg.timestamp.isoformat(), } - for lg in logs + for lg, uname in rows ], } diff --git a/packages/meshbay-hub/src/meshbay_hub/api/deps.py b/packages/meshbay-hub/src/meshbay_hub/api/deps.py index addba30..1bf57a4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/deps.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/deps.py @@ -1,5 +1,10 @@ """ FastAPI shared dependencies — injected via Depends(). + +JWT scope enforcement: + - "user" scope (browser login): full access to all endpoints + - "node" scope (Ed25519 daemon auth): read-only group access + node operations + Node-scoped tokens CANNOT create/delete groups or manage membership. """ from fastapi import Depends, Header, HTTPException, status @@ -18,20 +23,13 @@ def set_admin_usernames(usernames: list[str]) -> None: _admin_usernames = set(usernames) -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. - """ +async def _decode_token(authorization: str = Header(...)) -> dict: + """Decode and verify JWT bearer token. Returns full payload.""" try: scheme, token = authorization.split(None, 1) if scheme.lower() != "bearer": raise ValueError - payload = decode_access_token(token) + return decode_access_token(token) except Exception: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -39,6 +37,15 @@ async def get_current_user( headers={"WWW-Authenticate": "Bearer"}, ) + +async def get_current_user( + payload: dict = Depends(_decode_token), + db: AsyncSession = Depends(get_db), +) -> User: + """ + Verify the JWT bearer token and return the User from the database. + Accepts both user-scoped and node-scoped tokens. + """ result = await db.execute( select(User).where(User.id == payload["sub"])) user = result.scalar_one_or_none() @@ -52,6 +59,19 @@ async def get_current_user( return user +async def require_user_scope( + payload: dict = Depends(_decode_token), + current_user: User = Depends(get_current_user), +) -> User: + """Reject node-scoped tokens — only browser (user-scope) can mutate groups.""" + if payload.get("scope") == "node": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Node-scoped token cannot perform this operation — use browser", + ) + return current_user + + async def require_moderator( current_user: User = Depends(get_current_user), ) -> User: diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index e0ea016..88af764 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -5,10 +5,10 @@ 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.api.deps import get_current_user, require_user_scope from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import ( - FederatedGroup, GEKBundle, Group, GroupMember, + FederatedGroup, Group, GroupMember, IPLog, SwarmSource, User, ) @@ -37,6 +37,7 @@ async def my_groups( "join_policy": g.join_policy, "created_at": g.created_at.isoformat(), "is_admin": g.admin_id == current_user.id, + "description": g.description or "", } for g in groups ] @@ -56,6 +57,8 @@ async def group_online_nodes( group = await db.get(Group, group_id) if not group: raise HTTPException(status_code=404, detail="Group not found") + if group.status != "active": + raise HTTPException(status_code=403, detail="Group is suspended") node_ids = get_online_nodes_for_group(group_id) nodes = [] @@ -85,6 +88,7 @@ async def list_public_groups( groups = [ { "id": g.id, "name": g.name, "join_policy": g.join_policy, + "description": g.description or "", "created_at": g.created_at.isoformat(), "source": "local", } for g in local @@ -193,7 +197,7 @@ async def group_members( async def join_group( group_id: str, request: Request, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_user_scope), db: AsyncSession = Depends(get_db), ): group = await db.get(Group, group_id) @@ -219,26 +223,23 @@ 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 + description: str | None = None @router.post("", status_code=201) async def create_group( body: GroupCreateRequest, request: Request, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_user_scope), db: AsyncSession = Depends(get_db), ): + desc = (body.description or "")[:512] if body.description else None group = Group( name=body.name, admin_id=current_user.id, visibility=body.visibility, join_policy=body.join_policy, + description=desc, ) db.add(group) await db.flush() # get group.id @@ -251,12 +252,11 @@ async def create_group( return {"group_id": group.id, "name": group.name} -@router.post("/{group_id}/members/{username}/gek", status_code=201) -async def store_gek_bundle( +@router.post("/{group_id}/members/{username}", status_code=201) +async def add_group_member( group_id: str, username: str, - body: GEKBundleRequest, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_user_scope), db: AsyncSession = Depends(get_db), ): group = await db.get(Group, group_id) @@ -270,26 +270,11 @@ async def store_gek_bundle( if not target: raise HTTPException(status_code=404, detail="User not found") - # Upsert GEK bundle - existing = await db.get(GEKBundle, (group_id, target.id)) new_member = False - 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)) - new_member = True + mem = await db.get(GroupMember, (group_id, target.id)) + if not mem: + db.add(GroupMember(group_id=group_id, user_id=target.id)) + new_member = True if new_member: from meshbay_hub.api.notifications import create_notification @@ -303,26 +288,26 @@ async def store_gek_bundle( return {"status": "stored", "group_id": group_id, "username": username} -@router.get("/{group_id}/gek") -async def get_my_gek_bundle( +@router.delete("/{group_id}") +async def delete_group( group_id: str, - current_user: User = Depends(get_current_user), + request: Request, + current_user: User = Depends(require_user_scope), 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 the group creator can delete") - 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, - } + from sqlalchemy import delete as sa_delete + await db.execute(sa_delete(GroupMember).where(GroupMember.group_id == group_id)) + db.add(IPLog(user_id=current_user.id, event="group_delete", + ip_address=_ip(request), detail=group.name)) + await db.delete(group) + await db.commit() + return {"status": "deleted", "group_id": group_id} def _ip(request: Request) -> str: diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py index b970aa8..321e43c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py @@ -1,15 +1,84 @@ """Node endpoints — /v1/nodes/*""" +import base64 +import time + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from cryptography.exceptions import InvalidSignature from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from meshbay_hub.auth import issue_access_token from meshbay_hub.api.deps import get_current_user +from meshbay_hub.api.middleware import limiter from meshbay_hub.db.engine import get_db -from meshbay_hub.db.models import IPLog, Node, User +from meshbay_hub.db.models import GroupMember, IPLog, Node, User router = APIRouter(prefix="/v1/nodes", tags=["nodes"]) +NODE_AUTH_TIMESTAMP_WINDOW = 60 # seconds + + +class NodeAuthRequest(BaseModel): + username: str + timestamp: int # unix epoch seconds + signature: str # base64 Ed25519 signature + + +@router.post("/auth") +@limiter.limit("10/minute") +async def node_auth( + body: NodeAuthRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + """Authenticate a node daemon via Ed25519 challenge-response. Returns node-scoped JWT.""" + now = int(time.time()) + if abs(now - body.timestamp) > NODE_AUTH_TIMESTAMP_WINDOW: + raise HTTPException(status_code=401, detail="Timestamp too old or too far in the future") + + result = await db.execute(select(User).where(User.username == body.username)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=401, detail="Invalid credentials") + if user.status != "active": + raise HTTPException(status_code=403, detail=f"Account {user.status}") + + if not user.pk_node_ed25519: + raise HTTPException( + status_code=401, + detail="No node key registered — link your node from the browser first", + ) + + message = f"meshbay:node_auth:{body.username}:{body.timestamp}".encode() + try: + pk_raw = base64.b64decode(user.pk_node_ed25519) + pk = Ed25519PublicKey.from_public_bytes(pk_raw) + sig = base64.b64decode(body.signature) + pk.verify(sig, message) + except (InvalidSignature, Exception): + db.add(IPLog(event="node_auth_fail", ip_address=_ip(request), detail=body.username)) + await db.commit() + raise HTTPException(status_code=401, detail="Invalid signature") + + memberships = await db.execute( + select(GroupMember.group_id).where(GroupMember.user_id == user.id)) + group_ids = [gid for (gid,) in memberships.all()] + + access_token = issue_access_token( + user.id, user.pk_node_ed25519, ttl=3600, groups=group_ids, scope="node") + + db.add(IPLog(user_id=user.id, event="node_auth", ip_address=_ip(request))) + await db.commit() + + return { + "access_token": access_token, + "token_type": "bearer", + "expires_in": 3600, + } + class NodeAnnounceRequest(BaseModel): pk_node: str diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 2c2eede..53238de 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -1,5 +1,6 @@ """User endpoints — /v1/users/*""" +import base64 import uuid from datetime import datetime, timezone, timedelta @@ -24,7 +25,7 @@ from meshbay_hub.api.middleware import limiter from meshbay_hub.config import HubConfig from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import GroupMember, IPLog, RefreshToken, User -from meshbay_hub.api.deps import get_current_user +from meshbay_hub.api.deps import get_current_user, require_user_scope router = APIRouter(prefix="/v1/users", tags=["users"]) @@ -46,10 +47,10 @@ def _refresh_ttl() -> int: class RegisterRequest(BaseModel): username: str email: str - password: str + password: str | None = None # deprecated — legacy native clients + auth_key: str | None = None # PBKDF2-derived, new clients pk_user_ed25519: str # base64 raw 32B pk_user_x25519: str # base64 raw 32B - keypair_bundle: str | None = None # AES-GCM encrypted bundle (web clients) @field_validator("username") @classmethod @@ -61,17 +62,11 @@ class RegisterRequest(BaseModel): 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 + password: str | None = None # legacy (raw password) for migration + auth_key: str | None = None # PBKDF2-derived auth key (new scheme) class RefreshRequest(BaseModel): @@ -92,18 +87,23 @@ async def register( if existing.scalar_one_or_none(): raise HTTPException(status_code=409, detail="Username already taken") - pw_hash, pw_salt = hash_password(body.password) + credential = body.auth_key or body.password + if not credential: + raise HTTPException(status_code=400, detail="auth_key or password required") + + pw_hash, pw_salt = hash_password(credential) + # auth_key → pw_version 3 (password split); raw password → pw_version 2 (legacy) + pw_ver = current_pw_version() if body.auth_key else 2 hub_id = _cfg.identity.id if _cfg else "meshbay.org" user = User( username=body.username, email=encrypt_email(body.email), pw_hash=pw_hash, pw_salt=pw_salt, - pw_version=current_pw_version(), + pw_version=pw_ver, pk_ed25519=body.pk_user_ed25519, pk_x25519=body.pk_user_x25519, hub_id=hub_id, - keypair_bundle=body.keypair_bundle, ) db.add(user) db.add(IPLog( @@ -136,18 +136,52 @@ async def login( 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, version=user.pw_version - ): + + if not body.auth_key and not body.password: + raise HTTPException(status_code=401, detail="No credentials provided") + + if not user: 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.pw_version >= 3: + # New scheme: verify auth_key + if not body.auth_key or not verify_password( + body.auth_key, user.pw_hash, user.pw_salt, version=user.pw_version + ): + db.add(IPLog(event="login_fail", ip_address=ip, detail=body.username)) + await db.commit() + raise HTTPException(status_code=401, detail="Invalid credentials") + else: + # Legacy scheme: need raw password + if not body.password: + raise HTTPException(status_code=401, detail="auth_upgrade_required") + if not verify_password( + body.password, user.pw_hash, user.pw_salt, version=user.pw_version + ): + db.add(IPLog(event="login_fail", ip_address=ip, detail=body.username)) + await db.commit() + raise HTTPException(status_code=401, detail="Invalid credentials") + # Migrate to new scheme if auth_key provided alongside password + if body.auth_key: + new_hash, new_salt = hash_password(body.auth_key) + user.pw_hash = new_hash + user.pw_salt = new_salt + user.pw_version = current_pw_version() + elif user.pw_version < 2: + # Legacy rehash: upgrade Argon2 params within the password scheme (v1 -> v2) + new_hash, new_salt = hash_password(body.password) + user.pw_hash = new_hash + user.pw_salt = new_salt + user.pw_version = 2 + if user.status != "active": raise HTTPException(status_code=403, detail=f"Account {user.status}") - if pw_needs_rehash(user.pw_version): - new_hash, new_salt = hash_password(body.password) + # Rehash within the auth_key scheme if Argon2 params upgraded beyond v3 + if user.pw_version >= 3 and pw_needs_rehash(user.pw_version): + new_hash, new_salt = hash_password(body.auth_key) user.pw_hash = new_hash user.pw_salt = new_salt user.pw_version = current_pw_version() @@ -168,15 +202,12 @@ async def login( db.add(IPLog(user_id=user.id, event="login", ip_address=ip)) await db.commit() - resp = { + return { "access_token": access_token, "refresh_token": raw_rt, "token_type": "bearer", "expires_in": _ttl(), } - if user.keypair_bundle: - resp["keypair_bundle"] = user.keypair_bundle # encrypted, for web clients - return resp @router.post("/token/refresh") @@ -248,6 +279,64 @@ async def get_current_user_info( } +class NodeKeyRequest(BaseModel): + pk_node_ed25519: str # base64 raw 32B Ed25519 public key + + +@router.put("/me/node_key") +async def register_node_key( + body: NodeKeyRequest, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """Link a node daemon's Ed25519 public key to the operator's account.""" + try: + raw = base64.b64decode(body.pk_node_ed25519) + if len(raw) != 32: + raise ValueError + except Exception: + raise HTTPException(status_code=400, detail="Invalid Ed25519 public key (need 32 bytes base64)") + + current_user.pk_node_ed25519 = body.pk_node_ed25519 + await db.commit() + return {"status": "stored", "pk_node_ed25519": body.pk_node_ed25519} + + +class RotateKeysRequest(BaseModel): + pk_user_ed25519: str # base64 raw 32B + pk_user_x25519: str # base64 raw 32B + + +@router.put("/me/keys") +async def rotate_browser_keys( + body: RotateKeysRequest, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + for field, label in [ + (body.pk_user_ed25519, "Ed25519"), + (body.pk_user_x25519, "X25519"), + ]: + try: + raw = base64.b64decode(field) + if len(raw) != 32: + raise ValueError + except Exception: + raise HTTPException( + status_code=400, + detail=f"Invalid {label} public key (need 32 bytes base64)", + ) + + current_user.pk_ed25519 = body.pk_user_ed25519 + current_user.pk_x25519 = body.pk_user_x25519 + await db.commit() + return { + "status": "updated", + "pk_ed25519": body.pk_user_ed25519, + "pk_x25519": body.pk_user_x25519, + } + + @router.get("/{username}/pubkeys") async def get_user_pubkeys( username: str, @@ -258,12 +347,15 @@ async def get_user_pubkeys( target = result.scalar_one_or_none() if not target: raise HTTPException(status_code=404, detail="User not found") - return { + resp = { "user_id": target.id, "username": target.username, "pk_ed25519": target.pk_ed25519, "pk_x25519": target.pk_x25519, } + if target.pk_node_ed25519: + resp["pk_node_ed25519"] = target.pk_node_ed25519 + return resp def _client_ip(request: Request) -> str: diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py index 11ad112..563a1eb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/auth.py +++ b/packages/meshbay-hub/src/meshbay_hub/auth.py @@ -30,8 +30,9 @@ _ARGON2_KEY_LEN = 32 _ARGON2_VERSIONS = { 1: {"iterations": 3, "memory_cost": 65536}, # 64 MB — initial 2: {"iterations": 3, "memory_cost": 262144}, # 256 MB — production target + 3: {"iterations": 3, "memory_cost": 262144}, # 256 MB — auth_key input (password split) } -_ARGON2_CURRENT_VERSION = 2 +_ARGON2_CURRENT_VERSION = 3 # Module-level hub keypair (loaded once at startup) _hub_sk_pem: bytes | None = None @@ -133,11 +134,13 @@ def issue_access_token( pk_user: str, ttl: int = 3600, groups: list[str] | None = None, + scope: str = "user", ) -> str: """ Issue a signed JWT access token. Includes jti (UUID4) — required to prevent replay and enable revocation. Includes groups — list of group_ids the user is a member of (node-side authz). + scope: "user" (browser, full access) or "node" (daemon, restricted). """ if _hub_sk_pem is None: raise RuntimeError("Hub keypair not loaded") @@ -151,6 +154,7 @@ def issue_access_token( "iat": now, "exp": now + ttl, "groups": groups or [], + "scope": scope, } return jwt.encode(payload, _hub_sk_pem, algorithm="EdDSA") diff --git a/packages/meshbay-hub/src/meshbay_hub/db/__init__.py b/packages/meshbay-hub/src/meshbay_hub/db/__init__.py index 5ef1d3c..62e5388 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/__init__.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/__init__.py @@ -1,9 +1,9 @@ """Hub database layer.""" from .engine import init_db, close_db, get_db -from .models import Base, User, Node, Group, GroupMember, GEKBundle, RefreshToken, IPLog +from .models import Base, User, Node, Group, GroupMember, RefreshToken, IPLog __all__ = [ "init_db", "close_db", "get_db", "Base", "User", "Node", "Group", "GroupMember", - "GEKBundle", "RefreshToken", "IPLog", + "RefreshToken", "IPLog", ] diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/2041a4060b3c_add_keypair_bundle_federated_groups_.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/2041a4060b3c_add_keypair_bundle_federated_groups_.py index 779efdc..a59a3d1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/2041a4060b3c_add_keypair_bundle_federated_groups_.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/2041a4060b3c_add_keypair_bundle_federated_groups_.py @@ -63,14 +63,12 @@ def upgrade() -> None: ) op.create_index('ix_content_reports_group', 'content_reports', ['group_id'], unique=False) op.create_index('ix_content_reports_hash', 'content_reports', ['content_hash'], unique=False) - op.add_column('users', sa.Column('keypair_bundle', sa.Text(), nullable=True)) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('users', 'keypair_bundle') op.drop_index('ix_content_reports_hash', table_name='content_reports') op.drop_index('ix_content_reports_group', table_name='content_reports') op.drop_table('content_reports') diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d28b9caf9f07_initial_schema.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d28b9caf9f07_initial_schema.py index d4a9aa6..5192eb8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d28b9caf9f07_initial_schema.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d28b9caf9f07_initial_schema.py @@ -84,17 +84,6 @@ def upgrade() -> None: sa.UniqueConstraint('token_hash') ) op.create_index('ix_refresh_tokens_hash', 'refresh_tokens', ['token_hash'], unique=False) - op.create_table('gek_bundles', - sa.Column('group_id', sa.String(length=36), nullable=False), - sa.Column('user_id', sa.String(length=36), nullable=False), - sa.Column('pk_eph_b64', sa.String(length=64), nullable=False), - sa.Column('nonce_b64', sa.String(length=32), nullable=False), - sa.Column('wrapped_b64', sa.String(length=128), nullable=False), - sa.Column('stored_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('group_id', 'user_id') - ) op.create_table('group_members', sa.Column('group_id', sa.String(length=36), nullable=False), sa.Column('user_id', sa.String(length=36), nullable=False), @@ -110,7 +99,6 @@ def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### op.drop_table('group_members') - op.drop_table('gek_bundles') op.drop_index('ix_refresh_tokens_hash', table_name='refresh_tokens') op.drop_table('refresh_tokens') op.drop_index('ix_nodes_user_id', table_name='nodes') diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index c420661..cdebd3c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -6,7 +6,6 @@ Tables: nodes — node announcements groups — group registry group_members — group membership - gek_bundles — encrypted GEK per (group, user) refresh_tokens — hashed refresh tokens ip_logs — connection log for legal compliance (1-year retention) """ @@ -45,15 +44,14 @@ class User(Base): pw_version: Mapped[int] = mapped_column(Integer, default=1) pk_ed25519: Mapped[str] = mapped_column(String(64), nullable=False) # base64 raw 32B pk_x25519: Mapped[str] = mapped_column(String(64), nullable=False) # base64 raw 32B + pk_node_ed25519: Mapped[str | None] = mapped_column(String(64), nullable=True) # node daemon key hub_id: Mapped[str] = mapped_column(String(128), nullable=False) - keypair_bundle: Mapped[str | None] = mapped_column(Text) # AES-GCM encrypted, web clients only role: Mapped[str] = mapped_column(String(16), default="user") # user|moderator|admin status: Mapped[str] = mapped_column(String(16), default="active") # active|suspended|revoked created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) nodes: Mapped[list["Node"]] = relationship(back_populates="user") group_memberships: Mapped[list["GroupMember"]] = relationship(back_populates="user") - gek_bundles: Mapped[list["GEKBundle"]] = relationship(back_populates="user") refresh_tokens: Mapped[list["RefreshToken"]] = relationship(back_populates="user") ip_logs: Mapped[list["IPLog"]] = relationship(back_populates="user") @@ -89,11 +87,11 @@ class Group(Base): admin_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) visibility: Mapped[str] = mapped_column(String(16), default="private") # public|private join_policy: Mapped[str] = mapped_column(String(16), default="invite") # open|request|invite - status: Mapped[str] = mapped_column(String(16), default="active") # active|revoked + description: Mapped[str | None] = mapped_column(String(512)) + status: Mapped[str] = mapped_column(String(16), default="active") # active|suspended|revoked created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) members: Mapped[list["GroupMember"]] = relationship(back_populates="group") - gek_bundles: Mapped[list["GEKBundle"]] = relationship(back_populates="group") __table_args__ = (Index("ix_groups_name", "name"),) @@ -109,23 +107,6 @@ class GroupMember(Base): user: Mapped["User"] = relationship(back_populates="group_memberships") -# ── GEK bundles ─────────────────────────────────────────────────────────────── - -class GEKBundle(Base): - """Encrypted GEK bundle — opaque to the hub (hub cannot decrypt it).""" - __tablename__ = "gek_bundles" - - group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"), primary_key=True) - user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), primary_key=True) - pk_eph_b64: Mapped[str] = mapped_column(String(64), nullable=False) - nonce_b64: Mapped[str] = mapped_column(String(32), nullable=False) - wrapped_b64: Mapped[str] = mapped_column(String(128), nullable=False) - stored_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) - - group: Mapped["Group"] = relationship(back_populates="gek_bundles") - user: Mapped["User"] = relationship(back_populates="gek_bundles") - - # ── Refresh tokens ──────────────────────────────────────────────────────────── class RefreshToken(Base): diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index d8f9df5..ddff928 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -66,6 +66,58 @@ async function getAllCachedIndexes() { // ── Auth persistence ───────────────────────────────────────────────────────── let _sessionKeys = null; +let _bundleKey = null; +let _pendingBundlePush = null; + +function _openKeyDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open('meshbay_keys', 1); + req.onupgradeneeded = () => req.result.createObjectStore('k'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} +async function _storeBundleKey(key) { + try { + const db = await _openKeyDB(); + const tx = db.transaction('k', 'readwrite'); + tx.objectStore('k').put(key, 'bk'); + await new Promise(r => { tx.oncomplete = r; }); + db.close(); + } catch {} +} +async function _loadBundleKey() { + try { + const db = await _openKeyDB(); + const tx = db.transaction('k', 'readonly'); + const g = tx.objectStore('k').get('bk'); + const val = await new Promise(r => { g.onsuccess = () => r(g.result); }); + db.close(); + return val || null; + } catch { return null; } +} +async function _clearKeyDB() { + try { + const db = await _openKeyDB(); + const tx = db.transaction('k', 'readwrite'); + tx.objectStore('k').clear(); + await new Promise(r => { tx.oncomplete = r; }); + db.close(); + } catch {} +} +function _saveSessionKeys() { + try { + if (_sessionKeys) sessionStorage.setItem('meshbay_sk', JSON.stringify(_sessionKeys)); + } catch {} +} +function _restoreSessionKeys() { + try { + if (!_sessionKeys) { + const sk = sessionStorage.getItem('meshbay_sk'); + if (sk) _sessionKeys = JSON.parse(sk); + } + } catch {} +} function loadAuth() { try { @@ -81,6 +133,10 @@ function saveAuth(auth) { } else { localStorage.removeItem(AUTH_KEY); _sessionKeys = null; + _bundleKey = null; + _pendingBundlePush = null; + _clearKeyDB(); + try { sessionStorage.removeItem('meshbay_sk'); } catch {} } } @@ -139,9 +195,74 @@ function navigate(path) { const AuthContext = createContext(null); function useAuth() { return useContext(AuthContext); } +// ── User Menu ──────────────────────────────────────────────────────────────── + +function UserMenu({ user, theme, onThemeChange, onLogout }) { + const [open, setOpen] = useState(false); + const [langOpen, setLangOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const close = (e) => { + if (ref.current && !ref.current.contains(e.target)) setOpen(false); + }; + document.addEventListener('click', close); + return () => document.removeEventListener('click', close); + }, [open]); + + const resolved = resolveTheme(theme); + + return html` +
+ + ${open && html` +
+
+ ${user.username[0].toUpperCase()} +
+
${user.username}
+
${user.role || 'user'}
+
+
+
+ + ${langOpen && LOCALES.map(l => html` + + `)} + + +
+ +
+ `} +
+ `; +} + // ── Nav ────────────────────────────────────────────────────────────────────── -function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle, unreadCount }) { +function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount }) { return html`