summaryrefslogtreecommitdiffstats
path: root/devel-phases-next.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 /devel-phases-next.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 'devel-phases-next.md')
-rw-r--r--devel-phases-next.md406
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