summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src
Commit message (Collapse)AuthorAgeFilesLines
...
* feat(node): operator surface — member list, invite, revoke, unpin over SSHChristophe Besson2026-08-145-14/+286
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A node admits people from its own roster, and until now a headless operator had no way to put anyone on it: pairing worked from the CLI, everything else needed a browser on a machine that does not have one. Absorbs milestones 14.3/14.4. member list who is admitted, role, status, when and how pinned member invite <username> one-time code; the node wraps the key when they connect, so nobody has to be online then member revoke <username> stop serving them the key member unpin <username> forget the pin so they can pair again after a reset All of it goes through the daemon's loopback API with the per-run session token (11.5.3) — _daemon_api() in daemon.py, which also replaced three hand-rolled urllib blocks. `status` deliberately still reads the keystore, config and roster directly, so it works while the daemon is stopped. Two things the commands say out loud, because getting them wrong is silent: - revoke ends by telling the operator to rotate the key. The ex-member stops receiving it on their next connection, but they hold the current one, and "revoked" reads like it took the key back. - revoke/unpin refuse a username the roster does not know instead of acting on nobody. A typo must not look like success. Code lifetimes now differ by what the act is: 7 days for an invitation, which crosses a human conversation and gets answered whenever someone reads their messages, and 24 h for operator pairing, which is typed during the SSH session that printed it. Both configurable ([node] invite_ttl_hours, pair_ttl_hours). A day was long enough for the second and not for the first — a code that dies over a weekend means finding a browser to issue another one. The roster is also in the local admin UI, escaped: usernames come from the hub and land on the page that can re-key groups and read the audit log, so H2's rule covers them exactly as it covers filenames. Verified by driving the real CLI against a stub daemon over a socket, which is how the "known: <nothing>" bug in the not-found path turned up. Tests: 89 node here (roster, endpoints, CLI routing, TTL config). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(node)!: the node wraps the group key — closes H3 and M3Christophe Besson2026-08-145-159/+979
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The invite flow fetched the invitee's pk_x25519 from the hub and wrapped the GEK for whatever came back (app.js:1466, and gek-init did the same server-side). The hub is the key directory, so a hub answering with its own key was handed the group key by an honest member following the protocol exactly. No forgery, no injection, nothing for the client to notice. That was H3. The fix is not safety numbers. Nobody reads the directory any more: - the node holds the GEK and wraps it itself, on every connection, for the X25519 key the joiner signed with their Ed25519 identity in one transcript (meshbay:join:v1), so the identity key vouches for the encryption key; - identities are bound to accounts by a one-time code the hub never sees — 40 bits, single use, one account, bounded per connection AND node-wide; - the node's own roster decides who may receive the key. Hub membership lets someone reach a node; it no longer gets them anything. A hub that invents an account and mints it a token is answered not_authorized_for_group. Safety numbers would have made substitution detectable by a human who checks, at the moment there is nothing to check against — first contact. Removing the lookup makes it impossible, and costs the user one code to pass along. M3 falls out of the same work. The daemon auto-pinned its own keystore key as admin_pk_ed25519 while the browser signs with the user identity key, so every privileged operation failed closed with a signature error that looked like a bug somewhere else; the demo only worked because a deploy script overwrote the value. Authority now comes from the roster, established locally by `operator pair`. Asking the hub for the operator's key — the obvious-looking fix — would have let the hub install itself as node administrator. BREAKING: gek_bundle_store is deleted, not gated. No member hands the node key material at all, so C5b becomes structural rather than an authorization to check. Existing stored bundles are still served, so current deployments keep working. Also: - join_policy (invite|open) is read from node.toml, never from the hub — a hub able to declare a group open would be handed its key. Unknown group ⇒ invite. - admin signatures are verified against the roster on every check, so unpinning takes effect without a restart. admin_pk_ed25519 stays readable as legacy. - two C5b tests were rewritten, deliberately: they asserted that gek_bundle_store demanded an operator signature, and the message is gone. They now assert the stronger property. The file says not to fix these tests, so this is the record of why they changed. - a slice-1 bug found while writing slice 2: connect() never passed skEdB64, so pairing would have failed at runtime with no test able to catch it. Tests: 152 node+common here, including an end-to-end DataChannel run where a member who has never held the group key redeems a code in the pre-proof window and receives the key wrapped for a key only they can open. Design: docs/invite-pairing-v1.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(node): CLI for headless operators — status, ui, gek-initChristophe Besson2026-08-131-5/+124
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Every operator action lived behind a web UI on the node's own loopback interface. For the normal deployment — a node on a server reached over SSH — that is unusable: no browser on the host, and 11.5.3 added a per-run token that had to be copied out of a log to get in. status hub, node public key, daemon state, groups, admin-key pinning. Reads the keystore directly so it works while the daemon is STOPPED, which is exactly when it is needed: the daemon cannot stay up before its key is linked or before a group exists. ui prints the URL and the ssh -L line. It does not open a browser — that was an assumption about the environment, and a wrong one. gek-init initialises a group key through the daemon's loopback API. Same operation as the admin UI button, no browser involved. Also fixes a latent bug in QE/deploy/deploy-node.sh: the pkill pattern was unanchored, so it matched any shell whose command line merely mentioned the daemon — including the one running the script. It killed a session three times before being pinned down. Anchored to the end of the command line. Verified against the live deployment. grenet and cbesson both connect over WebRTC through real NAT and can browse, download, stream, upload and chat. The node audit log confirms the security properties in production: uploads land in .uploads/{user_id}/ (C5a), the invite required the operator's signature over an admin transcript (C5b, H5), the pre-proof bundle window is bounded and audited (C4), and a non-member handshake was refused. Docs updated: Phase 14 marked partially delivered with the reason, draft-v5 §5.3 records the two operator personas, QE/deploy/README.md documents the commands and the remaining browser-only gaps (invite, delete). Tests: 121 node. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(node): keep the daemon alive when the hub rejects credentialsChristophe Besson2026-08-131-8/+22
| | | | | | | | | | | | | | | | | | | | | | A node whose owner has not registered yet got a plain 401 from /v1/nodes/auth, which _login_with_retry re-raised — so the daemon exited and took its local admin UI down with it. That UI is where the operator reads the node's public key in order to link it, so exiting strands them: no daemon, no key, no way forward without digging the keystore open by hand. The daemon already parks on "No node key" for exactly this reason; it now parks on any 401, reporting waiting_for_account with a message naming the account and hub, and keeps retrying every 30s. The intended order remains: register on the hub, install the node, copy its key from the local UI, paste it into Settings > Link Node. The daemon now survives being started out of order instead of failing with a traceback. Adds QE/deploy/ — generic deployment (deploy-hub.sh, deploy-node.sh) kept separate from the demo scenario (demo.py, demo.env, README.md). Credentials live in QE/, which is gitignored; verified with git check-ignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): require proof of possession on node announce — closes M8Christophe Besson2026-08-131-0/+10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Phase 11.5.10. POST /v1/nodes/announce accepted any pk_node with no proof the announcer held the matching private key, so a user could register a node record carrying someone else's node key, and records accumulated without limit. The announcer now signs a domain-separated message binding the key to their account — meshbay:node_announce:{user_id}:{pk_node}:{timestamp} — reusing the shape already proven by /v1/nodes/auth, so a signature for one can never satisfy the other. Same 60-second window. Re-announcing the same key now updates the existing record in place instead of creating a new row. Three test helpers had to be taught to sign, which is the useful part: nothing in the suite had ever exercised announce with an attacker's key. The new tests cover the missing proof, a foreign key, a stale timestamp, and idempotence. Note for the record: the node key is independent of the user's identity key. Two hub tests asserted the announced pk_node equalled the user's pk_ed, which happened to be true only because the daemon announces its keystore key. They now assert against the announced key itself. Tests: 157 hub+common, node suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(quic): GEK proof and mutual authentication — closes C6Christophe Besson2026-08-132-8/+182
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Phase 11.5.4/5/6 — finding C6, the last open critical finding. QUIC ran a JWT-only handshake: a forged or stolen token reached the node and could inject chat without ever holding the group key. It now runs the same challenge/response as WebRTC through meshbay_common.handshake — client nonce, role-bound length-prefixed transcript, GEK proof, and the node proving itself with a GEK proof plus an Ed25519 signature over the transcript (C3). 11.5.6 channel binding, resolved by spike and then by two findings the spike could not predict: * aioquic 1.3.0 exposes no RFC 5705 exporter, and the peer certificate only via a private attribute. The server reads its own certificate from disk, so no internals are touched on that side; the client's access is guarded and fails loudly if an upgrade moves it. * A RESUMED TLS session carries no certificate — aioquic does not re-send it, so there is nothing live to bind to. The anchor therefore travels with the session ticket, which is sound because the ticket is cryptographically derived from the handshake where that certificate was presented. * The anchor had to travel with the ticket rather than live on the client object: resumption constructs a fresh client, so an instance-level cache was silently useless. Caught by the resumption test, not by inspection. Both paths refuse rather than degrade. No certificate and no cached anchor means the handshake fails; it never falls back to an unbound proof, which would silently drop MitM detection (L4). QuicChunkClient gains a peer_cert_der property and constructor argument, mirroring how session_ticket is already carried by the caller. Tests: 9 quic/multi-group, full node+common suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(quic): share the unified handshake authorizationChristophe Besson2026-08-131-24/+28
| | | | | | | | | | | | | | | | | | | | | | | | | | Phase 11.5.4 — QUIC half. Findings M1, M9 on this transport. quic_server._do_handshake_sync was a second, weaker copy of the WebRTC logic: group_id was optional, so omitting it skipped the membership check entirely and fell back to the node's first group (M1); node-scoped daemon tokens were accepted as client tokens (M9); and the checks could drift from the WebRTC path independently, which is how they diverged in the first place. Authorization now comes from meshbay_common.handshake, shared with WebRTC. C6 IS STILL OPEN ON THIS TRANSPORT. There is no GEK proof here yet: a forged or stolen token still reaches the node over QUIC and can inject chat without holding the group key. What remains is the challenge/response and the mutual node proof — quic_binding() is written and unit-tested for exactly this, and 11.5.6 (whether a certificate hash is the right anchor, or an RFC 5705 exporter is reachable from aioquic) is still unproven. This commit narrows the gap to the proof itself; it does not close the finding. QUIC tests updated: default tokens are members of the test group, and clients pass group_id, since it is mandatory now. Tests: 9 quic/multi-group, full node+common suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(mnp): unified handshake with mutual authenticationChristophe Besson2026-08-131-58/+86
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Phase 11.5.4/5/7/8 — findings C6 (WebRTC half), C3, L4, M1, M9. New meshbay_common/handshake.py is the single implementation of authorization and proof: JWT verify, scope, denylist, mandatory group_id, membership, hosting. The handshake previously existed three times over and only the newest copy enforced the GEK proof. C3 — mutual authentication. Authentication ran one way: the client proved itself, the node proved nothing. handshake_ack.node_pk was never verified against anything and per-chunk signatures had been dropped in Phase 9.15, so a peer that had hijacked signaling (C2) or been substituted by the hub could accept the client's proof, ignore it, and serve a forged index, forged chat history and a forged is_node_admin flag. The client now sends a nonce; the node answers with its own GEK proof over that nonce AND an Ed25519 signature over the transcript; the browser verifies both and refuses otherwise. It also refuses an unchallenged handshake_ack, which previously let a peer skip proving anything at all. L4 — the proof was nonce ‖ offer_fp ‖ answer_fp: bare concatenation, and a missing fingerprint silently degraded it to nonce-only, dropping MitM detection (NS5). Every field is now length-prefixed and domain-separated, the role is bound so a client proof cannot be replayed as a node proof, and an absent channel binding is refused rather than tolerated. M1 — group_id was optional; omitting it skipped the membership check entirely and fell back to the node's first group. Now mandatory. M9 — node-scoped daemon tokens are refused on the client path. NOT DONE: quic_server.py still runs its own JWT-only handshake, so C6 remains open — a forged or stolen token reaches a node over QUIC and can inject chat without holding the GEK. quic_binding() is written and unit-tested but unwired. 11.5.6 (whether the certificate-hash anchor works with aioquic, or an RFC 5705 exporter is reachable) is unproven. 11.5.8 TOFU pinning of pk_node is not done: the client verifies the node's signature but does not yet remember which key it saw last. Adds packages/meshbay-common/tests/test_handshake.py (18 tests) covering the properties every transport must inherit. WebRTC test helpers rewritten around the shared module; _make_jwt now defaults to the test group, since group_id is mandatory. Tests: 24 webrtc, 176+ node+common. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: resource limits, signaling authz, node admin UI tokenChristophe Besson2026-08-133-28/+155
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Phase 11.5 — findings H6, C4 (partial), and milestone 11.5.3. H6 — resource exhaustion. Several paths let one peer degrade or stall a node: * the DataChannel frame limit was a flat 64 MB applied BEFORE authentication, so an unauthenticated peer could announce a huge frame and dribble bytes into it. Unauthenticated peers now get 64 KB; the large budget is granted only after the GEK proof, where it is needed for uploads. * _do_stream_segment ran subprocess.run(..., timeout=30) directly in the event loop, stalling the entire daemon — every peer, every group — for up to thirty seconds per request. Now async, with a timeout and process kill. * ffmpeg was spawned per stream request with no cap. Both streaming paths now share a transport-wide semaphore. * POST /v1/nodes/{id}/webrtc/offer was reachable by any authenticated user for any node, with no membership check and no rate limit, making the target node allocate an aiortc PeerConnection and gather ICE on demand — remote resource exhaustion against a third party's machine. Now rate limited, capped per user, SDP size bounded, and the caller must share an active group with the node. That also closes the H4 gap where signaling ignored group status. * POST /v1/nodes/{id}/incoming took peer_ip verbatim, so any user could make an arbitrary node emit UDP packets to an address of their choosing. The probe target must now match the caller's own source address. C4 (partial) — the pre-proof bundle window. GEK and keypair bundle fetches are served before the GEK proof by necessity: the client needs its wrapped bundle in order to compute the proof. That window is a disclosure surface a hub can reach by forging a JWT. Bounded to 4 fetches per session and audited as "pre_proof_fetch". The real fix is removing remote keypair bundles entirely, which belongs to the native client (Phase 13.3). 11.5.3 — the node admin UI was unauthenticated because it binds loopback. But any local process can reach it, and so can a page in the operator's browser via DNS rebinding — and this API re-initialises group keys and reads the audit log. H2 showed script execution there equals full control. Now gated by a per-run token, printed at startup, accepted as ?t= or X-MeshBay-Token. One test needed rewriting rather than adding: the first version asserted "subprocess.run(" was absent from the source, which also matched the comment documenting the old behaviour. It now parses the AST and checks the property. Tests: 121 node, 142 hub+common. Regression suite 47 node + 10 hub. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: swarm privacy, revocation persistence, keystore KDF, audit integrityChristophe Besson2026-08-134-13/+115
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Phase 11.5 hardening batch — H7, H4, M2, M6, M7, L1, L3, L6. H7 — private content hashes leaked to the hub. The daemon registered blake3 hashes for every group it hosted, private ones included, giving the hub a content fingerprint of every private file and letting anyone confirm whether a known file exists in the network. The leak was dormant only because the routes were declared on the groups router with a full path and mounted at /v1/groups/v1/swarm/* — the node's calls 404'd into a swallowed exception. Fixing the path alone would have activated the leak, so both land together: registration is gated on group visibility, the routes moved to a real /v1/swarm router, and the lookup now requires authentication. H4 — revocation was advisory. Group revocations were signed and broadcast by the hub and then dropped by the node, whose handler understood only "user" and "jti", so "suspend a group" enforced nothing. The denylist was also in-memory only, so a restart silently un-revoked everyone. Now persisted to data_dir/denylist.json, group targets honoured on both transports, and live sessions for a revoked group are closed. M2 — the node keystore, which protects the node's Ed25519 and X25519 private keys, was still deriving at 64 MB long after the hub's password verifier moved to 256 MB; the docs recorded the bump as done, true for the hub only. Raising the constant alone would have made every existing keystore permanently undecryptable, so envelopes now record the parameters they were written with and pre-M2 files continue to open under the legacy profile. M6 — registration inserted its audit row with a NULL user_id and then ran UPDATE ip_logs SET user_id=<new> WHERE user_id IS NULL, claiming every unattributed row in the table: failed logins for other usernames, concurrent registrations. In logs retained a year for legal requests, that attributed other people's connections to the wrong account. M7 — X-Forwarded-For was trusted unconditionally at four call sites, so anyone could forge the IP written to the compliance log and evade per-IP rate limits. New netutil.client_ip honours the header only from a trusted proxy and takes the rightmost hop (the one our proxy appended); no direct header reads remain. L1 dead GEK_REQUEST/GEK_RESPONSE constants removed; L3 peer errors no longer echo exception text (paths, internal state); L6 email sanity-checked instead of accepting any string — deliberately not RFC 5322, to avoid a new dependency. test_daemon_index_change_pushes_to_peers asserted that a PRIVATE group's hashes are registered with the hub. Split: private asserts not-called (index push to members still asserted), and a new test proves public groups still register. That is the fourth pre-existing test found asserting a vulnerability as intended behaviour, after gek auto-activation, the transport-wide chat_store and the blind admin challenge. Tests: 116 node, 132 hub+common. Regression suite now 43. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(node): group isolation, upload confinement, GEK seizure, admin challengeChristophe Besson2026-08-133-112/+291
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Phase 11.5 — findings H1, C5a, H2, C5b, H5 (see second-review.md). Batched together because the node-side changes share webrtc_server.py and cannot be separated into working commits. H1 — cross-group chat leak. chat_store, the peer registry and the display-name cache were read from the shared transport context, and daemon.py hoisted the FIRST group's chat store onto it. On a node hosting several groups every group's messages went to one database, chat_history served them back to members of every other group, and chat broadcast reached all peers regardless of group. All three now resolve through _group_ctx(). C5a — upload confinement. Uploads landed in the shared root under a client-chosen name and overwrote whatever was there. Any member could destroy the operator's files, and by becoming the recorded uploader of the replaced file could then delete it through the uploader path, bypassing the Ed25519 admin challenge. Uploads now go to a per-user quarantine (.uploads/{user_id}/), refuse to overwrite, and enforce chunk ordering, a filename allowlist and a size cap. H2 — stored XSS in the node admin UI. Filenames chosen by any group member were interpolated raw into the localhost UI, which has no authentication, so script execution there equals control of the node admin API. Now html.escape() throughout, textContent in the audit table, plus CSP/nosniff/no-referrer. The CSP contains exfiltration but cannot stop injected inline script — escaping is the fix. C5b — group key seizure. gek_bundle_store wrote whatever any member sent and auto-activated bundles addressed to the node operator. The operator's X25519 public key is public (the node publishes it in handshake_ack), so any member could wrap a key of their choosing for it and take over the group, locking every legitimate member out. Storing now requires an operator signature and _try_activate_gek is removed: nothing arriving over MNP can set a live GEK. H5 — unbound signing oracle. The node challenged with 32 raw random bytes and the client signed them blind, so a signature named no operation, subject, node or time. New meshbay_common/adminop.py defines a length-prefixed, domain-separated transcript; both sides build it independently and the client refuses to sign when the announced op/subject do not match its request. BREAKING: a group admin who does not operate the node can no longer store GEK bundles on it. Invites must be performed by the node operator. Adds tests/test_security_regressions.py. Verified against pre-fix source via git stash. Three pre-existing tests asserted the vulnerable behaviour as a feature and were inverted: gek auto-activation, and the transport-wide chat_store in test_daemon. Tests: 109 node, 132 hub+common. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(node)!: remove unauthenticated HTTP file API and TCP transportChristophe Besson2026-08-139-882/+42
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Phase 11.5.A — findings C1 and C6 (see second-review.md). C1: the per-group HTTP file API bound 0.0.0.0 for every configured group, private ones included, and served two endpoints with no authentication at all: GET /index (full Mesh Group Index) and GET /file/{id} (raw plaintext file via FileResponse). Anyone able to reach the port — LAN, forwarded port, permissive IPv6 — read every private file. This bypassed the entire GEK-proof and node sovereignty layer. Deleted rather than patched: it duplicated MNP without any of its controls. C6: the TCP+TLS chunk server accepted a bare JWT with no GEK proof, leaving a second non-compliant handshake path. Deleted; QUIC remains and will be brought to parity with WebRTC by the unified handshake in 11.5.4. Transport decision recorded in transport/__init__.py: WebRTC/ICE is primary for browser and native clients (the only NAT traversal validated here — 2 ISPs, IPv4 STUN + IPv6, 4G CGNAT); QUIC is kept for LAN, port-forwarded and hub-less group:// access. punch_nat() is a direct-connection helper, not a traversal stack. Also removed server_ssl_context()/client_ssl_context() from tls_cert.py (no remaining callers) and a dead import of the former in quic_server.py. generate_self_signed_cert() stays: QUIC uses it, and the certificate hash is the intended channel-binding anchor for 11.5.6, since QUIC has no DTLS fingerprint to bind the GEK proof to. BREAKING CHANGE: node.toml keys `port` and `http_port` are gone. Regenerate config with `meshbay-node init`. Env var MESHBAY_PORT -> MESHBAY_QUIC_PORT. Tests: 198 passed (209 - 7 test_http_server - 4 test_transport). No other test changed status. Net -1300 lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat: Phase 12 — P2P crypto material, password split, node Ed25519 authChristophe Besson2026-08-136-153/+768
| | | | | | | | | | | | | | | | 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 <noreply@anthropic.com>
* feat(node): audit logging + local admin UI rewriteChristophe Besson2026-08-114-133/+629
| | | | | | | | | | | | | | | | | | | Add SQLite audit store for legal compliance (LCEN/DSA): logs user IP, actions (handshake, file download/upload/delete, stream, chat), and timestamps. Retention: 1 year, with cleanup method. WebRTC transport now logs all user actions to the audit store with remote IP extraction from the ICE transport. Local web UI rewritten as a proper admin dashboard: - Stats cards (groups, files, peers) - Connected peers table with IP, username, group, state - Group cards with file listings and shared directory info - Audit log page with event/user filtering - Dark theme, responsive, auto-refresh - JSON API: /api/status, /api/groups, /api/peers, /api/audit, /api/config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(node): index push to WebRTC peers + swarm registration (11.5, 11.9)Christophe Besson2026-08-112-1/+80
| | | | | | | | | | | When watchdog detects file changes, the daemon now: - Pushes INDEX_SYNC to all connected WebRTC peers in that group - Registers file hashes with hub /v1/swarm/register endpoint Also registers all file hashes on startup for initial discovery. hub_client: add register_swarm() method for bulk hash registration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(node): Phase 11 — production-ready daemon with WebRTC, WS, chat, HTTPChristophe Besson2026-08-113-29/+205
| | | | | | | | | | | | | | | | | | The node daemon was previously a skeleton that only started QUIC/TCP servers and the local web UI. All browser-facing functionality (WebRTC, hub WebSocket, chat store, HTTP file API) lived in QE demo scripts. This rewrites daemon.py to be fully self-contained: - WebRTC transport for browser clients (aiortc DataChannel) - Hub WebSocket task (signaling, revocations, WebRTC offers) - ChatStore per group (SQLite in ~/.local/share/meshbay/) - HTTP file API per group (create_http_app on configured port) - Graceful shutdown (all transports, stores, tasks) - hub_client: _ws tracking + send_ws() for chat notifications - config: data_dir field for persistent state - systemd: security hardening (ProtectSystem, StateDirectory) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: Phase 10c — MSE video streaming (real-time playback)Christophe Besson2026-08-111-0/+133
| | | | | | | | | Replace download-then-play VideoPlayer with MSE (MediaSource Extensions) streaming. Node remuxes to fMP4 via ffmpeg, probes codecs with ffprobe, and sends encrypted segments over DataChannel. Browser decrypts and appends to SourceBuffer — playback starts within seconds. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: chat notifications via hub, file delete, upload fix, cached display, ↵Christophe Besson2026-08-111-0/+12
| | | | | | | | | | | | | | | | | | | | | inline thumbnails Backend: - Chat store persists sender_name (SQLite migration, no more UUID display) - FILE_DELETE / FILE_DELETE_ACK MNP types — node admin can delete files - Node sends chat_notify to hub WS — hub creates notifications for offline members - Hub revocation.py handles chat_notify, creates per-member notifications Frontend: - Upload chunk size 64KB (was 1MB) — fixes WebRTC DataChannel max-message-size - Show cached files immediately while WebRTC connects - ChatImage component — inline image thumbnails in chat (download+decrypt) - File delete action in menu (group admin, with confirm dialog) - Member panel: "Owner" label instead of confusing "Group admin" - Create group page: hint about needing a node - Refresh index after chat attachment upload Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(ui): upload chunk size, cached file display, chat names, file delete, ↵Christophe Besson2026-08-112-14/+59
| | | | | | | | | | | | | | | | inline thumbnails - Upload chunks capped at 64KB to avoid WebRTC DataChannel max-message-size - Show cached files immediately while WebRTC connects (tabs visible during connection) - Persist sender_name in chat store (SQLite) — no more UUID display in history - File delete action in menu (node admin only, enforced server-side) - FILE_DELETE / FILE_DELETE_ACK MNP message types - Inline image thumbnails in chat attachments (download+decrypt, Signal-style) - Member panel: "Owner" label instead of "Group admin" to avoid hub/group admin confusion - Create group page: hint about needing a node - Refresh index after chat file attachment upload Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(node): iterate dict copies in WebRTC peer broadcast and close_allChristophe Besson2026-08-111-2/+2
| | | | | | | Concurrent peer disconnects could mutate _peers/_sessions during iteration, causing RuntimeError: dictionary changed size during iteration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(ui): group creation error, chat UUIDs, file preview, action menuChristophe Besson2026-08-111-0/+6
| | | | | | | | | | | | | | | | | | | Bug fixes: - Group creation "[object Object]" error: removed dead pkcs8 import code that threw before GEK wrapping, added array detail handling in hubFetch - Chat shows usernames instead of UUIDs (sender_name passed through node) - Join button: navigate to group on "Already a member" instead of error UI improvements: - Loading spinner animation for async states (connecting, fetching) - File action menu (3-dot dropdown: View, Download, Play) - Click filename to preview inline (images, text/code files) - FilePreview overlay for images and text files - Chat file attachment button (upload to node + structured message) - Chat attachment display (icon, filename, size) - Member panel: "Group admin" badge instead of plain "Admin" text Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: Phase 10b — Self-service UI (group create/join, upload, IndexedDB, ↵Christophe Besson2026-08-111-0/+44
| | | | | | | | | | | | | | | | search) Six self-service features for the web SPA: - Group creation UI with GEK auto-generation (AES-256-GCM ECIES) - Member management + invite by username (GEK wrapping for invitee) - Open group self-join flow (POST /v1/groups/{id}/join) - File upload client→node (FILE_UPLOAD MNP type, .uploads/ staging) - IndexedDB caching of group file indexes (instant display on revisit) - Cross-group file search (SearchPage, pure client-side on cached indexes) 11 new tests (166 total): 8 group self-service + 3 AES GEK wrap/unwrap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: Phase 9 — Web client SPA with WebRTC P2P transportChristophe Besson2026-08-118-56/+157
| | | | | | | | | | | | | | | | Complete browser-based client: Preact SPA with login, group file browser, encrypted download, video playback, group chat, i18n, and dark/light theme. Browser connects P2P to nodes behind residential NAT via WebRTC DataChannel (aiortc). Hub handles signaling only — all data flows E2E. Performance: pipelined downloads (8-chunk sliding window), binary msgpack wire format (no base64), redundant I/O elimination. Large file downloads stream to disk via File System Access API (showSaveFilePicker). Validated on SFR + Orange residential NATs, Chrome + Firefox, IPv4/IPv6. 132 tests passing. Deployed to meshbay.org + Orange node. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: Phase 9.1–9.5 — WebRTC DataChannel transport for browser P2PChristophe Besson2026-08-103-3/+399
| | | | | | | | | | | | | | | | | | | | | | | | Browser clients can now connect P2P to nodes behind residential NAT via WebRTC DataChannel with ICE/STUN. Validated on SFR Port-Restricted Cone NAT + 4G CGNAT across three scenarios (WiFi LAN, 4G IPv6, 4G IPv4 STUN). No TURN relay needed. Hub serves only as signaling relay (<1 KB). New files: - webrtc_server.py: aiortc-based WebRTC transport (node side) - signaling.py: SDP/ICE relay endpoint (hub side) - transport.js: browser WebRTC client with msgpack framing - webrtc-test.html: spike test page for browser→NAT→node validation - test_webrtc_transport.py: 4 tests (handshake, file transfer, auth, guard) - meshbay-draft-v4.md: architecture spec updated for web client Modified: - hub_client.py: WebRTC offer handling via hub WebSocket - revocation.py: node_id from WS auth + webrtc_answer routing - pyproject.toml: aiortc>=1.9 dependency 123 tests passing (117 existing + 6 new). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: Phase 7 — Node v2 (multi-group, Sender Keys, 0-RTT, chat, denylist)Christophe Besson2026-08-1011-65/+582
| | | | | | | | | | | | | | | | | | | | | | | | | | | Implements all 8 milestones (7.0-7.7): - 7.0: JWT carries `groups` claim; node verifies group membership at MNP handshake (QUIC + TCP+TLS). Resolves security review C2. - 7.1: QUIC 0-RTT session resumption via stored session tickets (17-21ms reconnect vs 47ms cold). - 7.2: Hub→node WebSocket signaling for NAT punch coordination (`client_incoming`/`punch_ready`) + jti denylist push. Denylist class blocks revoked users/jtis at handshake. - 7.3: Multi-group daemon — one QUIC port serves N groups with per-group GEK, shared_root, and index routing. - 7.4: HLS streaming via QUIC (STREAM_SEGMENT message type, ffmpeg segment extraction). - 7.5: Sender Keys protocol for group chat (Signal Groups approach). Each member has own sending chain key, HKDF chain ratchet, AES-256-GCM encryption, Ed25519 signing. Resolves security review C1. - 7.6: Chat store (SQLite via aiosqlite), CHAT_MESSAGE MNP wire type with peer broadcast, web UI with WebSocket push. - 7.7: Argon2id calibration CLI. First security review included (first-review.md). 109 tests, demo-v3 validated against meshbay.org production hub. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(node): punch_nat() — NAT traversal from QUIC server socketChristophe Besson2026-08-101-0/+16
| | | | | | | | | | | | | | | | | | | SFR residential NAT is Port-Restricted Cone: inbound is only allowed from (peer_ip, peer_port) if the node previously sent a packet TO (peer_ip, peer_port) from the SAME socket. punch_nat(peer_ip, peer_port): sends a probe UDP packet from the QUIC server's own transport (_transport.sendto), creating the correct NAT entry. Used after server.start() to enable direct QUIC connections through SFR NAT without UPnP or relay. demo-v2 result: QUIC/UDP direct Fedora→SFR NAT→meshbay.org validated. Connection time 12.7s (QUIC handshake through NAT). File transfer 700B. QuicChunkClient local_port param: ensures client binds to same port as punch_nat destination (Port-Restricted Cone requirement). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* fix(node): QuicChunkClient local_port + QuicChunkServer dual-stackChristophe Besson2026-08-102-8/+11
| | | | | | | | local_port=0 param on QuicChunkClient — specify for Port-Restricted Cone NAT hole punching (client must send from the same port the node probed to). QuicChunkServer default host '::' for IPv4+IPv6 dual-stack on Linux. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* fix: 4 corrections — streaming hash, watchdog bug, cipher doc, depsChristophe Besson2026-08-091-3/+11
| | | | | | | | | | | | | | | | | | | 1. indexer.py: streaming blake3 (8MB chunks) instead of read_bytes(). Large files (initrd.img, ISOs, VM images) no longer load into RAM. 2. QE/demo-v1/run_node.py: call indexer.start() not initial_scan(). initial_scan() alone never starts the watchdog observer — files added after startup were silently ignored. Added indexer.stop() on shutdown. 3. USERGUIDE.md §8: clarify symmetric vs asymmetric. Ed25519/X25519 = asymmetric (key pairs). ChaCha20-Poly1305 and AES-256-GCM = symmetric AEAD 256-bit (content encryption). ChaCha20 is PRIMARY; AES-GCM is optional browser-compat variant only. 4. pyproject.toml: aioquic, websockets, aiosqlite, slowapi added to proper package deps (were installed manually, now declared). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* fix: complete pyproject.toml deps + graceful QUIC fallbackChristophe Besson2026-08-091-4/+15
| | | | | | | | | | | | | | | | | | | | meshbay-node/pyproject.toml: add aioquic>=1.0 (was commented 'v2'), websockets>=12.0 (revocation push). Both are production code since Phase 5. meshbay-hub/pyproject.toml: add aiosqlite (tests without PostgreSQL), slowapi (rate limiting), websockets (revocation push), PyJWT (explicit). transport/__init__.py: QUIC imports wrapped in try/except — node works without aioquic (TCP+TLS + HTTP fallback). QUIC_AVAILABLE flag exported. QUICKSTART.md: replace manual pip list with 'pip install -e' that pulls all deps from pyproject.toml automatically. Add dependency table. CLAUDE.md: clarify that all deps go in pyproject.toml, not manual installs. 81/81 tests. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* feat: Phase 6 complete — chat, multi-group, federation, replication, webcryptoChristophe Besson2026-08-091-0/+142
| | | | | | | | | | | | | | | | | | | | | | | | | | 6.1 Double Ratchet (meshbay_common/ratchet.py): Forward secrecy, break-in recovery, out-of-order delivery. Signal-spec KDF_RK/KDF_CK via HKDF-SHA256. 11/11 tests. 6.2 Multi-group node (config.py): [[groups]] TOML array, per-group ports, back-compat [group]. 6.3 MHP federation persistence (db/models.py FederatedGroup + SwarmSource): receive_directory() now persists to federated_groups table. list_public_groups() includes federated results with source attribution. 6.4 Content replication (node/replication.py + hub SwarmSource): ContentReplicator: fetch-index, download, hash-verify, register-swarm. Hub: POST /v1/swarm/register, GET /v1/swarm/{hash} for multi-source. 6.5 Browser private group (webcrypto.py + static/crypto.js): AES-256-GCM variant of GEK for WebCrypto-compatible groups. crypto.js: SubtleCrypto importGEK + deriveChunkKey + decryptChunk. Keys distinct from ChaCha20 via :aes HKDF info suffix. 4/4 tests. 74/74 tests total. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* feat(node): multi-group config support — 6.2Christophe Besson2026-08-091-24/+65
| | | | | | | | | Config now supports [[groups]] array (N groups) alongside back-compat [group] single section. Each group has independent port/quic_port/http_port and visibility. GroupConfig gains quic_port, http_port, visibility fields. NodeConfig gains quic_port and http_port defaults. 70/70 tests. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* feat: add revocation push (WebSocket hub→node) — 5.7Christophe Besson2026-08-091-0/+153
| | | | | | | | | | | | | Hub: /v1/nodes/ws WebSocket endpoint for persistent node connections. /v1/admin/revoke marks user/group revoked in DB, signs JWT revocation token (EdDSA), broadcasts to all connected nodes. Node: RevocationSubscriber maintains WS connection, verifies incoming revocation tokens offline (hub Ed25519 PK), adds to local blocklist (_revoked_users/_revoked_groups sets). 53/53 tests. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* feat(node): add QUIC transport (MNP v2) — quic_server + quic_clientChristophe Besson2026-08-093-2/+482
| | | | | | | | | | | | | | QuicChunkServer/QuicChunkClient: same MNP protocol over QUIC/UDP. Enables hole-punching (Spike 4 Cone NAT validated). Uses aioquic 1.3.0. Bug found+fixed: asyncio.Event race condition in client recv loop (quic_event_received overwrote _stream_events[0] after _recv created it). Fixed with asyncio.Queue (no shared mutable state). Server uses synchronous handlers in quic_event_received (avoids ensure_future transmit timing issue). 3/3 tests. Full suite: 50/50. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* feat(node): add HTTP file API for browser access (Phase 4)Christophe Besson2026-08-092-1/+337
| | | | | | | | | | | | | FastAPI app on port 19001: GET / (node info), GET /index (public group index JSON), GET /file/{id} (full download), GET /file/{id}/{n} (encrypted or plaintext chunk), GET /hls/{id}/playlist.m3u8 + GET /hls/{id}/{n}.ts (HLS streaming via ffmpeg). Public groups: index browsable without auth, files downloadable. Private groups: chunks encrypted with GEK, auth required. 7/7 tests passing. Full suite: 47/47. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* feat(node): add config, daemon, and local web UIChristophe Besson2026-08-094-2/+478
| | | | | | | | | | | config.py: TOML + env var overrides, sane defaults. daemon.py: full startup sequence (keystore→hub→GEK→indexer→ server→UI), SIGINT/SIGTERM shutdown, calibrate-argon2 command. ui/app.py: FastAPI on localhost:18000, status+files JSON API, HTML status page (auto-refresh 10s). All bound to 127.0.0.1. Full suite: 29/29 tests. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* feat(node): add TCP+TLS chunk server and client (MNP v1)Christophe Besson2026-08-094-0/+512
| | | | | | | | | | Self-signed TLS cert (RSA-2048, TLS 1.3 min). Server: JWT offline verify, index sync, file_request → encrypt+sign chunk pipeline. Client: handshake, fetch_index, fetch_chunk with Ed25519 verify + blake3 hash check + GEK decrypt. Integration test: 2MB file served in 2 chunks, reassembled == original. 3/3 tests. Full suite: 29/29. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* feat(node): add directory indexer and Mesh Group IndexChristophe Besson2026-08-093-0/+409
| | | | | | | | | GroupIndex: msgpack→zstd→GEK-encrypt→sign for private groups, plaintext+sign for public groups. DirectoryIndexer: watchdog-based watcher, async initial scan via thread pool, on_change callback. Delta support (diff between versions). 10/10 tests passing. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* feat(node): add hub client with JWT offline verify and GEK fetchChristophe Besson2026-08-091-0/+256
| | | | | | | | Register/login/announce/token-refresh/GEK-unwrap. JWT jti and pk_user verified at login. Hub PK cached after first fetch. 6/6 tests passing with httpx.MockTransport (no network). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* feat(node): add keystore module with 3 unlock modesChristophe Besson2026-08-091-0/+225
| | | | | | | | Argon2id + AES-256-GCM encryption at rest. Unlock via env var (MESHBAY_UNLOCK_KEY), unlock.key file (chmod 600), or interactive getpass. 10/10 tests passing. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* chore: initialize monorepo structure for MeshBayChristophe Besson2026-08-096-0/+12
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>