# MeshBay — Project Conventions ## What this project is MeshBay is a decentralized peer-to-peer platform for file sharing, video streaming, and group messaging. See `docs/meshbay-draft-v5.md` for the architecture specification (v3/v4 superseded). ## Repository structure ``` meshbay/ ├── packages/ │ ├── meshbay-common/ # Shared crypto + protocol — python3-meshbay-common RPM │ ├── meshbay-hub/ # Hub server (FastAPI + PostgreSQL) — meshbay-hub RPM │ └── meshbay-node/ # Node daemon + local UI — meshbay-node RPM ├── poc/ # POC spike scripts (reference, not production) ├── docs/ # Architecture drafts and POC plans ├── packaging/ # RPM spec files, DEB control files, systemd units └── QE/ # NOT versioned (.gitignore) — test artefacts, credentials, demos ├── demo-v1/ # Scripts démo opérationnels (setup_demo.py, run_node.py, download.py) ├── spikes/ # Expérimentations futures (remplace ~/draft/) └── server-state/ # Inventaire de ce qui tourne sur meshbay.org ``` **Règle QE/** : tout test sur meshbay.org doit ouvrir le port UFW, tester, et fermer le port + tuer les processus dans le MÊME bloc de commandes. Jamais de processus orphelins ni de ports ouverts après un test. ## Python environment - **Minimum Python:** 3.12 - **Build backend:** hatchling (per package `pyproject.toml`) ```bash # Créer le venv (--clear si recréation sur une autre machine/OS) python3 -m venv .venv --clear source .venv/bin/activate # Toutes les dépendances sont déclarées dans les pyproject.toml — un seul pip install suffit pip install -e packages/meshbay-common -e packages/meshbay-hub -e packages/meshbay-node pip install pytest pytest-asyncio aiosqlite # extras dev ``` Les deps clés (aioquic, watchdog, fastapi, blake3, etc.) sont dans les `pyproject.toml` et installées automatiquement. Ne pas ajouter manuellement des packages sans les déclarer dans le bon `pyproject.toml`. > **Ne jamais copier `.venv/` entre machines d'OS différents.** Si rsync depuis Fedora vers Ubuntu, > exclure `.venv/` et recréer sur la cible avec `python3 -m venv .venv --clear`. > Sans `--clear`, `certifi.where()` pointe vers un chemin Fedora inexistant sur Ubuntu → `FileNotFoundError`. ```bash # Lancer les tests .venv/bin/pytest ``` ## Code conventions - **Linter/formatter:** ruff (`uv run ruff check .` / `uv run ruff format .`) - **Line length:** 100 - **Type hints:** required on all public functions - **Comments:** only when the WHY is non-obvious; no docstrings restating the function name - **No prints in library code** — use `logging` module ## Versioning ### Package versions (SemVer) - Format: `MAJOR.MINOR.PATCH` - Pre-1.0: breaking changes bump MINOR, not MAJOR - All three packages share the same version number (released together) ### Protocol versions (independent) - MNP: `0.2` → bumped independently of package version - 0.2 added `PING`/`PONG` and backward chat paging (`before` / `has_more`). Additive, so an 0.1 peer still works: it sends no `before` and is answered with the newest page, which is what it wanted - MHP: `0.1` → bumped independently of package version - Every wire message carries a `v` field - Breaking change → MAJOR bump; backward-compatible → MINOR bump - N-2 MINOR backward compatibility guaranteed ## Commit messages (Conventional Commits) ``` feat(node): add directory watcher with watchdog fix(hub): include jti in all JWT tokens chore(common): add Argon2id calibration to crypto.py docs: update draft v3 with POC findings test(common): add wrap/unwrap GEK round-trip test ``` Types: `feat`, `fix`, `chore`, `docs`, `test`, `refactor`, `perf` Scope: `hub`, `node`, `common`, or omitted for cross-cutting ## Security rules - **Never commit private keys** (hub_private.pem, *.key, unlock.key, keystore.enc) - **Never commit QE/** — credentials, test keys, demo data go there - **Never log GEK, private keys, or plaintext passwords** — even at DEBUG level - **meshbay.org is internet-facing** — open port → test → close port + kill processes in same block ## First security review (2026-08-10) — see `first-review.md` **Critical (before Phase 7):** - **C1** Chat: Sender Keys protocol, NOT shared Double Ratchet (pairwise protocol would cause key/nonce reuse in group context). `ratchet.py` kept for future 1:1 DM. - **C2** JWT must carry `"groups": [group_ids]` claim. Node MNP handshake must verify group membership before serving content. Without this, any authenticated user accesses any group. **Significant (Phase 7-8):** - **S1** Admin revocation endpoint has no authz check ✅ DONE (Phase 8.1 — config-based require_admin) - **S2** Email stored in plaintext (spec says encrypted at rest) ✅ DONE (Phase 8.2 — AES-256-GCM, HKDF from hub key) - **S3** jti denylist push via hub→node WebSocket → Phase 7.2 - **S4** AES-GCM keystore IV fixed: 128-bit → 96-bit (NIST SP 800-38D) ✅ DONE - **S5** Refresh token rotation (one-time use) ✅ DONE (Phase 8.3 — family-based reuse detection) **Node sovereignty (2026-08-12):** - **NS1** GEK-HMAC proof in handshake — blocks hub admin from accessing any group content ✅ DONE - **NS2** Ed25519 challenge-response for admin operations — blocks hub admin impersonation ✅ DONE - **NS3** `gek_req` endpoint removed — node never serves GEK in plaintext ✅ DONE - **NS4** ~~`admin_pk_ed25519` auto-pinned from keystore~~ ❌ **that was finding M3.** The keystore key is not the key the browser signs with, so every admin operation failed closed. Authority now comes from the node's roster — `meshbay-node operator pair` (2026-08-14). `admin_pk_ed25519` was kept as a legacy form and is now **removed** (2026-08-15) — one source of authority, the roster. A config still naming it is warned about at startup, never obeyed. Never auto-pin again, and never resolve the operator's key through the hub - **NS5** DTLS channel binding in GEK-HMAC — `HMAC(GEK, nonce || offer_fp || answer_fp)` detects WebRTC signaling MitM ✅ DONE - **NS6** Chat `sender_id` enforced from authenticated session — prevents impersonation ✅ DONE - **NS7** Node Ed25519 auth — node daemon authenticates to hub via `POST /v1/nodes/auth` (Ed25519 signed timestamp), no auth_key/password on node. JWT `scope: "node"` blocks group management (create/add/delete/join). Operator manages groups from browser only. ✅ DONE - **NS8** GEK-required enforcement — node REFUSES connections when GEK is None (no `gek_required: false` bypass). GEK initialization via node local admin UI only. ✅ DONE **Known remaining trust assumptions (Phase 12 — all actionable items done):** - **T1** ✅ DONE: password split (auth_key / bundle_key, independent PBKDF2). Legacy migration on first login. - **T2** ✅ **CLOSED 2026-08-14** (the finding is H3). Not by safety numbers: the invite path stopped reading the directory. The node holds the GEK and wraps it for a key the recipient proves possession of; identities are bound to accounts by one-time codes the hub never sees. See `docs/invite-pairing-v1.md` - **T3** SPA served by hub → fundamentally unsolvable in browser. Fix: native client or browser extension **T3 attack surface reduction (2026-08-12, all phases complete):** - **Phase 1** ✅ DONE: GEK bundles moved from hub to node P2P (WebRTC DataChannel). No hub fallback. - **Phase 2** ✅ DONE: Keypair bundles moved from hub to node P2P. Registration stores locally, pushed to node on first connect. Hub never stores keypair bundles. - **Phase 3** ✅ DONE: Hub GEK cleanup — `GET /gek` endpoint removed, `GEKBundle` model removed, `gek_bundles` table dropped, `keypair_bundle` column removed, member-add URL cleaned (`/gek` suffix removed), Alembic migrations updated. **Browser crypto hardening (2026-08-13):** - `_bundleKey` persisted in IndexedDB (CryptoKey survives page refresh) - `_sessionKeys` persisted in sessionStorage (survives refresh, cleared on tab close) - `_pkFromSk()`: derive X25519 public key from recovered private key via JWK export (no hub fetch) - `regenerateKeys()` is gone entirely (2026-08-14): identity keys are per node, so rotation is `meshbay-node member unpin ` plus a fresh code - Raw answer SDP saved before `setRemoteDescription` (Chrome strips sha-256 from multi-hash SDP) - Upload chunk size: 48KB (fits aiortc SCTP limit after msgpack overhead) **Architecture validated:** crypto primitives, GEK wrapping (ECIES), trust model, key hierarchy, on-the-fly encryption, transport abstraction, DTLS channel binding. ## Second security review (2026-08-13) — see `second-review.md` **6 critical, 7 high findings. Phase 11.5 is BLOCKING — see `devel-phases-next.md`.** The current build must not host real private data. The claims above about node sovereignty and P2P crypto material were **overstated**. The GEK-HMAC proof, Ed25519 admin challenge and channel binding are real, but they are enforced on the WebRTC path only, and three other paths into the node were left behind. - **C1** Node HTTP API (`http_server.py`) serves private group **index and plaintext files with no authentication**, on `0.0.0.0`, for every group — bypasses the entire sovereignty layer - **C2** `/v1/nodes/ws` trusts a client-supplied `node_id` → any user hijacks a node's signaling identity and impersonates it to browsers - **C3** The node never authenticates itself to the client (`node_pk` is never verified, no proof of possession) - **C4** Keypair bundles are served pre-proof and pushed to every node joined; PBKDF2-only → offline password attack - **C5** Any member can overwrite arbitrary shared files (upload) and seize the group GEK (`gek_bundle_store` + auto-activation) - **C6** GEK proof exists on WebRTC only — QUIC and TCP accept a bare JWT (chat injection) - **H1** Multi-group nodes share one `chat_store` and one peer registry → cross-group chat leak - **H2** Stored XSS in the node admin UI via uploaded filename → node takeover - **H3** Hub is the key directory → key substitution at invite yields the GEK. "Unreadable even by the hub" is true against a *passive* hub only ## Invite redesign (2026-08-14) — closes H3 and M3 See `docs/invite-pairing-v1.md`. Read it before touching invites, admin authority or `gek_bundle_store`. - **The node wraps the group key**, on every connection, for the X25519 key the joiner signed with their pinned Ed25519 identity. **Nothing fetches a public key from the hub to wrap for** — not the SPA, not `gek-init`. That lookup *was* H3 - **`gek_bundle_store` is deleted**, not gated. No member hands the node key material - **The node's roster decides who gets the key**, not hub membership: a hub that invents an account and mints it a token gets `not_authorized_for_group` - **One-time codes** bind a key to an account without the directory. 40 bits, single use, one account, node-wide lockout. 7 days for invitations, 24 h for operator pairing, both in `[node]` of node.toml - **`join_policy`** (`invite`|`open`) is read from **node.toml, never the hub** — a hub able to declare a group open would be handed its key. Unknown group ⇒ `invite` - Operator surface over SSH: `operator pair`, `member list|invite|revoke|unpin`. Deleting a file is the last browser-only operation - Revocation now works for key delivery (nothing stored survives it) — but **still rotate the GEK**, the ex-member holds the current one ## Keypair bundles and the browser KDF (2026-08-14) - The bundle key is **Argon2id 128 MB / t=3 / p=1**, WebAssembly vendored under `static/vendor/` (CSP forbids external hosts; 12.2 must keep `wasm-unsafe-eval`). **Do not change the parameters in one place**: `keyderive.js`, the QE harness and `test_bundle_kdf_parity.py` are held byte-identical by that test, and a mismatch presents as an account nobody can open - Bundles carry an `MBK2` marker; the PBKDF2 form is still readable and is re-encrypted on the next backup. Both keys are derived at sign-in because the passphrase is deliberately not retained - Cost is paid **once per sign-in** (650 ms bundle + 239 ms auth_key); reloading a page derives nothing — the key lives in IndexedDB - The bundle is stored on **every node its owner joins**. That is what makes a second browser work, and it is C4: cracking one yields identity keys, hence content on *other* nodes and the ability to sign as that user. Draft-v5 §7.1 has the measured numbers. **The passphrase is the wall; the KDF is a speed bump** - Floor: 12 characters and ~60 estimated bits, enforced client-side only — with the password split (T1) the hub never sees a passphrase ## Identity keys are PER NODE (2026-08-14) See `docs/per-node-identity-v1.md`. Read it before touching registration, the keypair bundle, or anything that looks like a user's public key. - A keypair is created at **first contact with a node**, encrypted under the passphrase, and left on that node. Never reused elsewhere. Cracking one yields the identity used with that operator and nothing anywhere else - **The hub stores and publishes no user keys.** `users.pk_ed25519`/`pk_x25519` are dropped, `PUT /me/keys` is gone, `/pubkeys` returns an account id and the node linking key. Do not reintroduce a key directory — that was H3 - **Tokens carry no `pk_user`.** The node recorded it as the uploader's identity and authorized deletion against it, so whoever issued tokens decided who could delete a file. Attribution uses the roster pin (`_pinned_pk`) - Registration generates nothing, so a scripted signup is a real account — `QE/deploy/demo.py bootstrap` takes a wiped hub and node to a working demo - The key handed back on a join belongs to the **group of the connection**, not the group named in the invitation (an operator pairs node-wide while opening a group) ## Two lessons that cost four rounds of live testing - **`QE/deploy/e2e.py` cannot test `app.js`.** It is a second implementation of the client, written in the right order by construction: it proves the protocol and nothing about the SPA. Three ordering bugs passed it and failed in a browser. `test_spa_ordering.py` exists for that class and is worth extending - **An unbounded `await` on the hub socket makes a node silently unreachable.** Three instances found in `maintain_ws`: the offer handler awaited inside the read loop, `ws.recv()` for auth with no timeout, and `return` on auth refusal ending the task for good. Symptom is always the same — daemon running, logging nothing, `connected_nodes: 0`, socket in CLOSE-WAIT. Look there first - **A background task nobody holds can be collected mid-flight.** asyncio keeps only a *weak* reference to a task, so `asyncio.ensure_future(coro)` with the result thrown away may be garbage-collected while still running — the loop logs "Task was destroyed but it is pending!" and nothing else happens. For `_stream_video` that meant its `async with sem` never reached `__aexit__` and a transcode slot was lost for good. `WebRTCPeerSession._spawn()` exists for this; never call `ensure_future` there directly, and `test_task_lifetime.py` fails the build if anyone does - **`await proc.wait()` after `kill()` still deadlocks on a full pipe.** ffmpeg outruns a credit-paced viewer; stop reading its stdout — which is what closing the player does — and the transport cannot finish closing, SIGKILL or not. Measured 2026-08-16: closing a viewer after 99 segments held a slot past the 15 s handover timeout, so the next video hung and the one after was refused. Drain the pipes, then wait with a timeout, and release the slot regardless - **Losing a peer must *stop* its work, not merely forget it.** The WebRTC `connectionstatechange` handler popped the session from a dict and nothing else, so a closed tab went on transcoding for the full 120 s credit timeout. Anything holding a resource needs `shutdown_tasks()` on the way out - **A service worker being *active* is not the page being *controlled*.** An uncontrolled page's requests never reach the worker's fetch handler, so the streamed-download path handed over its stream and was never asked for it — `writer.write()` then blocks on backpressure that will never lift, and the download freezes after exactly one chunk. Require `navigator.serviceWorker.controller`, and have the worker confirm it actually served the request before trusting the sink - **A removed `useState` leaves its setter behind and nothing complains.** `setActionsOpen` outlived `actionsOpen` and shipped: every action in the Files panel threw ReferenceError on click. `grep actionsOpen` does not find `setActionsOpen` — the capital breaks the match, which is exactly how it got through. `test_transport_contracts.py` compares called setters against declared ones - **`node --check` validates syntax, not names.** It caught none of the above. Neither can `e2e.py`, which is a second implementation of the client. Source- reading tests are weak evidence and are the only evidence available for the SPA; prefer ones that re-derive a value from the source over ones that restate it - **A test that models a fix agrees with it by construction.** The first buffer-ceiling test transcribed the player's credit loop into a small model and passed, while the player it was written for still hung on a phone. The model and the fix had the same author and the same misunderstanding. `tests/harness/ mse_harness.mjs` lifts `bufferedAhead`, `evictBehind`, `flushQueue` and `pump` out of `app.js` *as text* and executes them; what it models is the browser. When even that was not enough, a headless Chrome driven against real fragmented MP4 reproduced the defect in one run. Model the environment, never the code under test - **A refresh token that rotates must be stored, or it is spent once.** The hub revokes the refresh token presented, returns a replacement, and treats a revoked one presented again as theft — revoking the whole family. The SPA kept only the access token out of that response, so renewal worked once and the second attempt destroyed the session, which is why signing out and in was the only cure. Nothing used the path at all: `hubFetch` reported 401 like any other error, and watching a film is an hour in which the hub hears nothing, because the video is WebRTC. Renew on a margin, on returning to the tab, and on a 401 with a replay; coalesce concurrent renewals, or the second presents what the first just spent and looks exactly like theft. `tests/harness/session_harness.mjs` runs it against a hub that enforces rotation — a lax stub would pass the broken client - **An effect keyed on a value that used to be constant.** The WebRTC dial listed `token` among its dependencies. Harmless while a token only ever expired; once the session renewed itself the string rotated, and the effect tore the connection down and rebuilt it — worst at mount, where a stale token is renewed exactly while ICE is negotiating, so the browser abandoned the handshake and the node sat in `connecting` for ever. Depend on whether there is a token, not which one, and read the live one where it is used. Before making something vary that never varied before, grep the dependency arrays it appears in — this and the hook-ordering fault above are the same shape: code that is correct read on its own and wrong against the component lifecycle - **A stylesheet does not tell you where anything lands.** The responsive tests pinned numbers out of `style.css` and said in their own docstring that a layout could not be measured because there was no browser in the suite. There is one — Chrome, from the video work — and the difference is the transfers panel: `width: 330px` was never the problem, the problem was that it is anchored to a button which is not at the right edge of the screen, so it hung 138 px off the left of a 320 px phone and hid the file names. No reading of the rule would have shown that. `tests/harness/layout_probe.py` renders the real stylesheet at a given width (in an iframe — a headless window will not go below ~500 px) and returns rectangles; one browser measures every width, because one apiece put three minutes on the suite. Assert on geometry, and check the test fails with the fix removed - **A hook cannot depend on one declared below it.** `const a = useCallback(fn, [b])` evaluates `[b]` where it is written, so a `b` further down the component is still in its temporal dead zone: `ReferenceError` on every render, before anything the component does can run. The component simply does not appear — clicking a video did nothing at all, with no error on screen and nothing in the node's log because nothing was ever requested. It reached production. `node --check` passes; the code is well-formed. Worse, the MSE harness extracted the player functions in a list order of its own and therefore *reordered* them, quietly repairing the one class of defect it was best placed to catch — it now sorts by position in the file. `test_hook_ordering.py` checks the whole SPA - **Flow-control accounting comes before every early return.** A segment that arrived is no longer in flight, whatever is then done with it. Discarding one before decrementing the in-flight window leaked a slot per discard, and `reinitAt` is asynchronous, so a seek's whole window could arrive while it was still awaiting its `updateend`s — the player then believed a full window was in flight, granted no further credit, and the node waited for ever while logging a stream it had fed perfectly. A race, so it worked twice and hung on the third try; "it works now" is not evidence against a race, and `tests/harness/window_leak.mjs` forces the worst case instead - **A new stream starts from a known state, and that list grows.** `appendingRef` and `endedRef` were the first two; `awaitingInitRef` and `seekTargetRef` repeated the same bug a session later, and worse — only `reinitAt` lowers `awaitingInit`, and the next film never reaches it, so every one of its segments would have been discarded. Reset at the *start* of the stream, never in the teardown of the one before, which is skippable - **`Cache-Control: no-cache` only binds a browser that asks.** One that cached the SPA before that header existed applies heuristic freshness — a fraction of the file's age, days for a file dated weeks ago — and does not ask at all. A fix can be written, tested, deployed, served and still not be what runs, which is indistinguishable from a fix that does not work. The whole module graph is now served under `/a//` so relative imports inherit the prefix and no cache can serve yesterday's build or half of each. `test_asset_versioning.py` - **Redeploying during someone else's test destroys the evidence.** A node deploy restarts the daemon, which kills every live WebRTC session — the tester sees "transport not connected" caused by nothing they did — and `deploy-node.sh` truncates `/tmp/meshbay-node.log`, taking the reproduction with it. Ask before deploying while a reproduction is in flight - **`updateend` fires for `remove()` as well as `appendBuffer()`.** Crediting the node from that event paid it for the player's own evictions: every time room was made, more was asked for to fill it. Credit now follows the buffer, decided in one place, and the append path grants nothing - **Flow control on a media stream is a window, not a debt.** Granting a credit per append means pulling at network speed, which for a film is far faster than watching it and fills the browser's SourceBuffer ceiling; accumulating those credits and releasing the balance when the buffer finally drains sends the lot in one burst and then says nothing for forty-six seconds. Bound the read-ahead by *time past the playhead* and top a small window up as segments land. A viewer deliberately holding credit must still say so, or the node's stall timeout ends a film that is merely paused - **`create_all()` is not a migration.** It creates missing *tables* and never a missing *column*, so a new column reaches the tests (fresh DB every run) and never reaches the deployed hub. Symptom: one endpoint answering 500 with an HTML body while everything else works, and a `psycopg` `UndefinedColumn` in the journal. `deploy-hub.sh` runs `alembic upgrade head` before restarting the service; a schema change that skips a migration file will still pass every test you have - **Some paths only exist in a browser, and only one browser has them.** The download-to-disk story is three different mechanisms — File System Access (Chrome/Edge), a service worker streaming a response (Firefox/Safari), and a blob as the floor — and no test in this repo exercises any of them. `test_downloads.py` pins the contracts by reading the source; the behaviour needs a person with a large file. Confirmed by the operator on 2026-08-15: Firefox, 180 MB, written to disk. Nothing multi-gigabyte has been measured **Corrections to remember:** - `punch_nat()` is **not** a NAT traversal stack — one UDP probe, no STUN, no candidate gathering, one ISP validated. **ICE/STUN (WebRTC) is the traversal path**, for native clients too (via `aiortc` in Python) - Argon2id 256 MB was applied to the **hub only**; `crypto.py` keystore is still 64 MB - Sender keys must be distributed **pairwise to identity keys**, never GEK-derived - Chat is plaintext on the wire and at rest; the index is plaintext on the WebRTC path ## Known calibration TODOs - Argon2id `memory_cost`: ✅ DONE — bumped to 262144 (256 MB) in pw_version=2. Existing v1 users (64 MB) are transparently rehashed on next successful login. CLI `calibrate` command still TODO for per-hardware tuning. ## NAT traversal — empirical results ### QUIC native clients (demo-v2) SFR residential Fedora 44 → meshbay.org OVH VPS: - **NAT type**: Port-Restricted Cone - **Mechanism**: `QuicChunkServer.punch_nat()` sends probe from QUIC server socket - **Scripts**: `QE/demo-v2/` ### WebRTC browser clients (Phase 9 spike, 2026-08-10) **SFR residential NAT** — Mobile 4G SFR → node behind SFR residential (Port-Restricted Cone + CGNAT 4G): | Test | ICE path | Result | |---|---|---| | WiFi LAN | IPv6 direct | OK, ~100ms | | 4G + IPv6 | IPv6 inter-network | OK, ~600ms | | 4G + IPv4 only (IPv6 disabled) | STUN hole-punch IPv4 | OK, ~650ms | **Orange Livebox NAT** — Firefox/Chrome laptop (SFR) → node behind Orange residential NAT: | Test | ICE path | Result | |---|---|---| | Chrome laptop → Orange node | IPv6 inter-network | OK, ~7000ms | | Firefox laptop → Orange node | IPv6 inter-network | OK, ~6700ms | | Firefox laptop → Orange node (IPv6 disabled) | STUN hole-punch IPv4 | OK, ~6900ms | - **Two ISPs validated** — SFR + Orange residential NAT, both work without TURN - **No TURN relay needed** — ICE/STUN handles both NAT types automatically - **Hub role**: signaling only (SDP/ICE relay via WebSocket, <1 KB) - **Data path**: browser ↔ node P2P via WebRTC DataChannel - **Scripts**: `QE/demo-v3/run_node_webrtc.py`, test page at `/webrtc-test.html` ## Key modules — où trouver quoi | Need | Module | File | |---|---|---| | Chunk encryption (prod) | `meshbay_common.crypto` | `crypto.py` | | Key derivation from password | `meshbay_common.keyderive` | `keyderive.py` | | Key bundle (web) | `meshbay_common.keyderive` | `keyderive.py` + `static/keyderive.js` | | GEK wrap/unwrap (ECIES) | `meshbay_common.crypto` | `crypto.py` | | Double Ratchet (1:1 DM, future) | `meshbay_common.ratchet` | `ratchet.py` | | Sender Keys (group chat) | `meshbay_common.senderkeys` | `senderkeys.py` (Phase 7.5) | | AES-GCM (browser) | `meshbay_common.webcrypto` | `webcrypto.py` + `static/crypto.js` | | Node keystore | `meshbay_node.keystore` | `keystore.py` | | QUIC NAT punch (native) | `meshbay_node.transport.quic_server` | `QuicChunkServer.punch_nat()` | | WebRTC transport (browser) | `meshbay_node.transport.webrtc_server` | Phase 9.3 — `aiortc` DataChannel | | WebRTC signaling (hub) | `meshbay_hub.api.signaling` | Phase 9.2 — SDP/ICE relay | | Browser transport client | `static/transport.js` | Phase 9.4 — WebRTC DataChannel | | Web SPA | `static/app.js` | Phase 9.6 — Preact + preact-router | | File download (large) | `static/app.js` | File System Access API (`showSaveFilePicker`) — stream to disk | | Background tasks (node) | `meshbay_node.transport.webrtc_server` | `_spawn()` — the only way to start one; a bare `ensure_future` can be collected | | Stream handover (node) | `meshbay_node.transport.webrtc_server` | `_replace_stream` + `shutdown_tasks` — one viewer, one film, and the slot comes back when they leave | | Download backpressure (node) | `meshbay_node.transport.webrtc_server` | `DOWNLOAD_BUFFER_HIGH` — 8 × 1 MB answered blind queues 8 MB on the channel | | Streamed download (browser) | `static/downloads.js` + `static/sw.js` | Needs the page *controlled*, and the worker confirms it served the request | | Leave a group (hub) | `meshbay_hub.api.groups` | `POST /v1/groups/{id}/leave` — self only; the owner is refused | | Public group cap (hub) | `meshbay_hub.api.groups` | `_check_public_group_quota` — 10 live public groups per owner, staff exempt. **Checked at creation only, because PATCH refuses to change visibility** | | Node presence (hub) | `meshbay_hub.api.groups` | `node_online` on `/v1/groups/mine`, read from the signaling registry — no poll, no timer | | Chat paging (node) | `meshbay_node.chat.store` | `get_recent` / `get_before` / `has_before`. `get_messages` pages *forwards* and is not what a chat opens with | | Liveness (MNP) | `meshbay_common.protocol` | `PING`/`PONG` on an **already-open** channel; never for discovery — a handshake costs 0.6-7 s | | Profile page (browser) | `static/app.js` | `ProfilePage` — identity, node link, pins, account deletion. Settings keeps behaviour | | i18n (browser) | `static/i18n.js` | `t()` lookup + `Intl.PluralRules`, region-aware resolution, localStorage lang selection | | Translation catalogues | `static/locales/*.js` | One per language, fetched on demand. `en.js` is the source; `test_locales.py` holds the other nine to its key set | | Admin API (hub) | `meshbay_hub.api.admin` | Phase 10.2 — user/group mgmt, audit logs, stats | | Admin UI (browser) | `static/app.js` | Phase 10.3–10.4 — AdminPage component, 5 tabs | | Auth dependencies | `meshbay_hub.api.deps` | `require_admin`, `require_moderator`, `get_current_user`, `require_user_scope` | | Node auth (hub) | `meshbay_hub.api.nodes` | `POST /v1/nodes/auth` — Ed25519 challenge-response, node-scoped JWT | | Site overlay | `site/` | Phase 10.1 — landing, about, downloads (meshbay.org-specific) | | Notifications (hub) | `meshbay_hub.api.notifications` | Phase 10.5 — CRUD, per-user, triggered by admin/group actions | | Version check (hub) | `meshbay_hub.api.hub` | Phase 10.10 — `GET /v1/hub/version` | | Group self-service (hub) | `meshbay_hub.api.groups` | Phase 10b — create, join, members (GEK exchange is P2P) | | File upload (node) | `meshbay_node.transport.webrtc_server` | Phase 10b.4 — FILE_UPLOAD MNP handler | | GEK wrap AES (browser) | `static/crypto.js` | Phase 10b.2 — AES-256-GCM ECIES for WebCrypto | | GEK HMAC proof (browser) | `static/crypto.js` | `hmacGEK()` — HMAC-SHA256 with DTLS channel binding | | DTLS fp extraction (browser) | `static/transport.js` | `_extractDtlsFingerprint()` — SDP fingerprint for channel binding | | DTLS fp extraction (node) | `meshbay_node.transport.webrtc_server` | `_extract_dtls_fingerprint()` — SDP fingerprint for channel binding | | Ed25519 sign (browser) | `static/keyderive.js` | `signChallenge()` — admin challenge-response | | Auth key derivation (browser) | `static/keyderive.js` | `deriveAuthKey()` — password split, hub never sees raw password | | GEK wrap AES (Python) | `meshbay_common.crypto` | Phase 10b.2 — `wrap_gek_aes()` / `unwrap_gek_aes()` | | IndexedDB cache (browser) | `static/app.js` | Phase 10b.5 — group index caching | | Cross-group search (browser) | `static/app.js` | Phase 10b.6 — SearchPage, client-side | | MSE video streaming (node) | `meshbay_node.transport.webrtc_server` | Phase 10c — ffmpeg fMP4 remux + encrypted segments | | MSE video streaming (browser) | `static/app.js` | Phase 10c — MediaSource + SourceBuffer progressive playback | | Video codec detection | `meshbay_node.transport.webrtc_server` | Phase 10c — `_probe_video()` ffprobe + MSE codec strings | | Node daemon (production) | `meshbay_node.daemon` | Phase 11 — WebRTC + WS + chat + HTTP + audit all wired | | Node config | `meshbay_node.config` | `node.toml` loader, `data_dir` for chat/audit DBs | | Hub WS client | `meshbay_node.hub_client` | `login()` (Ed25519) + `maintain_ws()` + `send_ws()` — no auth_key on node | | Chat store | `meshbay_node.chat.store` | SQLite per-group, `data_dir/{group_id}/chat.db` | | Audit store | `meshbay_node.audit` | SQLite IP/action log, `data_dir/audit.db` (legal compliance) | | Bundle store (node) | `meshbay_node.bundle_store` | SQLite P2P GEK + keypair bundles, `data_dir/bundles.db` — hub never stores crypto | | P2P bundle exchange (MNP) | `meshbay_common.protocol` | GEK + keypair bundle STORE/FETCH/RESP message types | | Bundle via DataChannel | `static/transport.js` | GEK + keypair bundle fetch during handshake, store after connect | | Key persistence (browser) | `static/app.js` | `_bundleKey` in IndexedDB, `_sessionKeys` in sessionStorage | | pkX from private key | `static/transport.js` | `_pkFromSk()` — JWK export to derive X25519 public key | | Group delete (hub) | `meshbay_hub.api.groups` | `DELETE /v1/groups/{group_id}` — admin only | | JWT scope enforcement | `meshbay_hub.api.deps` | `require_user_scope` — blocks node-scoped tokens from mutations | | Node local admin UI | `meshbay_node.ui.app` | Dashboard, peers, groups, audit log (localhost:18000) | | Demo scripts | — | `QE/demo-v1/*.py`, `QE/demo-v2/*.py`, `QE/demo-v3/*.py` (not versioned) | | Video flow control (browser) | `static/app.js` | `pump()` — the only place credit is granted. Read-ahead bounded by `BUFFER_AHEAD_S` of film, `STREAM_WINDOW` segments in flight, driven by a clock and by playback, never by arriving data | | Player under test | `tests/harness/mse_harness.mjs` | Runs the real `pump`/`flushQueue`/`evictBehind` against a fake SourceBuffer with a ceiling. Do not write a second model of them | | Asset versioning (hub) | `meshbay_hub.api.webapp` | `_asset_version()` — content hash; whole module graph served under `/a//` so a cache cannot mix two builds | | Stream capacity (node) | `meshbay_node.config` | `[node] max_concurrent_streams` (default 8) — a slot is held for the length of a film, so it counts simultaneous viewers | | Stream diagnosis (node) | `webrtc_server.py` | `client_diag` at DEBUG — the player's own view (`ready`, `quota`, `ranges`, `err`) in the node's log. The only window into a phone | | Stream probe (no browser) | — | `QE/deploy/stream_probe.py` — pulls a real film over real MNP, `--start` to seek. Answers "is it the node or the browser" in one run (not versioned) | | Seeking (browser) | `static/app.js` | `requestSeek` → node restarts ffmpeg with `-ss`; `reinitAt` clears the buffer and sets `timestampOffset`. `-copyts` does *not* preserve position — measured — so the offset comes from the client | | Seeking (node) | `webrtc_server.py` | `start` on `stream_req`; `-ss` **before** `-i` (index seek, not decode-and-discard), clamped away from the end, echoed in `stream_init` | | Resume position | `static/app.js` | `readResumePosition` / `writeResumePosition` — localStorage, per file, per browser. No protocol, and nothing new learns what you watch | | Layout, measured | `tests/harness/layout_probe.py` | Renders `style.css` in Chrome at any width and returns bounding boxes. Use it for layout, not `test_layout_responsive.py`, which only pins CSS values | | Session renewal (browser) | `static/app.js` | `refreshAccessToken` / `ensureFreshToken` — one writer (`setAuth`), one in-flight renewal, rotated refresh token stored. `hubFetch` renews on 401 and replays | | Token lifetimes (hub) | `meshbay_hub.config` | `[jwt] access_token_ttl` 4 h, `refresh_token_ttl` 30 days. **Production sets both in `~/.config/meshbay/hub.toml`** — changing the code default alone does nothing there | ## meshbay.org server (état cible) - OS: Ubuntu 26.04 LTS, Python 3.14.4 - SSH: `ssh cbesson@meshbay.org` - Caddy : reverse proxy HTTPS sur 80/443 - UFW rules: **22/tcp, 80/tcp, 443/tcp uniquement** - Services légitimes : `meshbay-hub.service`, Caddy, PostgreSQL (local) - Inventaire détaillé : `QE/server-state/meshbay.org.md` - Deploy hub : voir `QE/server-state/meshbay.org.md` ## new rules, from now Documents and demo/comments are written in english unless requested in french.