From 15087b0e8fdb872602310119f14680aaa443fd93 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 3 Sep 2026 14:09:18 +0200 Subject: 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 --- docs/WINDOWS-PORT.md | 480 ++++++++++++++++++++++++++++++++++++++++++++++ docs/devel-phases-next.md | 90 +++++---- 2 files changed, 536 insertions(+), 34 deletions(-) create mode 100644 docs/WINDOWS-PORT.md (limited to 'docs') 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. - -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. +direct)". An earlier version of this document rejected GEK-wrapped distribution on the +grounds that anyone holding the GEK recovers every sender key. + +**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) -- cgit v1.2.3