diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-18 02:14:39 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-18 02:14:39 +0200 |
| commit | 50ebb4f2e620dad8e1fbca8307b97c5e10e7e6c0 (patch) | |
| tree | 0dd0d2f63ec70a4d459d12aff8f68d2f04881c70 /devel-phases-next.md | |
| parent | f23e8e51e4b8bc03d6109081fe048af1782e1f4e (diff) | |
| download | meshbay-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 'devel-phases-next.md')
| -rw-r--r-- | devel-phases-next.md | 406 |
1 files changed, 298 insertions, 108 deletions
diff --git a/devel-phases-next.md b/devel-phases-next.md index db9267d..338beb2 100644 --- a/devel-phases-next.md +++ b/devel-phases-next.md @@ -1,7 +1,9 @@ # MeshBay — Next Implementation Phases > Base: Phases 1–12 complete (except 10.9 → Phase 18). Web SPA + admin panel + self-service UI + MSE video streaming live on meshbay.org. Node daemon is production-ready (WebRTC, WS, chat, HTTP, index push, swarm all wired). -> Architecture reference: **docs/meshbay-draft-v5.md** (v4 superseded 2026-08-13) +> Architecture reference: **docs/meshbay-draft-v6.md** (2026-08-17; v5 remains +> authoritative for everything v6 does not restate, v4 superseded 2026-08-13). +> Desktop client, roots, device linking: **docs/desktop-client-v1.md** > First security review: first-review.md (2026-08-10) > **Second security review: second-review.md (2026-08-13) — 6 critical, 7 high findings.** > @@ -300,27 +302,28 @@ and requests to `/app/` fall through to the hub. ### Caddy integration -Recommended Caddyfile snippet for meshbay.org: -``` -meshbay.org { - root * /path/to/meshbay/site - try_files {path} {path}.html - file_server +**The real configuration lives at `packaging/caddy/meshbay.org.Caddyfile`** (added +2026-08-17). Use it, not the snippet this section used to carry. - handle /v1/* { - reverse_proxy localhost:8000 - } - handle /app* { - reverse_proxy localhost:8000 - } - handle /style.css { - reverse_proxy localhost:8000 - } - handle /*.js { - reverse_proxy localhost:8000 - } -} -``` +The snippet that was here served `site/` from the root with `try_files` and proxied +`/v1/*`, `/app*`, `/style.css` and `/*.js` to the hub. It predates asset versioning and +**would have broken the SPA**: the module graph is served under `/a/<hash>/`, which +`handle /*.js` does not match, and neither does `/locales/*.js`. Worse, `/sw.js` would +have 404ed — the service worker has to stay at the root or its scope stops covering the +pages it intercepts downloads for, which breaks streamed downloads on Firefox and Safari +without any visible error. + +The rule is inverted: an **allowlist** of site paths served statically, everything else +proxied to the hub. The hub mounts its whole static directory at `/` (`app.py`), so it +owns the root namespace by default and the site takes only what it names. + +**Consequence to be aware of:** with the site overlay in front, `/` is the landing page, +so `webapp.py`'s `GET /` (which returns the SPA shell) is unreachable on meshbay.org. That +route stays — a **generic** hub with no site overlay should serve the application at its +root. The overlay is meshbay.org-specific by design. + +`site/` is **not** pushed by the hub deploy procedure; it syncs separately to +`/srv/meshbay/site`. ### Hub mirror (design only — implementation deferred) @@ -785,17 +788,31 @@ metadata minimization, residual schema cleanup. --- -## Phase 13 — Native desktop client (pywebview + aiortc) +## Phase 13 — Native desktop client (Electron + optional Python sidecar) -> **Status (2026-08-13): 13.1 active, 13.2–13.11 DEFERRED to after Phase 15**, pending -> decision D2 in `tmp-decisions.md` (browser extension vs native client vs both). +> **Reworked 2026-08-17 by operator decision. `docs/desktop-client-v1.md` is +> authoritative for this phase** — shell, device linking, account creation, node +> management, packaging and the open items. The milestone table below is the summary; +> the design and its rationale are there. +> +> **The shell changed: pywebview → Electron.** Structural decision 18 is reversed. The +> reason is measured, not aesthetic: the SPA depends on Chromium-class APIs (WebRTC, +> WebCrypto X25519/Ed25519, MSE, Service Workers, File System Access), so keeping +> Chromium keeps `transport.js`, `crypto.js`, `keyderive.js`, `downloads.js` and `sw.js` +> unchanged, while a system webview meant reimplementing ~2500–3000 lines plus a loopback +> media server and native dialogs. The old "69 % reused" figure was measured against an +> `app.js` of ~2600 lines; it is **4586** as of 2026-08-17, and `app.js` now holds 2 direct +> `crypto.subtle` calls — the three-globals seam had already leaked. > -> **13.1 (platform adapter split) proceeds regardless** — it is pure refactoring whose -> acceptance criterion is "the browser SPA behaves identically", and it is the prerequisite -> for every option under D2. +> **One blocking addition: device linking (13.3b).** Identity keys are per node, so a +> native client holding its own keys is refused by `webrtc_server.py:886-894` where a +> browser is not. Without linking, an account created natively could never be opened in a +> browser without an operator code per node — the "native must not prevent web use" +> objective fails outright. See `docs/desktop-client-v1.md` §4. -**Objective:** ship a desktop application with durable key storage, hub-independent -`group://` access, and a better media path than the browser allows. +**Objective:** ship a desktop application whose UI is not served by the hub, with durable +local key storage, at feature parity with the web for a standard user, that does not +prevent web use, and that can manage locally installed nodes. > ⚠️ **Do not justify this phase as "the fix for T3".** An earlier draft of > `second-review.md` claimed a native client makes code integrity independent of the hub. @@ -803,31 +820,37 @@ metadata minimization, residual schema cleanup. > operator holds relocates the trust rather than removing it. What native actually changes is > **detectability** — an attack must ship as an artifact that can be hashed and compared > instead of a one-off HTTP response — and that value is realised only by **18.7 reproducible -> builds** plus published hashes. Native also *costs* the browser sandbox, hands you patch -> velocity for WebKitGTK and every bundled dependency, and adds the loopback media server, -> the IPC bridge and the updater as new attack surface. +> builds** plus published hashes. +> +> **Cost line revised 2026-08-17.** Two of the three costs recorded here were pywebview's, +> not native's. Electron with `sandbox` and `contextIsolation` **keeps** the Chromium +> renderer sandbox — the strongest available — so "native costs the browser sandbox" is +> false for this shell, and the D2 table in `tmp-decisions.md` is wrong on that row. The +> loopback media server is deleted (13.6). What remains, and is real: **we own Chromium's +> patch cadence**, the renderer parses attacker-controlled content from nodes, npm enters +> the supply chain, and the updater is new surface. > > The security-per-effort ranking is: **11.5 ≫ 12 ≫ 14 (CLI) ≫ 13.** This phase is justified -> on product grounds. It permanently closes **C4** as a side effect, but C4 can also be closed -> in a browser by not storing keypair bundles remotely at all. +> on product grounds. It closes **C4** for native devices, but **not for accounts that also +> use a browser** — see `docs/desktop-client-v1.md` §5.1. Reproducible builds are unusually +> tractable here: the UI has no bundler or minifier, and Electron's own binaries have +> published upstream hashes. -### Why this is cheap +### Why this is cheap (re-measured 2026-08-17) -The SPA never touches a browser crypto or network primitive directly: `app.js` contains -**0** occurrences of `crypto.subtle` and **0** of `RTCPeerConnection`. All crypto and -transport go through three injected globals (`window.MeshBayCrypto`, `MeshBayKeys`, -`MeshBayTransport` — 16 call sites) and all hub I/O through one function (`hubFetch`, 30 -call sites). That is the seam. +| Asset | Lines | Under Electron | Under a system webview | +|---|---|---|---| +| `style.css`, `i18n.js`, `vendor/`, `zipstream.js`, `transfers.js` | ~4200 | reuse | reuse | +| `app.js` | 4586 | reuse, minus the hub base URL | reuse, minus storage glue + MSE player | +| `transport.js`, `crypto.js`, `keyderive.js` | 1907 | **reuse** | delete and reimplement | +| `downloads.js`, `sw.js` | 363 | **reuse** | delete | -| Asset | Lines | Native | -|---|---|---| -| `style.css`, `i18n.js`, `vendor/htm-preact.js` | 1708 | **reuse as-is** | -| `app.js` — components, routing, theme, admin | ~2050 | **reuse as-is** | -| `app.js` — storage glue, `hubFetch`, download/upload callbacks, MSE `VideoPlayer` | ~550 | rewrite | -| `transport.js`, `crypto.js`, `keyderive.js` | 1145 | **delete** | +`HUB = ''` (`app.js:12`) becoming a configurable absolute base URL is the one structural +change. That is what 13.1 exists for, and under Electron it is nearly all of it. -≈ **69 % reused unchanged**, and the 31 % that is not is largely code `second-review.md` -says to delete anyway (WebCrypto AES variant, PBKDF2 password split, keypair bundles). +The old plan also budgeted a loopback media server (WebKitGTK MSE is unreliable), a SQLite +index cache (IndexedDB is restricted under `file://`) and native file dialogs. Chromium +provides all three, so those milestones are deleted rather than rewritten. ### Non-negotiable @@ -835,30 +858,48 @@ says to delete anyway (WebCrypto AES variant, PBKDF2 password split, keypair bun `https://meshbay.org/app/` is a browser with a different icon and fixes nothing. The hub is used for the API only, and the bundle is covered by 13.9 signing. +Shell hardening is part of that and is not optional: `contextIsolation` and `sandbox` on, +`nodeIntegration` off, a custom `app://` protocol rather than `file://`, navigation to +non-local targets refused, and a strict in-package CSP that **keeps `wasm-unsafe-eval`** — +the bundle KDF is Argon2id in WebAssembly, and a policy forbidding it locks every user out +of their keys. + ### Milestones -| # | Component | Description | -|---|---|---| -| 13.1 | Platform adapter split | Extract `platform-web.js` (WebRTC/WebCrypto/fetch — today's behaviour) and `platform-native.js` (pywebview bridge). `app.js` imports neither directly. **Acceptance: the browser SPA is byte-for-byte functional after the split** — this lands first, on its own, with no native code | -| 13.2 | pywebview shell + Python bridge | `meshbay-client` package; `window.pywebview.api.*` implements the same surface as the three globals; single-instance, tray, window state | -| 13.3 | Local keystore + Ed25519 client auth — **the real close for C4**, now partially mitigated by Argon2id (draft-v5 §7.1) | Reuse `keystore.py` (Argon2id 256 MB, OS keychain later). Client authenticates like the daemon does: signed timestamp, `POST /v1/users/auth`. **No password on the wire, no `auth_key`/`bundle_key`, no keypair bundle anywhere** → closes **C4** permanently | -| 13.4 | aiortc client transport | `RTCPeerConnection` + `createDataChannel` + `createOffer` in Python; ICE/STUN via `aioice` — the traversal path validated on 2 ISPs. Calls the unified handshake from 11.5.4. QUIC (`quic_client.py`) retained as opt-in for LAN / port-forwarded / hub-less `group://` | -| 13.5 | Local index cache | SQLite in the client profile dir, replacing IndexedDB (also restricted under `file://` in some WebViews) | -| 13.6 | Loopback media server | Python decrypts and serves with HTTP Range; `<video src="http://127.0.0.1:…">`. Drops MSE + the fMP4 remux for native (WebKitGTK MSE is unreliable). **Hardening is mandatory and mirrors C1: bind `127.0.0.1` only, random port, per-file capability token scoped to the session, no CORS, reject non-local `Origin`** | -| 13.7 | Native file dialogs | Replace `showSaveFilePicker`; stream decrypted chunks to disk with constant memory | -| 13.8 | Safety-number UI | Consumes 12.2: display and compare fingerprints, warn on key change | -| 13.9 | Signed releases + verified updates | **Gate for GA.** GPG/minisign release key, client verifies before applying, documented key + revocation procedure. Without this the update channel becomes the new T3 | -| 13.10 | Packaging | AppImage + Flatpak (Linux, primary), MSI (Windows), dmg (macOS) | -| 13.11 | Decision point | Retire the browser SPA, or keep it explicitly labelled reduced-trust (12.6). Deferring is fine; deciding by accident is not | +| # | Component | Prio | Description | +|---|---|---|---| +| 13.1 | Hub base URL + storage/save-file adapter | 1 | `HUB = ''` becomes configurable; a thin adapter for storage and saving. **Acceptance: the browser SPA behaves identically.** Much smaller than the old platform split — Chromium stays Chromium | +| 13.2 | Electron shell | 1 | `app://` via `protocol.handle`, CSP, preload with an enumerated API, sandbox, single instance, tray, window state | +| 13.3 | Local key storage + device auth | 1 | `safeStorage` (OS keychain) with an explicit fallback where no keyring exists; device Ed25519 auth on the `POST /v1/nodes/auth` pattern. **`POST /v1/users/auth` does not exist yet and must be written** | +| **13.3b** | **Device linking** | 1 | **Blocking.** One-time code generated by the new device, countersigned by an already-pinned key. `identities` gains a device dimension; `pin_identity`'s `INSERT OR REPLACE` must go. `docs/desktop-client-v1.md` §4 | +| 13.3c | Hybrid registration | 1 | Register with a passphrase-derived `auth_key` (existing endpoint, browser-compatible at once, and the only account recovery path), device key thereafter | +| ~~13.4~~ | aiortc client transport | — | **Deleted** — Chromium provides WebRTC. `transport.js` is kept as the client | +| ~~13.5~~ | SQLite index cache | — | **Deleted** — IndexedDB works under `app://` | +| ~~13.6~~ | Loopback media server | — | **Deleted** — Chromium MSE works. Removes the C1-shaped surface this milestone would have added | +| 13.7 | Native save dialog | 2 | `dialog.showSaveDialog` + streamed write from the main process; the service-worker path already works, so this is an improvement, not a prerequisite | +| 13.8 | Safety-number UI | 3 | Consumes 12.2. **No longer load-bearing for device linking** — the code binding replaced digit comparison | +| 13.9 | Release key + verified updates | 3 | Prefer the signed apt/dnf repo (18.5) over a bespoke updater. **The key must exist before the first public package**; without it the update channel is the new T3 | +| 13.10 | Packaging | 1 / 3 | `.deb` (Ubuntu 24+) and `.rpm` (Fedora 44+) first; MSI per-user (Windows) later; AppImage/Flatpak optional | +| ~~13.11~~ | "Retire the SPA?" | — | **Settled**: the web stays. A native client must not prevent web use | +| 13.11b | Per-root `unavailable` index state | 2 | Freeze the subtree instead of emptying it when a volume goes away. **Prerequisite for root selection** — otherwise unplugging a USB drive propagates deletions for a whole library | +| 13.11c | Named roots per group | 2 | `shared_dir` (one string) → a list of `{name, path, kind}`; the name is the directory's basename, derived once and stored. Unique names (case-insensitive), no nesting, per-root availability, operator-designated upload target | +| 13.11d | Filesystem portability | 1 | Case folding, NFC normalization, Windows reserved names, `\\?\` paths, reconciliation scans. Case folding is for index identity, collision reporting and root names. **Not** the no-overwrite check: `Path.exists()` is already case-insensitive on NTFS/exFAT, so C5a is not reachable that way (verified 2026-08-18) | +| 13.12 | Node management over signed MNP ops | 2 | Invitations, revoke, unpin, devices, file/dir delete, roster, audit, peers, root selection, **`gek_rotate`**. The loopback admin API is never exposed to the network | +| 13.13 | First-run wizard | 2 | Detect a local node, `systemctl --user enable --now` (no privilege), link to the hub account, create/attach a group, `gek-init`, `operator pair` — all on loopback, no terminal | +| 13.14 | Python sidecar — `group://` over QUIC | 2 | Reuses `quic_client.py`. Also the only local management path on a LAN with no internet, since MNP setup needs the hub's signaling | ### Deletions enabled once native is the recommended client `webcrypto.py` + the `:aes` HKDF variant · `deriveAuthKey`/`deriveEncryptionKey` + `pw_version` 3 + legacy migration · keypair bundle MNP messages + `keypair_bundles` table · -MSE path (`stream_init/data/end`, `_probe_video` remux) · `_bundleKey` in IndexedDB + -`_sessionKeys` in sessionStorage + `_pkFromSk`. +`_bundleKey` in IndexedDB + `_sessionKeys` in sessionStorage + `_pkFromSk`. + +**Revised 2026-08-17:** `transport.js`, `crypto.js`, `keyderive.js`, `downloads.js`, `sw.js` +and the MSE path are **no longer on this list** — under Electron they are the client, not +browser workarounds. The keypair-bundle deletions still apply, but only for accounts that +opt out of browser use (`docs/desktop-client-v1.md` §5.1); the browser path needs them. -**Kept regardless:** WebRTC/aiortc transport, hub signaling relay, DTLS channel binding. +**Kept regardless:** WebRTC transport, hub signaling relay, DTLS channel binding. These carry NAT traversal and are not browser workarounds. --- @@ -890,17 +931,45 @@ a browser.** | 14.1 | `meshbay-node status` | ✅ DONE — hub, node public key, daemon state, groups, admin-key pinning. Reads the keystore directly so it works while the daemon is stopped | | 14.1b | `meshbay-node ui` | ✅ DONE — prints the admin UI URL and the `ssh -L` line. Does not open a browser | | 14.1c | `meshbay-node gek-init` | ✅ DONE — initialises a group key via the daemon's loopback API. Was previously only possible by clicking a button in a browser on the node's own machine | -| 14.2 | `meshbay-node group list` | List configured groups with online status | +| 14.2 | `meshbay-node group list` | ✅ **DONE 2026-08-18** — groups with roots, key state, file and peer counts | | 14.3 | `meshbay-node group create` | Create group on hub, add to config, generate GEK | | 14.4 | `meshbay-node group join` | Join existing group, fetch GEK from local BundleStore, add to config | | 14.5 | `meshbay-node member invite` | ✅ **DONE 2026-08-14** — issues a one-time code; the node wraps the GEK itself when the invitee connects. The original description ("wrap GEK for new member, store bundle") describes the design the invite redesign replaced | | 14.6 | `meshbay-node member revoke` | ✅ **DONE** — stops the node serving the key, and tells the operator to rotate it, since the ex-member still holds the current one | | 14.6b | `meshbay-node member unpin` | ✅ **DONE** — forget a pinned identity so someone can pair again after a key reset | | 14.7 | `meshbay-node member list` | ✅ **DONE** — roster: who is admitted, with what role, pinned when and how. Online status still to add | -| 14.8 | Config reload (SIGHUP) | Daemon reloads config and adds/removes groups without restart | +| 14.8 | Config reload (SIGHUP) | ✅ **DONE 2026-08-18** — `meshbay-node reload`. Deliberately narrow: it re-roots **groups already hosted**, which is what an operator adjusts day to day, and reports a changed group *set* as needing a restart. Adding a group live means new indexers, chat stores, GEK loads and transport contexts, and that is how a half-built group ends up serving content. No connection is dropped | | 14.9 | ~~`meshbay-node admin-key`~~ | ✅ **Superseded by `operator pair`** — pairing binds the operator's browser key with a one-time code instead of pasting a base64 key, and the auto-pin that made M3 possible is deleted | -| 14.10 | `meshbay-node denylist` | Inspect and clear the persisted revocation denylist (11.5.17) | -| 14.11 | `meshbay-node file rm` | The one operator action still requiring a browser | +| 14.10 | `meshbay-node denylist` | ✅ **DONE 2026-08-18** — `denylist show|clear [identifier]`. Clearing asks for confirmation and reports the count, because it re-admits whoever it was keeping out | +| 14.11 | `meshbay-node file rm` | ✅ **DONE 2026-08-18** — `file list|rm <id>`. **No operator action now requires a browser.** Refuses a file whose root is unavailable: it is frozen, not gone | + +### Sequencing and factoring (added 2026-08-17) + +**Phase 14 finishes before 13.12** (node management from the desktop client), and the +remaining commands are written against a single internal module rather than beside one. + +- The CLI is the only interface that works with the daemon stopped, with no GEK, or with + no operator paired — exactly the states the desktop client cannot reach, and the ones + decision E5 sends back to the local machine. Holes here have no fallback. +- 13.12 would add MNP handlers for operations the CLI already performs through the + loopback API. **Two paths to one operation with different authorization is the shape of + C1 and C6.** Factor each operation into `meshbay_node/ops.py`, with the CLI, the + loopback API and the MNP handler as three thin adapters. Parity becomes structural, + authorization lives in one place, and 13.12 is adapter code. + +The refactor is cheaper now, with six commands left, than after 13.12 exists. +See `docs/desktop-client-v1.md` §6.6. + +**Done 2026-08-18.** `meshbay_node/ops.py` holds each operation once; the loopback API is +a one-line adapter per endpoint (`_op()` translates `OpError` into a JSON response) and the +MNP handlers call the same functions through `_run_op`. `test_ops.py` asserts the shape +rather than trusting it: every operation takes `state` first, `ops` imports nothing +web-shaped, and no loopback handler performs an operation itself. + +**Signed MNP ops shipped with it:** `gek_rotate` and `member_unpin`, both operator-signed +over a structured transcript like every other destructive operation. Rotation is the half +of revocation that revocation cannot do — the ex-member holds the current key — and the +node generates the replacement with its own CSPRNG, so no key material crosses the wire. ### Architecture @@ -944,6 +1013,43 @@ Distribution must be **pairwise to identity keys**: wrap each sender key with EC recipient's `pk_x25519` (the existing `wrap_gek_aes` primitive), or run the existing `ratchet.py` Double Ratchet per member pair. Decide and record before writing 15.1. +### 15.0b — A sender key is per DEVICE, never per person (added 2026-08-17) + +**This phase predates device linking (`docs/desktop-client-v1.md` §4) and is wrong as +written.** One person now holds several identity keys on one node — a browser and a +desktop client, up to the device cap. Two consequences, and the first is the whole +decision: + +**A shared per-person chain reintroduces C1, one level down.** If Alice's two devices share +one sending chain, both advance it, and concurrent sends produce **key and nonce reuse** — +which is precisely why `first-review.md` C1 rejected a shared Double Ratchet for groups. +Per-device chains have no shared mutable state and no reuse. There is no third option worth +weighing. + +**The code already fails this, silently.** `senderkeys.py` keys everything by +`sender_id: str`, and `GroupSenderKeyStore.add_sender` does +`self._states[dist.sender_id] = ...` — so a second device registering under the same +`sender_id` **overwrites the first, dropping its chain**. Same shape as `pin_identity`'s +`INSERT OR REPLACE`, same fix: `sender_id` becomes a **device** identifier (account plus +device key fingerprint), not a `user_id`. The module needs its identifier redefined, not +restructuring — and the class docstring, which says "one chain per member", needs to say +per device. + +**What follows from per-device chains:** + +- **Fan-out is O(devices), not O(members)** — bounded by the per-user device cap (5 by + default), so up to 5× the distribution messages. Acceptable, but size the distribution + path for it rather than discovering it. +- **A new device cannot read history until every sender redistributes.** Nobody but the + senders holds their chain keys — that is the point — so a freshly linked device sees an + unreadable backlog until each sender is next online. Either accept and surface it + ("history before this device was added is unavailable"), or have the **linking device + hand over its own accumulated state as a blob sealed to the new device's key**, relayed + by the node, which cannot read it. Decide in 15.0. +- **Revoking a device must rotate**, exactly like revoking a member: a lost laptop holds + every sender key it ever received. 15.4 only knows about members today and must cover + `device revoke` and `member unpin`. + ### Honest threat delta (state this in the docs, not just here) Sender Keys protects chat against **someone who holds the node's disk but is not a group @@ -952,57 +1058,126 @@ chat from the node operator, because on this platform the operator is a group me therefore a legitimate sender-key recipient. Claiming more than that would repeat the overstatement pattern `second-review.md` §7 flags. +Three additions once devices exist, all of which belong in the user-facing docs: + +- **It does not protect against anyone holding any one device of any member.** With + several devices per person, that surface is larger than it was. +- **C4's blast radius reaches chat history.** A browser recovers its identity key from the + keypair bundle on the node; cracking that bundle yields every sender key ever wrapped to + it, because the distribution channel has no forward secrecy. Not a regression — chat is + plaintext at rest today — but it means Sender Keys is worth measurably less to a + browser-using account than to a native one, which is the same asymmetry as everywhere + else in `docs/desktop-client-v1.md` §5.1. +- **Sender authentication is now a requirement, not an accepted limitation** + (operator decision, 2026-08-17). A sender key proves *a device*; it does not prove which + account that device belongs to, and NS6's enforcement of `sender_id` from the session is + the node's word. The design is in `docs/desktop-client-v1.md` §4.8: **sign every message + with the sender's device key** (independent of encryption, so it can land before this + phase), **pin `account → device keys` client-side** using the device-add + countersignatures as evidence, and optionally have the **operator sign a roster + attestation** to close first contact. What survives: an operator who turns malicious + *later* cannot forge an account a member has already seen — forgery is limited to + accounts the victim has never encountered. + ### Milestones | # | Component | Description | |---|---|---| | 15.0 | **Distribution decision** | Pairwise-to-identity-key, never GEK-derived. Blocking | -| 15.1 | Node: sender key init | Generate sender key on group join, distribute to members | -| 15.2 | Node: encrypt chat on send | Encrypt payload with sender's chain key before broadcast | +| 15.0b | **Per-device chains** | `sender_id` becomes a device identifier; fix `GroupSenderKeyStore`'s silent overwrite; decide the history-handover question. **Blocking, and depends on device linking (Stage C) landing first** | +| 15.1 | Node: sender key init | Generate a sender key **per device** on group join, distribute to **every device of every member** | +| 15.2 | Node: encrypt chat on send | Encrypt payload with that device's chain key before broadcast | | 15.3 | Node: decrypt chat on receive | Decrypt incoming chat messages, handle out-of-order | -| 15.4 | Key rotation on member removal | Admin removes member → all remaining members rotate keys | +| 15.4 | Key rotation on removal | Member removed **or device revoked or unpinned** → all remaining devices rotate | | 15.5 | Chat retention config | Per-group `max_age_days` setting, periodic cleanup in ChatStore | -| 15.6 | MNP version negotiation | Handshake declares supported version range, not just a single `v` field (L2 — today `v` is sent by everyone and checked by no one) | -| 15.7 | Chat attachments | Attachments are ordinary files on the node and remain plaintext at rest. Either encrypt them under the sender key, or document the asymmetry explicitly | +| 15.6 | MNP version negotiation | Handshake declares a supported version range, not a single `v` field (L2 — today `v` is sent by everyone and checked by no one). **Move this earlier**: it pairs with the minimum-client-version check, and version skew begins the day the desktop client ships (`docs/desktop-client-v1.md` §2.6), not when chat is encrypted | +| 15.7 | Chat attachments | Attachments are ordinary files on the node and remain plaintext at rest. Either encrypt them under the sender key, or document the asymmetry explicitly. Note they now land in the **operator-designated upload root** (§6.7 of the desktop-client doc) | --- ## Phase 16 — Android client MVP > Was Phase 14 before the 2026-08-13 renumbering. -> **Shares the Phase 13 design:** local keystore, Ed25519 client auth, no keypair bundles, -> aiortc-equivalent WebRTC for traversal (Android has a native WebRTC stack — prefer it over -> `punch_nat`, for the same reason the desktop client does). Do not re-derive a second -> crypto or auth model here. +> +> **Rewritten 2026-08-17.** The previous text described an architecture that no longer +> exists and, in two places, one that was deliberately dismantled. Corrections are listed +> below rather than silently applied, because the same mistakes are easy to make twice. +> +> **Shares the desktop design** (`docs/desktop-client-v1.md`): keys generated and kept +> locally, device Ed25519 authentication, no keypair bundles, and **an Android client is +> simply another device** under device linking. Do not re-derive a second crypto, auth or +> admission model here. -**Objective:** Android app for account creation, group browsing, file download, -chat. No node functionality on mobile (client-only). +**Objective:** Android app for account creation, group browsing, file download, streaming +and chat. **Client only — no node functionality on mobile**, and that is structural: an +app cannot freely read the phone's folders (the user grants access to one tree at a time, +revocably), background processes are killed, and a long-lived listening socket is not +guaranteed. All three are things a node must have. -**Stack:** Kotlin native + Jetpack Compose. QUIC via `quiche` (Cloudflare, Rust -JNI binding). Crypto via Bouncy Castle JVM. Same NAT traversal as desktop native -clients (`punch_nat` + QUIC). +### What the previous text got wrong -| # | Component | Tech | Priority | -|---|---|---|---| -| 14.1 | Hub client (auth, groups, GEK) | Kotlin + Retrofit | High | -| 14.2 | Crypto (Ed25519, X25519, ChaCha20) | Bouncy Castle JVM | High | -| 14.3 | QUIC client | quiche (Rust JNI) | High | -| 14.4 | NAT traversal (STUN + punch) | Kotlin native UDP | High | -| 14.5 | File browser + download | Kotlin + streaming IO | High | -| 14.6 | Chat UI | Jetpack Compose | Medium | -| 14.7 | Contact list integration | Android Contacts API (permission-gated) | Medium | -| 14.8 | Account creation from app | Registration flow + keypair bundle | High | +| It said | Reality | +|---|---| +| "The `keypair_bundle` (encrypted, stored on hub) enables cross-device" | **The hub has stored no keypair bundle since 2026-08-12**, and since 2026-08-14 identity keys are **per node** — there is no single identity to carry between platforms. Cross-device is **device linking**, not a shared bundle | +| "Notification state and read markers sync via hub (small encrypted blob per user)" | Violates the rule that **group-related server state lives on the node** (draft-v6 §2.5). Even encrypted, a per-user blob the hub stores gives it update timing and frequency — who reads which group, when. Node-side or not synced | +| "Hub client (auth, groups, **GEK**)" | The hub does not serve GEKs. `GET /gek` and the `gek_bundles` table were removed in the T3 work; the node wraps the key on every connection | +| "NAT traversal (`punch_nat` + QUIC)" | `punch_nat()` is **not** a traversal stack — one UDP probe, no STUN, no candidate gathering, one ISP validated (structural decision 17). **ICE/STUN is the traversal path**, and Android has a native WebRTC stack | +| "MNP extended with an `upload` message type" | Already shipped — `FILE_UPLOAD`, Phase 10b.4 | +| "Account creation … + keypair bundle" | Hybrid registration (draft-v6 §1 item 7): passphrase-derived `auth_key`, then a device Ed25519 key. No bundle anywhere | +| Milestones numbered 14.x inside Phase 16 | Leftover from the renumbering; they are 16.x below | -**Cross-device compatibility:** the user may switch between web and Android. -The `keypair_bundle` (encrypted, stored on hub) enables this — same credentials, -same keys on both platforms. Notification state and read markers should sync -via hub (small encrypted blob per user, minimal storage). +**Stack:** Kotlin + Jetpack Compose. **WebRTC via Android's native stack** — the traversal +path, same as every other client. Crypto via Bouncy Castle JVM. -**Upload from mobile:** posting photos/videos to a group. The mobile uploads to -the group's node(s), not to the hub. The node stores it. MNP protocol extended -with an `upload` message type for client→node push. +**QUIC is deferred.** It exists for LAN and hub-less `group://`, which is marginal on a +phone, and it would drag a Rust JNI dependency (`quiche`) into an MVP. Add it if a real +use case appears. -**Out of scope:** node functionality on mobile, Mac/iPhone support. +### Milestones + +| # | Component | Priority | +|---|---|---| +| 16.1 | Hub client — auth, groups, notifications (Retrofit) | High | +| 16.2 | Crypto — Ed25519, X25519, ChaCha20, and **`auth_key` derivation byte-identical to `keyderive.js`/`keyderive.py`** | High | +| 16.3 | WebRTC DataChannel transport + the unified handshake (11.5.4) | High | +| 16.4 | **Device linking** — the app generates its own keys and is approved by an already-paired device (§4 of the desktop-client doc) | High | +| 16.5 | Hybrid registration from the app | High | +| 16.6 | File browser + download, **root-aware paths**, per-root "unavailable" state | High | +| 16.7 | Upload from mobile via the existing `FILE_UPLOAD` handler; photo picker, no broad storage permission | Medium | +| 16.8 | Chat UI | Medium | +| 16.9 | Video streaming (native player, MSE not required) | Medium | +| 16.10 | Contact list integration (permission-gated) | Low | + +### Consequences carried from the other phases + +- **Device linking is a prerequisite** (Stage C), exactly as for the desktop client. Without + it, installing the app on a phone would need an operator code per node. +- **A fourth consumer of the KDF parity test.** `auth_key` is PBKDF2-SHA512 600 000 in + `keyderive.js`, `keyderive.py`, the QE harness and now Kotlin. The standing warning + applies and matters more each time: **never change those parameters in one place** — a + mismatch does not look like an error, it looks like an account nobody can open. +- **Sender Keys**: a phone is a device, so it gets its own chain (§15.0b). The "no history + until every sender redistributes" property is **most visible here** — people install an + app and expect their backlog — which argues for the sealed state handover rather than + the accept-and-explain option. +- **Version skew is worse than on desktop.** An installed client meets a newer hub + (`docs/desktop-client-v1.md` §2.6), and store review latency means a fix cannot be + pushed quickly. The minimum-client-version check is not optional here. +- **Multi-root** falls out for free if the app is built after Stage A; it must not assume a + group is one directory. + +### Open, and worth deciding before 16.8 + +**Chat delivery on a phone has no answer today.** Android will not let an app hold a +WebRTC DataChannel open in the background, so a message arriving while the app is closed +reaches nobody. The obvious mechanism is a push service, and the obvious push service is +FCM — which would mean **Google learning the timing of your group activity, and the hub +sending it**, against the whole metadata posture (H7, draft-v6 §2.5). Alternatives +(a self-hosted UnifiedPush distributor, a foreground service the user opts into, polling +on open) each cost something different. **Decide it explicitly; do not let FCM arrive as +an implementation detail.** + +**Out of scope:** node functionality on mobile, Mac and iPhone support. --- @@ -1073,11 +1248,11 @@ community developers. Core functionality must be complete and stable first. ``` Phase 11.5 (Security remediation) ⛔ BLOCKING — nothing else starts -Phase 13.1 (Platform adapter split) ← free refactor, unblocks every D2 option +Phase 13.1 (Hub base URL + adapters)← free refactor, prerequisite for the desktop client Phase 12 (Key verification) ← H3 safety numbers + served-SPA integrity Phase 14 (Node CLI) ← best security-per-effort answer to T3 Phase 15 (Sender Keys) ← chat encryption; 15.0 decision first -Phase 13.2–13.11 (Desktop client) ← DECIDED: offered alongside the browser SPA +Phase 13.2+ (Desktop client) ← Electron; offered alongside the browser SPA Phase 16 (Android) ← reuses the Phase 13 design Phase 17 (Resilience) ← optional, edge cases only Phase 18 (Packaging + CI) ← distro repos; 18.7 gates 13's security argument @@ -1099,9 +1274,14 @@ content over an unauthenticated HTTP port (C1), lets any user hijack a node's si identity (C2), and lets any member seize the group key (C5b). No feature work lands on top of that. -**One task can run in parallel:** 13.1 (platform adapter split) is pure refactoring with the -acceptance criterion "the browser SPA is unchanged in behaviour". It de-risks Phase 13 and -touches none of the security surface. +**One task can run in parallel:** 13.1 (hub base URL + storage/save-file adapter) is pure +refactoring with the acceptance criterion "the browser SPA is unchanged in behaviour". It +de-risks Phase 13 and touches none of the security surface. + +**One task must not be deferred inside Phase 13:** 13.3b (device linking). It is a protocol +and schema change, it gates the "native must not prevent web use" objective, and the +roster's `pin_identity` currently does `INSERT OR REPLACE` on a `user_id` primary key — a +silent overwrite that becomes a hole the moment more than one key per person is legitimate. **Renumbering map (2026-08-13):** @@ -1142,11 +1322,21 @@ touches none of the security surface. 17. **`punch_nat()` is a direct-connection helper, not a NAT traversal stack** — no STUN, no candidate gathering, no dual-stack fallback, validated on one ISP. ICE/STUN (validated on two ISPs, two browsers, IPv4 + IPv6 + 4G CGNAT) is the traversal path. ✅ (2026-08-13) -18. **Native desktop shell: pywebview**, UI assets shipped inside the package and loaded from - disk — never fetched from the hub, or T3 is not fixed. ✅ (2026-08-13) +18. ~~**Native desktop shell: pywebview**~~ → **Electron**, with an optional Python sidecar + for hub-less `group://` over QUIC. **Reversed 2026-08-17** — the SPA depends on + Chromium-class APIs, so a system webview meant reimplementing ~2500–3000 lines and + losing the renderer sandbox. What is unchanged and non-negotiable: **UI assets ship + inside the package and load from disk**, never fetched from the hub, or T3 is not + fixed. See `docs/desktop-client-v1.md` §2. +18b. **A second device is admitted by device linking, not by an operator code.** The + already-pinned key countersigns; the binding is a one-time code the new device + generates and displays, never a human comparing digits. The hub cannot produce that + countersignature. ✅ (2026-08-17) 19. **Private keys never leave the device on native clients.** Keypair bundles are retired rather than relocated; Phase 12's move of bundles from hub to node was the wrong - destination (C4). ✅ (2026-08-13) + destination (C4). ✅ (2026-08-13). **Qualified 2026-08-17:** this holds for native + devices. A browser has no durable storage of its own and still needs a bundle on each + node, so C4 closes for an account only when it opts out of browser use. 20. **Sender keys are distributed pairwise to identity keys, never derived from or wrapped under the GEK.** ✅ (2026-08-13) 21. **Hub minimization is enforced by an acceptance test (12.1), not by policy.** The hub |