aboutsummaryrefslogtreecommitdiffstats
path: root/CLAUDE.md
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 02:14:39 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 02:14:39 +0200
commit50ebb4f2e620dad8e1fbca8307b97c5e10e7e6c0 (patch)
tree0dd0d2f63ec70a4d459d12aff8f68d2f04881c70 /CLAUDE.md
parentf23e8e51e4b8bc03d6109081fe048af1782e1f4e (diff)
downloadmeshbay-50ebb4f2e620dad8e1fbca8307b97c5e10e7e6c0.tar.gz
docs: settle the desktop client, and draft v6
A design discussion on 2026-08-17 settled Phase 13 and, in doing so, changed four things the spec states. v6 restates only those; v5 stays authoritative for everything it does not touch, per the convention v5 itself used with v4. What changed: * The native shell is **Electron**, not pywebview — structural decision 18 reversed. The SPA depends on Chromium-class APIs (WebRTC, WebCrypto X25519/Ed25519, MSE, Service Workers), so keeping Chromium keeps transport.js, crypto.js, keyderive.js, downloads.js and sw.js *as the client*. A system webview meant reimplementing ~2500-3000 lines. The old "69 % reused" figure was measured against an app.js of ~2600 lines; it is 4586. * A group's content is **several named roots**, not one directory, because the planned video and audio libraries will not live in one folder on one disk. * **Device linking**: one person may hold several devices on a node, admitted by a key the node already pinned and bound by a one-time code the new device generates. Without it a native client is refused where a browser is not, and an account created natively could never be opened in a browser. * **Authorship is authenticated, not asserted** — chat senders sign, uploads have a provable owner, and delete authorization moves from the uploading key to the account. And one rule v5 assumed without writing down: **group-related server state lives on the node.** Verified for multi-root — SwarmSource carries hashes and endpoints, no paths. Also here: the Caddy configuration, which was a snippet in the roadmap that would have broken the SPA (it predates /a/<hash>/ asset versioning and would have 404ed /sw.js, silently killing streamed downloads on Firefox and Safari); and downloads.html, which becomes a security page once a release key exists. Phase 15 was re-read against device linking and is wrong as written: a sender key must be per **device**, never per person, or two devices sharing a chain produce key and nonce reuse — C1 again, one level down. senderkeys.py already fails this silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'CLAUDE.md')
-rw-r--r--CLAUDE.md99
1 files changed, 95 insertions, 4 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 76a7aa6..fcdb28a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3,7 +3,9 @@
## 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).
+See `docs/meshbay-draft-v6.md` for the architecture specification. v6 restates only
+what changed on 2026-08-17; `docs/meshbay-draft-v5.md` remains authoritative for
+everything v6 does not touch (v3/v4 superseded).
## Repository structure
@@ -127,7 +129,13 @@ Scope: `hub`, `node`, `common`, or omitted for cross-cutting
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
+- **NS6** Chat `sender_id` enforced from authenticated session — prevents impersonation ✅ DONE.
+ **Superseded as sufficient (2026-08-17):** that is the node's word. Messages must be
+ **signed with the sender's device key**, and clients pin `account → device keys`.
+ Likewise `_admin_exec_file_delete` authorizes against `entry.uploader_pk` — the exact
+ uploading key — which **device linking breaks**: it must become any non-revoked device
+ of `uploader_id`, resolved through the roster (never through a token claim).
+ See `docs/desktop-client-v1.md` §4.8
- **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
@@ -238,6 +246,83 @@ keypair bundle, or anything that looks like a user's public key.
the group named in the invitation (an operator pairs node-wide while opening a
group)
+## Desktop client — decided, not built (2026-08-17)
+
+See `docs/desktop-client-v1.md`. Nothing here is implemented; it is the design and the
+decision record for Phase 13. Read it before touching the roster, registration, or
+anything that assumes one key per person.
+
+- **Electron**, not pywebview — structural decision 18 is reversed. The SPA depends on
+ Chromium-class APIs (WebRTC, WebCrypto X25519/Ed25519, MSE, Service Workers), so
+ keeping Chromium keeps `transport.js`, `crypto.js`, `keyderive.js`, `downloads.js` and
+ `sw.js` **as the client**. They are no longer on the "delete once native" list. A
+ Python sidecar reusing `quic_client.py` covers hub-less `group://` only
+- **UI assets ship inside the package**, unchanged and non-negotiable. A shell pointing
+ at the hub's `/app/` fixes nothing
+- **Device linking**: `identities.user_id` is a PRIMARY KEY and `pin_identity` does
+ `INSERT OR REPLACE` — one key per person per node, silently overwritten. Both must
+ change. A second device is admitted by the **already-pinned key countersigning**,
+ bound by a one-time code the new device generates; the hub holds no user keys and so
+ cannot produce that signature. Never make the approval a human comparing digits —
+ that is the safety-number ritual 12.1 was abandoned for
+- **C4 is not fully closed by going native.** It closes for a native device
+ unconditionally, and stays open for any account that also uses a browser, which needs
+ a bundle on each node. An account is only as strong as its weakest client
+- **`gek_rotate` may become a signed MNP op** — the C5b rule forbids *key material
+ arriving from outside*, not an operator-signed instruction where the node generates
+ the key itself. The initial `gek-init` stays local: with no GEK there is no session
+- **Installation places files, never secrets.** No key generation in `%post`/`postinst`
+ or an MSI custom action — a golden image would give every machine the same key
+- **A group has several named roots, not one `shared_dir`.** The name is the chosen
+ directory's **basename**, derived once at add time and *stored* — recomputing it from
+ the path re-identifies a whole library the day someone renames a folder. Duplicates
+ refused case-insensitively, no root nested in another, one operator-designated upload
+ target, availability per root, and `kind` + `layout` reserved for the planned
+ video/audio libraries. `config.py:103` is the single string this replaces
+- **The planned video/audio libraries are VIEWS over the file index, not a catalogue.**
+ No metadata store, no server-side database, ever, and nothing reaching the hub — it
+ keeps no file names for private groups (H7). A file stays tied to its representation on
+ the filesystem: folders are the categories, and moving a file makes it a different
+ file. Everything a view needs already exists (whole-group index cached client-side,
+ 10b.5/10b.6). The only non-free piece is a video thumbnail
+- **Enrichment happens on the client; what it cannot compute, the node produces on demand
+ and the asking device caches.** Neither node nor hub keeps durable derived state. This
+ is already the rule for chat thumbnails (draft-v5 §5.2) and it is the answer for video
+ thumbnails too — a frame grab is strictly less than the decoding the node already does
+ for streaming, over the same authorized path
+- **A root that goes away must freeze, not empty.** `indexer.py` runs a watchdog
+ `Observer` and rebuilds on any change; unmounting a USB drive either emits deletions
+ for the whole tree or presents an empty directory to the next rescan. Both propagate as
+ though the owner erased their library. The per-root "unavailable" state ships **before**
+ root selection is offered
+- **exFAT/NTFS and Windows are the common case, not an edge case.** Most users are
+ expected to share from an external exFAT or NTFS drive, on Windows, whatever the build
+ order says. Consequences that are correctness, not portability: filenames need NFC normalization for identity while keeping original bytes
+ for display; Windows reserved names and `MAX_PATH` affect what can be downloaded;
+ `ReadDirectoryChangesW` drops events under load, so periodic reconciliation is
+ mandatory. Never assume POSIX, systemd or case sensitivity. The upload no-overwrite check was *not* affected — `Path.exists()` is already case-insensitive there (checked 2026-08-18); case folding is for comparisons the code makes itself
+- **Shipping the UI in a package creates version skew for the first time.** Today the SPA
+ and the hub deploy together, so a `/v1/` response shape and its caller change in one
+ commit. Once the UI is installed rather than served, `/v1/` is a compatibility surface
+ and `GET /v1/hub/version` needs a minimum client version — cheap now, awkward later
+
+- **A content-addressed index cannot represent the same bytes at two paths.**
+ `GroupIndex` is keyed by blake3, so `clip.mp4` at a root and in `uploads/` with
+ identical content is **one** entry — which is also why a scan can report ten
+ files and index nine. Reconciliation compares *paths*, so it decided the
+ second path was a missed event every 60 s, rewrote the entry, bumped the
+ version and pushed an index update to every connected peer. Found by watching
+ a live node, not by a test. Anything comparing disk against index must check
+ the id, not the path
+
+- **A CLI branch nobody has run is not covered by anything.** `reload` shipped
+ with `subprocess` unimported and crashed on first use; the module compiles
+ fine, which is the same "syntax, not names" trap already recorded for the SPA.
+ `test_cli_dispatch.py` walks every verb with the daemon stubbed, and refuses
+ to let a verb be added to the parser without an entry there. It also stubs
+ `os.kill` — the first version of that test SIGHUPed the developer's own
+ running node
+
## Two lessons that cost four rounds of live testing
- **`QE/deploy/e2e.py` cannot test `app.js`.** It is a second implementation of the
@@ -416,7 +501,12 @@ keypair bundle, or anything that looks like a user's public key.
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
+- Sender keys must be distributed **pairwise to identity keys**, never GEK-derived — and
+ **per device, never per person** (2026-08-17). Two devices sharing one sending chain
+ both advance it, producing key/nonce reuse: that is C1 again, one level down.
+ `GroupSenderKeyStore.add_sender` currently does `self._states[dist.sender_id] = ...`,
+ so a second device under the same `sender_id` silently overwrites the first. Revoking a
+ device must rotate, like revoking a member. See `devel-phases-next.md` §15.0b
- Chat is plaintext on the wire and at rest; the index is plaintext on the WebRTC path
## Known calibration TODOs
@@ -521,7 +611,8 @@ SFR residential Fedora 44 → meshbay.org OVH VPS:
| 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) |
+| Operator operations | `meshbay_node.ops` | **One implementation, several front doors.** The loopback API, the CLI and the signed MNP handlers all call these; they take the daemon `state`, raise `OpError`, and know nothing about HTTP. Two implementations of one operation with two authorization checks is C1/C6 one size down |
+| Node local admin UI | `meshbay_node.ui.app` | Dashboard, peers, groups, audit log (localhost:18000). Each operation endpoint is one `_op(...)` line — logic there is a rule the MNP path does not have |
| 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 |