summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-03 14:09:18 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-03 15:34:33 +0200
commit15087b0e8fdb872602310119f14680aaa443fd93 (patch)
treee04e37005b75593d8f23f7e0ef571f4947202027
parent4d7581b23b560a2a6803cae2df91f90368c01b21 (diff)
downloadmeshbay-15087b0e8fdb872602310119f14680aaa443fd93.tar.gz
docs: Windows port audit and sender key distribution decision
Add docs/WINDOWS-PORT.md with the full portability audit (what is already portable, what blocks, implementation plan W1-W7). Reverse structural decision 20: sender keys are distributed GEK-wrapped, not pairwise to identity keys. The GEK is the group secret; files and chat share the same access boundary. Per-device chains (15.0b) remain required for correctness. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--CLAUDE.md10
-rw-r--r--docs/WINDOWS-PORT.md480
-rw-r--r--docs/devel-phases-next.md88
3 files changed, 542 insertions, 36 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 0197d1e..0292a82 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -675,9 +675,13 @@ anything that assumes one key per person.
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 — 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.
+- ~~Sender keys must be distributed pairwise to identity keys, never GEK-derived.~~
+ **Reversed 2026-09-03:** sender keys are distributed **GEK-wrapped**. The GEK is the
+ group secret; files and chat share the same access boundary. Pairwise distribution
+ added complexity for a separation (files vs chat) that has no meaning in this
+ platform's group model. Sender keys remain **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 `docs/devel-phases-next.md` §15.0b
diff --git a/docs/WINDOWS-PORT.md b/docs/WINDOWS-PORT.md
new file mode 100644
index 0000000..9b3caee
--- /dev/null
+++ b/docs/WINDOWS-PORT.md
@@ -0,0 +1,480 @@
+# MeshBay — Windows Port
+
+> Status: **audit complete, work not started.**
+> Created 2026-09-03 from a full codebase scan.
+> This document is both the audit results and the implementation plan.
+> It does not repeat the design decisions already in `desktop-client-v1.md`
+> (§6.8, §7.5, decisions E8/E12) — read that first.
+
+---
+
+## 1. Current state — what is already portable
+
+The bulk of the codebase is ready. The architecture documents said "exFAT/NTFS
+and Windows are the common case" (decision E12) and the code reflects it where
+it matters most:
+
+| Area | Status | Notes |
+|---|---|---|
+| `meshbay_common/paths.py` | **Ready** | `fold()`, `nfc()`, `long_path()` with `\\?\` prefix, `WINDOWS_RESERVED`, `sanitize_for_download()`, `find_fold_collisions()` |
+| `meshbay_common/` (all other modules) | **Ready** | Crypto, protocol, keyderive — no OS dependency |
+| `roots.py` | **Ready** | Uses `fold()`, `portable_name_problem()`, `as_posix()` for virtual paths, Windows drive examples in docstrings |
+| `indexer/indexer.py` | **Ready** | `as_posix()` for index paths, `long_path()` for file I/O, reconciliation loop for dropped `ReadDirectoryChangesW` events |
+| Transport (WebRTC, QUIC, MNP) | **Ready** | asyncio + aiortc, no platform dependency |
+| Roster, bundle store, audit, chat store | **Ready** | SQLite + pathlib throughout |
+| `test_paths.py` | **Ready** | Case folding, NFC, reserved names, reserved characters, trailing dot/space — already covers Windows filesystem rules |
+| Electron `preload.js` | **Ready** | Pure IPC bridge, no platform code |
+| Electron `safeStorage` | **Ready** | `secretsBackend()` (`src/main.js:229-232`) already handles `win32 → 'dpapi'` |
+| Electron `app://` protocol | **Ready** | Cross-platform Electron API |
+| Electron `scripts/sync-ui.js` | **Ready** | Uses `path.join`/`path.resolve` |
+| Hub (`meshbay-hub`) | **N/A** | Server-only, stays Linux |
+
+---
+
+## 2. Blockers — will crash or fail on Windows
+
+### 2.1 Hardcoded XDG paths (~20 locations)
+
+The central problem. Every default path assumes `~/.config/meshbay` and
+`~/.local/share/meshbay`. On Windows these must be `%LOCALAPPDATA%\meshbay`
+(or `%APPDATA%\meshbay` for roaming config).
+
+**Python — meshbay-node:**
+
+| File | Line | Path |
+|---|---|---|
+| `config.py` | 28 | `DEFAULT_CONFIG_PATH = Path.home() / ".config" / "meshbay" / "node.toml"` |
+| `config.py` | 237 | `data_dir` default → `~/.local/share/meshbay` |
+| `keystore.py` | 60 | `DEFAULT_KEYSTORE_PATH` → `~/.config/meshbay/keystore.enc` |
+| `keystore.py` | 61 | `DEFAULT_UNLOCK_FILE` → `~/.config/meshbay/unlock.key` |
+| `hub_client.py` | 64 | `cache_dir` → `~/.config/meshbay` |
+| `tls_cert.py` | 26-27 | `DEFAULT_CERT` / `DEFAULT_KEY` → `~/.config/meshbay/` |
+| `quic_server.py` | 610-611 | Fallback cert/key paths → `~/.config/meshbay/` |
+| `daemon.py` | 1760-1762 | `reset` command hardcodes `~/.config`, `~/.local/share`, `~/.local/state` |
+
+**JavaScript — meshbay-client:**
+
+| File | Line | Path |
+|---|---|---|
+| `src/main.js` | 700 | `nodeConfigPath()` → `os.homedir() + '/.config/meshbay/node.toml'` |
+| `src/main.js` | 706 | `readNodeConfig()` → `os.homedir() + '/.local/share/meshbay'` |
+| `src/main.js` | 753 | `findNodeBinary()` → `os.homedir() + '/.local/bin/meshbay-node'` |
+| `src/main.js` | 836 | data dir fallback → `os.homedir() + '/.local/share/meshbay'` |
+| `src/main.js` | 855-856 | `provisionNode()` → hardcoded config and data dirs |
+
+**Fix:** one `platform_dirs()` function in Python (`meshbay_common` or
+`meshbay_node`) and one in JS, each returning `{config_dir, data_dir,
+state_dir, bin_dir}` per platform. Called from a single place; every default
+path derived from it. See §5.1.
+
+### 2.2 Signal handling in the daemon
+
+`daemon.py:679-680` — `loop.add_signal_handler(SIGINT, SIGTERM)` raises
+`NotImplementedError` on Windows' `ProactorEventLoop`. SIGHUP (`daemon.py:683-687`)
+is already guarded with `try/except`; SIGINT/SIGTERM is not.
+
+**Fix:** on Windows, use `signal.signal(signal.SIGINT, handler)` — works with
+`ProactorEventLoop`. Or use `SetConsoleCtrlHandler` via ctypes for
+`CTRL_C_EVENT` and `CTRL_CLOSE_EVENT`. See §5.2.
+
+### 2.3 systemd-hardwired daemon lifecycle
+
+The CLI and the Electron client assume systemd for start/stop/reload/restart:
+
+**Python CLI (`daemon.py`):**
+
+| Line | What |
+|---|---|
+| 1590-1605 | `_systemctl_user()` → `subprocess.run(["systemctl", "--user", ...])` |
+| 1806 | `reset` → `systemctl --user disable --now` |
+| 2058 | `reload` → `_systemctl_user("reload", ...)` |
+| 2070 | `restart-daemon` → `_systemctl_user("restart", ...)` |
+| 1751 | `init` prints `systemctl --user enable --now` instructions |
+
+**Electron (`src/main.js`):**
+
+| Line | IPC handler |
+|---|---|
+| 763 | `node:installed` → `systemctl --user show` (returns `{ installed: false }` on non-Linux) |
+| 781 | `node:service-status` → `systemctl --user show` (returns `{ supported: false }`) |
+| 806-810 | `node:service-stop` → throws on non-Linux |
+| 820-824 | `node:service-restart` → throws on non-Linux |
+| 898-954 | `node:start` → throws on non-Linux, then various systemctl calls |
+
+The Electron guards are correct (no crash on Windows), but the entire Node
+management panel is inoperative. **This is the largest piece of new code in
+the port.** See §5.3.
+
+### 2.4 `which` command in Electron
+
+`src/main.js:756` — `execFile('which', ['meshbay-node'])` to locate the
+binary. `which` does not exist on Windows.
+
+**Fix:** use `where.exe` on Windows, or use a JS-native lookup
+(`child_process.execFileSync` with `process.env.PATH` split on `;`). See §5.1.
+
+### 2.5 Electron packaging — no Windows target
+
+`package.json:11` — `"dist": "npm run sync-ui && electron-builder --linux deb rpm"`.
+The `build` section has only `linux`, `deb`, and `rpm` keys. No `build.win`,
+no NSIS/MSI configuration.
+
+`packaging/` contains only Linux artifacts: `deb/`, `rpm/`, `systemd/`,
+`firewall/`, `caddy/`, `desktop/`.
+
+**Fix:** add `build.win` to `package.json` with NSIS per-user target, and a
+`packaging/win/` directory. See §5.4.
+
+---
+
+## 3. Needs work — no crash, but wrong or incomplete behavior
+
+### 3.1 File permissions (`chmod 0o600`)
+
+`os.chmod(path, 0o600)` and `path.chmod(0o600)` are no-ops on NTFS — the
+mode bits are ignored. The files are left with default Windows permissions
+(readable by the user and administrators), which is acceptable for per-user
+data but not explicit.
+
+| File | Line | What |
+|---|---|---|
+| `keystore.py` | 236 | keystore file |
+| `keystore.py` | 97-98 | **Reads** `st_mode` and warns if not `"600"` — will always warn on Windows |
+| `roster.py` | 918 | roster database |
+| `tls_cert.py` | 67-68 | TLS cert and key |
+| `daemon.py` | 221 | UI token file |
+| `daemon.py` | 1726, 1734 | node.toml and unlock.key during `init` |
+| `src/main.js` | 193, 226, 866, 882, 888 | config files |
+
+**Fix:** guard `chmod` calls with `sys.platform != "win32"`. For the
+`keystore.py:97` permission check, skip the mode verification on Windows.
+Windows ACLs (via `icacls` or `pywin32`) are an option for defense in depth
+but not required — the files are under `%LOCALAPPDATA%`, which is per-user
+by default. See §5.5.
+
+### 3.2 ffmpeg/ffprobe discovery
+
+All calls use bare `"ffmpeg"` / `"ffprobe"` strings:
+
+| File | Line |
+|---|---|
+| `media_probe.py` | 51 |
+| `indexer/enrich.py` | 165 |
+| `webrtc_server.py` | 3468, 4387, 4607 |
+| `quic_server.py` | 559 |
+
+Python's `subprocess` resolves `.exe` transparently on Windows, so these work
+**if ffmpeg is in `%PATH%`**. But there is no startup check and no clear error
+message if it is missing.
+
+**Fix:** add a `node.toml` config key `[node] ffmpeg_path` with a default of
+`"ffmpeg"`, checked at startup with `shutil.which()`. Report a clear error
+at daemon startup if not found. See §5.6.
+
+### 3.3 CLI messages assume Linux
+
+`daemon.py:1751` — `init` unconditionally prints `systemctl --user enable
+--now meshbay-node`. The example config (`config.py:91-109`) uses Unix paths
+(`/home/user/Media`, `/run/media/user/USB/`).
+
+**Fix:** platform-conditional messages and example paths. Minor.
+
+---
+
+## 4. Not a problem — confirmed portable
+
+These were checked and need no work:
+
+- **watchdog `Observer`** — auto-selects `ReadDirectoryChangesW` on Windows.
+ The reconciliation scan (`_reconcile_loop`, `indexer.py:513`) already
+ compensates for dropped events.
+- **`asyncio.create_subprocess_exec`** — works on Windows with
+ `ProactorEventLoop` (default since Python 3.10). `proc.kill()` sends
+ `TerminateProcess` rather than SIGKILL — functionally correct.
+- **`as_posix()` for index paths** — the MNP protocol uses forward slashes;
+ the indexer already normalizes (`indexer.py:128`).
+- **SQLite** — fully cross-platform.
+- **aiortc** — pure Python, no OS dependency.
+- **No Unix-only imports** — no `fcntl`, `grp`, `pwd`, `resource` anywhere
+ in the codebase.
+- **`proc.kill()` semantics** — different (no signal) but the pipe-draining
+ and timeout patterns already in place handle it.
+
+---
+
+## 5. Implementation plan
+
+### Principles
+
+1. **Write under Linux, validate under Windows.** The code changes are
+ testable on Linux with `sys.platform` mocking. The Windows VM is for
+ integration testing, packaging and visual validation.
+2. **One function, one place.** Platform-specific logic is concentrated in
+ helper functions, never scattered across call sites.
+3. **No new files if a function in an existing module suffices.**
+4. **Tests must run on both platforms.** Anything that touches signals or
+ paths must have a `@pytest.mark.skipif` for the platform it cannot run
+ on, not a crash.
+
+### 5.1 Platform directories (W1)
+
+**Scope:** `meshbay_node` + `meshbay-client`
+
+Create `meshbay_node/platform.py`:
+
+```python
+def config_dir() -> Path:
+ if sys.platform == "win32":
+ return Path(os.environ.get("LOCALAPPDATA", Path.home())) / "meshbay"
+ return Path.home() / ".config" / "meshbay"
+
+def data_dir() -> Path: ...
+def state_dir() -> Path: ...
+```
+
+Replace every hardcoded XDG path in `config.py`, `keystore.py`,
+`hub_client.py`, `tls_cert.py`, `quic_server.py`, `daemon.py` with calls
+to these functions.
+
+In `src/main.js`, equivalent `meshbayConfigDir()` / `meshbayDataDir()` using
+`process.env.LOCALAPPDATA` on win32, `os.homedir() + '/.config/meshbay'`
+elsewhere. Replace the 8 hardcoded locations.
+
+For `findNodeBinary()`: use `where.exe meshbay-node` on Windows,
+`which meshbay-node` on others.
+
+**Estimated scope:** ~20 call sites, mechanical replacement.
+
+### 5.2 Signal handling (W2)
+
+**Scope:** `daemon.py` only
+
+```python
+if sys.platform == "win32":
+ signal.signal(signal.SIGINT, lambda *_: stop_event.set())
+ signal.signal(signal.SIGTERM, lambda *_: stop_event.set())
+else:
+ for sig in (signal.SIGINT, signal.SIGTERM):
+ loop.add_signal_handler(sig, stop_event.set)
+```
+
+SIGHUP is already guarded. No other signal handling in the codebase.
+
+**Estimated scope:** ~10 lines changed.
+
+### 5.3 Daemon lifecycle on Windows (W3)
+
+**Scope:** `daemon.py` CLI + `src/main.js` IPC handlers
+
+This is the largest work item. Two modes, matching `desktop-client-v1.md`
+§7.5:
+
+#### Per-user mode (default)
+
+The daemon runs as a regular process. Autostart via:
+- **Startup folder shortcut**, or
+- **Task Scheduler** logon task (preferred — survives "disable startup
+ apps" and has retry semantics).
+
+The Electron client manages this through Task Scheduler COM or `schtasks.exe`:
+- `node:start` → `schtasks /create /tn MeshBayNode /tr "meshbay-node run" /sc ONLOGON /rl LIMITED`
+- `node:service-stop` → `schtasks /end /tn MeshBayNode` + kill the process
+- `node:service-status` → `schtasks /query /tn MeshBayNode` + check if the process is running
+- `node:installed` → check if `meshbay-node.exe` exists in known locations
+
+The Python CLI equivalents:
+- `reload` on Windows → find the running daemon process and send a custom
+ event (named pipe or a reload-flag file the daemon watches), or just
+ restart
+- `restart-daemon` → kill + start
+- `reset` → remove scheduled task + delete data dirs
+
+#### Service mode (optional, elevated)
+
+A Windows Service under a dedicated low-privilege account. Uses
+`pywin32`'s `win32serviceutil` or a wrapper like NSSM. This is **Phase 2**
+of the Windows port — per-user mode ships first.
+
+**Estimated scope:** ~200 lines Python + ~150 lines JS for per-user mode.
+Service mode is a separate milestone.
+
+### 5.4 Packaging (W4)
+
+**Scope:** `package.json` + `packaging/win/`
+
+Add to `package.json`:
+
+```json
+"win": {
+ "target": "nsis",
+ "icon": "build/icons/icon.ico"
+},
+"nsis": {
+ "oneClick": false,
+ "perMachine": false,
+ "allowToChangeInstallationDirectory": true
+}
+```
+
+Add a `packaging/win/` directory with:
+- Icon in `.ico` format
+- Optional: Authenticode signing script (Phase 13.9)
+
+The `dist` script gets a platform flag:
+`"dist:win": "npm run sync-ui && electron-builder --win nsis"`
+
+Python node on Windows: **bundled with the Electron app** or installed
+separately. The simplest path is embedding Python via `python-embed` (the
+official embeddable zip from python.org) and installing meshbay-node into
+it. This avoids requiring a system Python install.
+
+**Estimated scope:** config + build script, no code changes.
+
+### 5.5 File permissions (W5)
+
+**Scope:** scattered, mechanical
+
+Guard all `chmod` calls:
+
+```python
+if sys.platform != "win32":
+ path.chmod(0o600)
+```
+
+For `keystore.py:97` — skip the mode check on Windows:
+
+```python
+if sys.platform != "win32":
+ mode = oct(key_file.stat().st_mode)[-3:]
+ if mode != "600":
+ log.warning(...)
+```
+
+For JS `{ mode: 0o600 }` — harmless on Windows (Node.js ignores it on
+NTFS), leave as-is.
+
+**Estimated scope:** ~10 lines, trivial guards.
+
+### 5.6 ffmpeg discovery (W6)
+
+**Scope:** `config.py` + `daemon.py` startup
+
+Add to `node.toml`:
+```toml
+[node]
+# ffmpeg_path = "ffmpeg" # default: found via PATH
+```
+
+At daemon startup, resolve with `shutil.which(cfg.ffmpeg_path)` and fail
+with a clear message if not found. Pass the resolved path to every
+subprocess call.
+
+On Windows, recommend installing ffmpeg via winget (`winget install ffmpeg`)
+or bundling it with the installer.
+
+**Estimated scope:** ~30 lines + plumbing the resolved path.
+
+### 5.7 CLI messages (W7)
+
+**Scope:** `daemon.py`, `config.py`
+
+Platform-conditional output in `init`, example configs with Windows paths
+when `sys.platform == "win32"`.
+
+**Estimated scope:** ~20 lines, cosmetic.
+
+---
+
+## 6. Execution order
+
+```
+W1 Platform directories ← unblocks everything; testable on Linux
+W2 Signal handling ← 10 lines; do it with W1
+W5 File permissions ← trivial guards; do it with W1
+W7 CLI messages ← cosmetic; do it with W1
+W6 ffmpeg discovery ← small; do it with W1
+ ──── milestone: daemon runs on Windows ────
+W3 Daemon lifecycle ← largest item; per-user mode first
+ ──── milestone: daemon starts/stops on Windows ────
+W4 Packaging ← electron-builder config + build script
+ ──── milestone: installable on Windows ────
+ Service mode (W3b) ← Phase 2, optional
+ Authenticode signing ← Phase 13.9
+ CI Windows matrix ← Phase 18.3
+```
+
+W1 through W7 (minus W3 and W4) can ship as a **single commit** — they are
+mechanical, self-contained, and testable on Linux. W3 and W4 are the real
+work and can be developed in parallel.
+
+---
+
+## 7. Testing strategy
+
+### What can be tested on Linux
+
+- **Platform directories:** mock `sys.platform` and `os.environ`, verify
+ the returned paths match `%LOCALAPPDATA%` layout.
+- **Signal handling:** the guard itself (`sys.platform` branch) is testable;
+ the actual Windows signal behavior needs a Windows run.
+- **File permissions:** verify the `chmod` calls are skipped when
+ `sys.platform == "win32"` is mocked.
+- **ffmpeg discovery:** mock `shutil.which` returning `None`, verify the
+ startup error.
+- **CLI messages:** snapshot test the output with mocked platform.
+- **Path construction:** `test_paths.py` already covers Windows rules.
+
+### What needs the Windows VM
+
+- **Integration:** daemon startup, WebRTC connection, file indexing on NTFS,
+ video streaming with ffmpeg, upload/download cycle.
+- **Removable media:** plug/unplug a USB drive, verify the per-root
+ unavailable state and the `ReadDirectoryChangesW` watcher recovery.
+- **Packaging:** build the NSIS installer, install, verify file placement,
+ verify `safeStorage`/DPAPI works, verify Task Scheduler autostart.
+- **Electron:** visual check of the UI, native save dialog, node management
+ panel (once W3 is done).
+- **Long paths:** a library with a path exceeding 260 characters on NTFS
+ with long paths enabled.
+- **Case collisions:** a group indexed on ext4 containing `Film.mkv` and
+ `film.mkv`, opened from a Windows client.
+
+### CI (future, Phase 18.3)
+
+Add a Windows runner to GitHub Actions:
+- `pytest` on Windows with a real NTFS filesystem
+- Electron build for Windows
+- The filesystem portability tests from `test_paths.py` run natively
+ rather than via mock
+
+---
+
+## 8. Dependencies and prerequisites
+
+| Dependency | Linux | Windows |
+|---|---|---|
+| Python 3.12+ | system / venv | `python-embed` zip or full install |
+| ffmpeg / ffprobe | system package | `winget install ffmpeg` or bundled |
+| Node.js 22+ | `/opt/nodejs` | installer from nodejs.org |
+| Electron 42+ | npm | npm (same) |
+| aiortc | pip | pip (same — pure Python) |
+| watchdog | pip | pip (same — uses `ReadDirectoryChangesW`) |
+| Argon2 WASM | vendored in `static/vendor/` | same |
+| safeStorage backend | libsecret / kwallet | DPAPI (built into Windows) |
+
+No new Python dependency is needed for per-user mode. Service mode (W3b)
+would need `pywin32` or NSSM.
+
+---
+
+## 9. What is NOT in scope
+
+- **The hub** — stays Linux. No Windows port planned or needed.
+- **macOS** — not planned. `safeStorage` already handles darwin, but no
+ packaging, no daemon lifecycle, no testing.
+- **Windows ARM** — not considered. Electron supports it; Python and
+ ffmpeg availability would need checking.
+- **Windows Store / MSIX** — not planned for v1. NSIS per-user installer
+ is the target.
+- **Service mode** — Phase 2 of the Windows port, after per-user mode is
+ validated.
diff --git a/docs/devel-phases-next.md b/docs/devel-phases-next.md
index 6730ca0..48620ca 100644
--- a/docs/devel-phases-next.md
+++ b/docs/devel-phases-next.md
@@ -1001,17 +1001,33 @@ in production imports it — `grep` finds it only in its own tests. The node cha
(`_do_chat_message`) stores raw payloads. The module provides per-sender chain key
derivation, symmetric message encryption, and a distribution format.
-### 15.0 — Decide the distribution channel FIRST (blocking sub-milestone)
+### 15.0 — Distribution channel ✅ DECIDED 2026-09-03
`draft-v4` §6.6 says sender keys are distributed "via pairwise channels (GEK-wrapped or
-direct)". **GEK-wrapped is the wrong choice** and must not be implemented: it makes every
-sender key a function of the GEK, so anyone who holds the GEK — including an attacker who
-obtained it via H3 key substitution, or a former member who kept it — recovers every sender
-key. The encryption would then be decorative.
+direct)". An earlier version of this document rejected GEK-wrapped distribution on the
+grounds that anyone holding the GEK recovers every sender key.
-Distribution must be **pairwise to identity keys**: wrap each sender key with ECIES to the
-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.
+**Revised 2026-09-03 by operator decision: GEK-wrapped distribution is the right choice
+for this platform.** The reasoning that led to pairwise was sound in isolation but wrong
+for the actual threat model:
+
+- The GEK already gives access to **all files** in the group. Wrapping sender keys
+ under it means "anyone who can read the files can read the chat" — which is exactly
+ the semantics of a group chat. There is no scenario where a member should read files
+ but not chat, or vice versa.
+- The node operator is always a group member and therefore a legitimate sender-key
+ recipient. Sender Keys does not protect chat from the operator regardless of the
+ distribution channel (see threat delta below).
+- H3 (key substitution at invite) is **closed** (2026-08-14, `invite-pairing-v1.md`).
+ The GEK is no longer obtainable through the hub. A former member who kept the old
+ GEK is handled by GEK rotation on removal, which is already implemented.
+- Pairwise distribution would add O(devices × members) ECIES wraps per sender key
+ change, for a marginal security gain: separating "file access" from "chat access"
+ on a platform where both are gated by the same group membership.
+
+**Distribution is GEK-wrapped:** each sender key distribution message is encrypted
+with `wrap_gek_aes` under the group's current GEK. Every member who has the GEK can
+unwrap it. Simple, no fan-out, no new crypto primitive.
### 15.0b — A sender key is per DEVICE, never per person (added 2026-08-17)
@@ -1037,37 +1053,40 @@ 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.
+- **A new device receives all current sender keys via the GEK it already holds** (revised
+ 2026-09-03). Since distribution is GEK-wrapped, a device that has completed the
+ handshake and received the GEK can unwrap every sender key distribution message. No
+ redistribution by every sender is needed; the node replays the latest distribution
+ message for each active chain. History encrypted under older chain keys remains
+ unreadable only if the chain has ratcheted forward since — which is the expected
+ forward-secrecy property, not a gap.
- **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
-member** — a seized machine, a hosting provider, a compromised node. It does **not** protect
-chat from the node operator, because on this platform the operator is a group member and
-therefore a legitimate sender-key recipient. Claiming more than that would repeat the
-overstatement pattern `second-review.md` §7 flags.
+Sender Keys protects chat against **someone who holds the node's disk but not the GEK** —
+a hosting provider imaging the machine, a backup that leaks, a law-enforcement seizure
+where the keystore password is not surrendered. It does **not** protect chat from anyone
+who holds the GEK, which includes every current group member and the node operator.
+This is the same boundary as file access, by design (operator decision 2026-09-03):
+the GEK is the group secret, and both files and chat are gated by it.
-Three additions once devices exist, all of which belong in the user-facing docs:
+Claiming more than that would repeat the overstatement pattern `second-review.md` §7
+flags. Additions that 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.
+- **It does not protect against a former member who kept the GEK before rotation.**
+ GEK rotation on member removal is implemented, but messages encrypted under the
+ old GEK remain readable to anyone who held it. This is the same property as files.
- **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 `desktop-client-v1.md` §5.1.
+ keypair bundle on the node; cracking that bundle yields the GEK, and therefore every
+ sender key distributed under it. 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 `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
@@ -1083,9 +1102,9 @@ Three additions once devices exist, all of which belong in the user-facing docs:
| # | Component | Description |
|---|---|---|
-| 15.0 | **Distribution decision** | Pairwise-to-identity-key, never GEK-derived. Blocking |
-| 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.0 | **Distribution decision** | ✅ **DECIDED 2026-09-03** — GEK-wrapped. The GEK is the group secret; files and chat share the same access boundary |
+| 15.0b | **Per-device chains** | `sender_id` becomes a device identifier; fix `GroupSenderKeyStore`'s silent overwrite. **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 GEK-wrapped to the group |
| 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 removal | Member removed **or device revoked or unpinned** → all remaining devices rotate |
@@ -1337,7 +1356,10 @@ silent overwrite that becomes a hole the moment more than one key per person is
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)
+20. ~~Sender keys are distributed pairwise to identity keys, never derived from or wrapped
+ under the GEK.~~ **Reversed 2026-09-03:** sender keys are distributed **GEK-wrapped**.
+ The GEK is the group secret; both files and chat are gated by it. Pairwise distribution
+ would add complexity for a separation (files vs chat) that has no meaning in this
+ platform's group model. Per-device chains (15.0b) remain required for correctness
21. **Hub minimization is enforced by an acceptance test (12.1), not by policy.** The hub
must be *unable* to see keys, content, or file listings. ✅ (2026-08-13)