From 50ebb4f2e620dad8e1fbca8307b97c5e10e7e6c0 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 18 Aug 2026 02:14:39 +0200 Subject: docs: settle the desktop client, and draft v6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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// 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 --- docs/desktop-client-v1.md | 1087 +++++++++++++++++++++++++++++++++++++++++++++ docs/meshbay-draft-v5.md | 46 +- docs/meshbay-draft-v6.md | 270 +++++++++++ 3 files changed, 1395 insertions(+), 8 deletions(-) create mode 100644 docs/desktop-client-v1.md create mode 100644 docs/meshbay-draft-v6.md (limited to 'docs') diff --git a/docs/desktop-client-v1.md b/docs/desktop-client-v1.md new file mode 100644 index 0000000..b167525 --- /dev/null +++ b/docs/desktop-client-v1.md @@ -0,0 +1,1087 @@ +# MeshBay — Desktop Client (design) + +> Status: **proposal**. Decided in discussion with the operator on 2026-08-17; +> nothing here is implemented. Supersedes structural decision 18 in +> `devel-phases-next.md` (pywebview) and rewrites Phase 13. +> +> Read `docs/invite-pairing-v1.md` and `docs/per-node-identity-v1.md` first — this +> document extends the roster and the pinning model, and it is wrong to read the +> device-linking section without them. +> +> Follows the v5 convention: **every claim names the adversary it holds against**, +> and where a property does not hold, it says so. + +--- + +## 1. What is decided + +| # | Question | Decision | +|---|---|---| +| E1 | Shell | **Electron**, with an optional Python sidecar for `group://` over QUIC. Reverses structural decision 18 | +| E2 | A second device on a node where the user is already pinned | **Device linking**: the already-pinned key countersigns, bound by a one-time code the new device generates. Neither the hub nor the node can substitute a key | +| E3 | Account creation from the native client | **Hybrid**: register with a passphrase-derived `auth_key` (existing path, browser-compatible immediately), then authenticate day to day with a device Ed25519 key | +| E4 | Node management | **Signed admin ops over MNP** for everything group-scoped, including GEK *rotation*. First run stays local. The loopback admin API is never exposed to the network | +| E5 | LAN enrolment of a headless node | **Out of scope for v1**, implementable later as an isolated, self-closing enrolment door. Not needed for the desktop persona | +| E6 | The browser SPA | **Stays.** Objective: a native client must not prevent web use — several devices, remote access | +| E7 | A group's content | **Several named roots**, unique names, forming one virtual root. Not one directory (§6.7) | +| E8 | Target filesystems and platform | **exFAT/NTFS and Windows are the common case**, not an edge case. Linux ships first; that is build order, not population (§6.8, §7.5) | +| E9 | Where group-related server state lives | **On the node. Always.** Not on the hub — putting group state there breaks the model, whatever the feature | + +**E9 is the rule that settles future arguments**, so it is worth stating on its own: + +> If a feature seems to need server-side state about a group — its files, its members' +> devices, what anyone watched, a catalogue, a search index — that state belongs **on the +> node**. The hub holds accounts, group registry and membership, signaling, and nothing +> else about content. It stores no file names for private groups and registers content +> hashes for **public** groups only (H7). + +Everything decided in this document already obeys it: the roster, the device rows, the +pending device requests, the invitations, the index, the per-root availability state and +the chat all live on the node. Nothing here adds a row to the hub. + +Priorities carried from the operator's statement: (1) UI not served by the hub · client at +feature parity for a standard user · confidential material stored locally · web keeps +working · Linux (Ubuntu 24+, Fedora 44+) then Windows. (2) local node management · +`group://` without a hub. (3) automatic updates · Windows. + +**Read the build order carefully.** Linux first, Windows later — but most users are +expected on Windows, sharing from exFAT or NTFS volumes. A design that is convenient on +ext4 and merely *portable* to NTFS has it backwards. + +--- + +## 2. Electron, and why the recorded decision changes + +### 2.1 The measurement that drove it + +Phase 13 was costed in August on figures that have since moved. Measured 2026-08-17: + +| Asset | Doc said | Actual | +|---|---|---| +| `app.js` | ~2600 lines | **4586** | +| `crypto.subtle` in `app.js` | 0 | **2** (`_pkFromSk`, `app.js:157`) | +| `RTCPeerConnection` in `app.js` | 0 | 0 — still true | +| `hubFetch` | 30 call sites | 43 occurrences | + +The seam the phase was justified on has already leaked, and the surface to rewrite grew. +`transport.js` (1242) + `crypto.js` (362) + `keyderive.js` (303) + `downloads.js` (295) + +`sw.js` (68), plus the MSE `VideoPlayer` and the storage glue inside `app.js`, is on the +order of **2500–3000 lines** to reimplement under pywebview — plus a loopback media +server, native dialogs and a SQLite index cache. + +**That cost is not intrinsic to being native. It is intrinsic to replacing the engine.** + +### 2.2 What the SPA actually depends on + +Not "the web" in general — Chromium-class platform APIs: + +| API | Chromium (Electron) | WebKitGTK (pywebview on Linux) | +|---|---|---| +| `RTCPeerConnection`, DataChannel, DTLS fingerprints | ✅ unchanged | ❌ reimplement in aiortc | +| WebCrypto X25519 / Ed25519 / AES-GCM | ✅ | ❌ partial → Python | +| MSE / `SourceBuffer` | ✅ | ⚠️ unreliable → loopback media server (old 13.6) | +| Service Worker (streamed download) | ✅ | ❌ | +| File System Access | ✅ + native dialog | ❌ | +| IndexedDB, WASM (Argon2id) | ✅ | ⚠️ restricted under `file://` | + +Under Electron, `transport.js`, `crypto.js`, `keyderive.js`, `downloads.js`, `sw.js` and +the MSE player **survive unchanged**. The native work reduces to a main process and the +part that has real value: local key storage. + +### 2.3 Correction to the D2 comparison table + +`tmp-decisions.md` counts "no browser sandbox" against native. That is true of +pywebview/WebKitGTK. It is **false for Electron** with `sandbox: true` and +`contextIsolation: true`, which keep the Chromium renderer sandbox — the strongest one +available. On that criterion Electron is better than the decision it replaces. + +### 2.4 What Electron costs, stated plainly + +1. **We own Chromium's patch cadence.** This is the real cost. The renderer parses + decrypted content from nodes — video, images, filenames — which is attacker-controlled + input. A critical Chromium CVE every few months means a rebuild and a push. Mitigated + by distro repositories (§7.4) and CI rebuilding on each Electron release, not + eliminated. +2. **npm enters the project.** Bounded: the UI has no build step (Preact + htm as native + ESM), so the dependency tree is Electron plus a packager. +3. **~180 MB installed**, against ~90–120 MB for a Python shell with `av`/aiortc. Real, + not decisive. + +Tauri was considered and rejected: it uses the system webview, so it inherits every +WebKitGTK problem above and adds a third language. + +### 2.5 The sidecar + +`group://` without a hub needs QUIC to a node's address. There is no usable equivalent in +JS, and a manual SDP exchange is excluded (no copy-paste path, and the operator constraint +forbids a browser on the node host). So hub-less access means reusing `quic_client.py` in +a Python sidecar spoken to over loopback with a token. + +On Linux this is natural: the desktop package **depends on `python3-meshbay-common`**, +which is already packaged. On Windows it means an embedded Python — consistent with +Windows being priority 3. + +The sidecar is also what makes local management work on a LAN with no internet (§6.4). + +### 2.6 Shipping the UI introduces version skew — the tax nobody budgeted + +This is not an Electron property. It follows from shipping the interface at all, and it is +the largest **recurring** cost of this phase — larger than the UI work, which is close to +zero. + +Today the SPA and the hub deploy together and are always in sync: a `/v1/` response shape +can change and `app.js` is fixed in the same commit, because the hub serves both. The +moment the UI lives inside a package, **an old client meets a new hub** — for the first +time in this project's life. + +Consequences to carry deliberately: + +- **`/v1/` becomes a compatibility surface.** Response shapes cannot change freely. The + N-2 MINOR rule already stated for MNP and MHP now applies to the hub API in practice. +- **`GET /v1/hub/version` (10.10) should carry a minimum supported client version**, and + the client should warn below it and refuse below a hard floor. That is cheap to add now + and awkward to retrofit once clients exist in the wild. +- A user running the desktop client and the web SPA against the same hub is running **two + versions of the same code at once**. They must agree on protocol, not on build. + +Decide this before the first public package, not after. + +### 2.7 One UI, one source + +`packages/meshbay-hub/src/meshbay_hub/static/` stays the single source of truth. The +desktop package **copies** it at build time; it is never forked into a second tree. A +silent fork is the only real way to end up maintaining the interface twice, so it should +fail the build rather than be caught by discipline. + +Features that only make sense natively — the node management panel, local folder +selection — live in the same codebase behind a capability check +(`platform.capabilities.nodeAdmin`, `localFolders`). The browser simply does not render +them. There is no second application. + +--- + +## 3. What the shell must do + +Non-negotiable, carried from structural decision 18: **UI assets ship inside the package +and load from disk.** A shell pointing a WebView at the hub's `/app/` is a browser with a +different icon and fixes nothing. + +| Requirement | Why | +|---|---| +| `contextIsolation: true`, `sandbox: true`, `nodeIntegration: false` | keeps the renderer sandbox; the preload exposes a narrow, enumerated API | +| Custom `app://` protocol via `protocol.handle`, **not** `file://` | service workers, ES modules and IndexedDB misbehave under `file://`; also gives a stable origin across updates | +| `registerSchemesAsPrivileged` with `standard`, `secure`, `supportFetchAPI` and `stream` | a scheme without these is not a secure context, so **the service worker silently refuses to register** and streamed downloads break with no error — the same failure mode as an uncontrolled page, already learned once | +| Strict CSP in-package, **keeping `wasm-unsafe-eval` in `script-src`** | the bundle KDF is Argon2id in WebAssembly; a policy forbidding it locks every user out of their keys | +| `will-navigate` and `setWindowOpenHandler` refuse every non-local target | the hub must never become the document origin | +| Electron version pinned to one whose Chromium has X25519 and Ed25519 in WebCrypto | the SPA uses both; **verify on the target version, do not assume** | + +The single structural change to the SPA: `HUB = ''` (`app.js:12`) becomes a configurable +absolute base URL. That is what the old 13.1 platform split existed to do, and under +Electron it is nearly all of it. + +--- + +## 4. Device linking + +### 4.1 The problem + +Identity keys are per node (`docs/per-node-identity-v1.md`). A browser keeps nothing +durable, so its identity is left with the node, encrypted under the passphrase — that is +what lets a second browser work, and it is finding **C4**. + +A native client that keeps its keys locally has no bundle. Today it would be refused: + +```python +# webrtc_server.py:886-894 +known = await roster.get_identity(user_id) +if known: + if known["pk_ed25519"] != pk_ed_b64 or known["pk_x25519"] != pk_x_b64: + self._join_refuse("key_changed", ...) +``` + +and `roster.py` declares `identities(user_id TEXT PRIMARY KEY)` with `pin_identity` doing +`INSERT OR REPLACE`. **One key per person per node**, and a silent overwrite if that ever +changed. + +Without a linking mechanism, a user with a browser and a native client needs an operator +code per node per device — and, worse, an account *created* natively would need an +operator code before its owner could ever use a browser. E6 would be dead on arrival. + +### 4.2 Principle + +The authority is **a key the node pinned itself**, exactly as with `_verify_admin_sig`. +The hub cannot produce it: the hub has stored no user keys since 2026-08-14 +(`users.pk_ed25519` / `pk_x25519` dropped, `PUT /me/keys` gone). So device linking adds +**no hub-reachable authority**. + +The approval must not rest on a human comparing digits. Phase 12.1 abandoned safety +numbers as "correct, unusable as the default", and reintroducing them here through the +back door would be the same mistake. Instead the approval is bound by a **one-time code +the new device generates and displays** — the same primitive as an invitation, with the +code produced by the joining device rather than by the operator. + +### 4.3 Flow + +``` +native generate 40 bits CSPRNG → display 7K2M-BQ84 + code_hash = sha256(code ‖ new_pk_ed25519 ‖ new_pk_x25519) +native → device_add_request {new_pk_ed25519, new_pk_x25519, code_hash, sig_new} + node ← pre-proof window; stored as a pending row, TTL 1 h +browser "Add a device" → the user types 7K2M-BQ84 +browser ← the node returns pending candidates for this user_id (req_id + both keys) + node the client recomputes sha256(code ‖ keys) and keeps the row that matches +browser → device_add {req_id, sig_pinned} + node +node verify sig_new, verify sig_pinned against a live pinned key of this user, + insert a device row, audit the event +``` + +Binding the keys *into* the code hash is what stops the node from substituting them: a +node returning different keys produces no match, and the client refuses before signing. +The fingerprint is still displayed on both ends, as a secondary check — **not** +load-bearing. + +### 4.4 Transcripts + +``` +"meshbay:device_req:v1" ‖ len‖node_pk ‖ len‖user_id ‖ len‖new_pk_ed25519 + ‖ len‖new_pk_x25519 ‖ len‖code_hash ‖ len‖nonce_s ‖ len‖ts + +"meshbay:device_add:v1" ‖ len‖node_pk ‖ len‖user_id ‖ len‖new_pk_ed25519 + ‖ len‖new_pk_x25519 ‖ len‖nonce_s ‖ len‖ts +``` + +Length-prefixed and domain-separated per 11.5.21. `node_pk` binds an authorization to one +node; `nonce_s` — the handshake nonce of the connection carrying the message — stops a +replay onto another connection. The code is a bearer secret: never signed, never echoed, +stored only as the hash. + +### 4.5 Schema change + +```sql +-- identities: PRIMARY KEY (user_id) → PRIMARY KEY (user_id, pk_ed25519) +-- + label TEXT, added_at TEXT, added_by_pk TEXT, revoked_at TEXT +``` + +`pin_identity` can no longer be `INSERT OR REPLACE` — today that silently overwrites, and +with multiple devices it becomes a hole. `key_changed` becomes "this key is not among +this user's devices". + +New table `device_requests(req_id, user_id, pk_ed25519, pk_x25519, code_hash, expires_at, +created_at)`, covered by the existing `purge_expired()`. + +### 4.6 Bounds + +- **TTL 1 h**, configurable as `[node] device_request_ttl_minutes`, beside the existing + `invite_ttl_hours` (7 d) and `pair_ttl_hours` (24 h). +- Only a `user_id` that already has at least one pinned identity may file a request. This + is anti-spam, **not** a security boundary — the filing key is unpinned by construction. +- 5 attempts per connection and a node-wide lockout on failures, as for invitations. + Guessing is 40 uniformly random bits, single use, one account. +- Cap devices per user (5 by default). The countersigning key must not be revoked. +- Every filing and every approval is an audit event. `member unpin ` removes **all** + their devices; add `member device list|revoke` to the operator surface. + +### 4.7 What this does not fix + +**A device chain inherits the weakness of its weakest ancestor.** Whoever cracks a +browser's keypair bundle (C4) can add a device — and that device **survives a passphrase +change**. That is a genuinely new risk, not a relocation. It is answered by visibility, +not by cryptography: audit events, devices listed to the operator and to the user, and +unpin removing the lot. + +**Approval from a browser inherits T3.** A hub shipping malicious SPA code reads the code +the user types into it and approves its own request. This is unchanged by anything here +and is the reason Phase 13 exists. Its practical consequence: the **first** browser → +native link is the moment of highest exposure for an account, and it happens once. Two +free mitigations — the operator's invitation code remains available for anyone wanting +out-of-band assurance, and an account created natively (§5) does the first link in the +safe direction. + +### 4.8 Authorship: who said it, who uploaded it + +**Requirement added 2026-08-17.** Chat senders must be **cryptographically authenticated to +each other**, and an upload must have an **identifiable owner** who can delete it, as the +operator can. An earlier draft of this document recorded node-trusted attribution as an +accepted limitation. It is now a requirement, and device linking both forces the work and +supplies most of the machinery. + +#### A. Device linking breaks the existing owner-delete path — fix it in the same change + +```python +# webrtc_server.py:1805-1817, _admin_exec_file_delete +uploader_pk = Ed25519PublicKey.from_public_bytes(base64.b64decode(entry.uploader_pk)) +if not (await self._verify_admin_sig(transcript, sig) + or self._verify_sig(uploader_pk, transcript, sig)): +``` + +Authorization is against **the exact key that uploaded**. Alice uploads from her phone and +cannot delete from her desktop. So: + +- Authorize against **any non-revoked device of `entry.uploader_id`** in the roster. + `uploader_id` is already recorded beside `uploader_pk` (`_register_uploader`, + `webrtc_server.py:1626`), so the data is there. +- Keep `uploader_pk` — it stops being the authorization key and becomes the **audit** + record of which device did it. +- **This stays roster-rooted, not token-rooted**, which is the protection + `per-node-identity-v1.md` added: a hub minting a token that claims to be Alice still + holds no key the node pinned for Alice, so the signature fails. Whoever issues tokens + still does not decide who may delete a file. +- Ownership must survive device revocation — a revoked laptop's uploads keep their owner — + and survive the account losing every device, where the operator remains able to delete. + +#### B. Make ownership provable, not merely recorded + +Today ownership is an assertion by the node. The uploader should **sign it**: + +``` +"meshbay:upload:v1" ‖ len‖node_pk ‖ len‖group_id ‖ len‖root ‖ len‖rel_path + ‖ len‖content_hash ‖ len‖user_id ‖ len‖ts +``` + +signed by the uploading device's key and stored with the index entry. Then ownership is +verifiable by any member rather than taken on the node's word, the delete authorization has +a signed record behind it, and — combined with the no-overwrite rule — the C5a path where +overwriting a file made the attacker its recorded uploader is closed twice over. The +content hash already exists. + +Chat attachments are ordinary uploads and get the same record, which is one reason they +land in the designated upload root (§6.7). + +#### C. Chat sender authentication — the constraint first + +**Operator constraint, 2026-08-17: no new code exchanges between people.** Verification +rituals between users are refused in this context, and that single rule decides the shape. + +It costs almost nothing, because the complexity here is in the implementation, not in the +experience: + +| | What the user does in addition | +|---|---| +| Tier 1 — sign each message with the device key | **nothing**, entirely invisible | +| Tier 2 — pin `account → device keys` on first sight | **nothing** in normal operation; a notice only when a key changes | +| Tier 3 — operator-signed roster attestation | **nothing**; the operator's key is already pinned | +| ~~Safety numbers~~ | compare digits with every contact ← **the only thing that adds an exchange** | + +The device-linking code is not an exchange between people: it is a code between **your own +devices**, and it was already accepted. Nothing new appears between users. + +**Decided:** ship Tiers 1 and 2. **Defer Tier 3** — it changes nothing a user sees and can +land at any time; it only covers "the node is compromised but the operator's key is not", +which is real but narrow, since in most deployments the operator *is* the machine. +**Safety numbers are refused for this purpose**, permanently. + +Total user-visible cost of the whole authorship requirement: **one notice**, "this +account's key changed". Everything else — signing, pinning, upload records, delete by +account — is invisible. And pinning is not a new concept: it is the third application of +the one already used for `pk_node` (11.5.8). + +#### C bis. The tiers themselves + +**Tier 1 — sign every message with the sender's device key.** Independent of Phase 15: +signing is not encryption, so this can land first and should. It gains: no member can forge +another member; someone holding a stolen chat database cannot *extend* it with messages +that verify; the node can no longer silently rewrite history. Today nothing but the node's +own enforcement (NS6) stands between a member and impersonation. + +**Tier 2 — clients pin `account → device keys` on first sight.** The evidence already +exists: **device linking produces a countersignature** — a statement, signed by an +already-pinned key, that a new key belongs to the same account. Relay those with the +roster and a member who has pinned Alice's first device can verify her second without +trusting the node. A node substituting a key for an account you have already seen is then +**detected**, not merely improbable. + +**Tier 3 — close first sight. Deferred.** The operator signs a roster attestation +(`account → devices`) and members pin the operator's key, which protects when the **node is +compromised but the operator's key is not** — a seized machine, a hosting provider, a +subverted daemon, the same threat Sender Keys was scoped for. Deferred because it changes +nothing a user sees and can be added later without touching Tiers 1 and 2. + +Safety numbers were the other candidate. **Refused** under the no-new-code-exchanges +constraint: Phase 12.1 dropped them for key *delivery* and left the door open for identity +verification, and that door is now closed for chat. + +#### D. The boundary that remains, stated per the convention + +Against **the node operator at first contact**, attribution cannot be made unforgeable: +they run admission, and a member who has never seen Alice has nothing to compare against. +But the property is better than "the operator can always impersonate anyone": + +> Once a member's client has pinned Alice's device key, **an operator who turns malicious +> later cannot forge Alice to that member.** Forgery is limited to accounts the victim has +> never seen. + +That is a real property, it is the same shape as `pk_node` TOFU pinning (11.5.8), and it is +worth stating precisely rather than rounding to either extreme. + +#### E. Where this lands + +A is a **regression introduced by device linking**, so it ships with Stage C, not later. +B and Tier 1 are small and independent of Phase 15 — they should land in the same stage. +Tier 2 belongs with Phase 15, whose threat delta it changes. Tier 3 is deferred with no +date, and nothing depends on it. + +--- + +## 5. Account creation and authentication + +`POST /v1/users/register` (`api/users.py:100`) accepts `username` + `auth_key` **or** +`password`, plus email; `auth_key` is PBKDF2-SHA512 600k derived client-side, so the hub +never sees a passphrase (T1). There is **no** `POST /v1/users/auth` today — the old 13.3 +assumed one; it has to be written. + +**Decision E3 — hybrid:** + +1. Registration derives `auth_key` from a passphrase exactly as the browser does, using + the existing endpoint. The account is browser-compatible from the first minute, and + the passphrase is the account's **only recovery path** — a native-only account whose + local keystore is lost would otherwise be dead. +2. Day-to-day authentication uses a device Ed25519 key registered with the hub, on the + pattern of `POST /v1/nodes/auth` (signed timestamp) which already exists for the + daemon. The passphrase is entered at install and at recovery, and stored nowhere. +3. Identity keys for nodes are generated locally and **never bundled anywhere**. + +`keyderive.py` already implements the browser's derivation in Python and +`test_bundle_kdf_parity.py` holds the two byte-identical. That test now covers a third +consumer; the standing warning applies — **never change those parameters in one place**. + +### 5.1 What this means for C4 + +C4 closes for an account **only when no browser needs a bundle on that node**. Two +consequences to state rather than blur: + +- The native device's private key is in no bundle anywhere, so an operator cannot attack + it at all. That part is closed unconditionally. +- As long as the account is also used from a browser, a bundle exists on each node and C4 + stays open for the browser identity — which is admitted to the same groups and reads the + same content. **The native client raises the ceiling, not the floor.** +- Closing it fully needs an opt-out: `device_policy {allow_bundle: false}`, **signed by a + pinned key** so the decision is the user's and not the hub's. The node then deletes the + stored bundle and refuses to store another for that user. + +--- + +## 6. Node management + +### 6.1 The observation + +The roster-pinned operator key is a **better authenticator than the loopback admin API's +token**. The token is a bearer string printed at startup; the operator key is an Ed25519 +identity bound by a one-time code, signing structured transcripts with a 120 s TTL and +channel binding. Moving operator actions from the loopback API to signed MNP ops is a +**strengthening**, not a relaxation. + +Existing ops (`meshbay_common/adminop.py`): `file_delete`, `dir_delete`, `invite_create`, +`member_revoke`. + +### 6.2 GEK rotation over MNP — and the line that stays + +Draft-v5 §5.1 says *"nothing arriving over MNP can **activate** a GEK"*. That rule targets +C5b — **a member supplying key material** the node adopts. An operator-signed instruction +where **the node generates the key with its own CSPRNG** is a different shape: nothing +arrives from outside, and the node remains the sole producer of every copy of the GEK, +which is the §5.5 property. + +So `gek_rotate` may be a signed MNP op. Blast radius is small: the same key already +authorizes `member_revoke` and `dir_delete` remotely, and rotation is self-healing because +wrapping happens per connection. + +**The initial `gek-init` is different** and stays local. Without a GEK, `join_result` +answers `no_gek` and no MNP session completes — no session, no signed op. Putting it in +the pre-proof window is possible but is the neighbourhood where C4 and C5b were born; it +is deferred (§11), and it costs only the headless case which is deferred anyway. + +### 6.3 Scope + +| | Reachable remotely | Note | +|---|---|---| +| Invitations, revoke, unpin, devices, file/dir delete, roster, audit, peers | ✅ signed MNP | mostly present already | +| **GEK rotation** | ✅ signed MNP | node generates; nobody supplies | +| Attaching a further group, a bounded set of config keys | ✅ signed MNP, empty `group_id` | the precedent exists: `roster.get_member("", user_id)` (`webrtc_server.py:899`) already authorizes the operator node-wide | +| **First `gek-init`** | ❌ local | §6.2 | +| Starting/stopping the daemon | ❌ | that is systemd's job; if the daemon is down nothing is reachable anyway | + +The node's loopback admin API (20 endpoints in `ui/app.py`, including `groups/attach` and +`groups/{id}/gek`) **is never exposed to the network**. C1 and C6 were both "a second path +into the node with its own weaker handshake"; a LAN-exposed admin API with a second +authorization model beside the roster is that shape exactly. + +### 6.4 The honest limit + +MNP between client and node is established through the **hub's WebRTC signaling**. On a +LAN, ICE picks a local candidate so the data path stays local — but setup needs the hub. +**A LAN with no internet cannot manage its node over MNP.** The answer is direct QUIC +`group://` to a local address, i.e. the sidecar (§2.5). That is what makes the sidecar +worth more than a nice-to-have: it is the hub-independent local path, and the one that +survives a hub that is down or hostile. + +### 6.5 Several nodes + +The client keeps a local node registry — identifier, pinned `pk_node`, hosted groups, +last seen. This is also where the 11.5.8 TOFU pin belongs, so design it once. + +### 6.6 One implementation, three adapters — and Phase 14 comes first + +**Phase 14 (node CLI) should be finished before 13.12.** Two reasons, and the second +changes how. + +1. The CLI is the only interface that works in the states the desktop client cannot + reach: daemon stopped, no GEK yet, no operator paired. Those are precisely the states + E5 and O1 send back to the local machine. An incomplete CLI leaves bootstrap holes + with no fallback. Remaining: 14.2 `group list`, 14.3 `group create`, 14.4 `group + join`, 14.8 SIGHUP reload, 14.10 `denylist`, 14.11 `file rm`. +2. **Parity is not achieved by writing the same thing twice.** The CLI calls the loopback + admin API (`_daemon_api()` in `daemon.py`); 13.12 would add MNP handlers for the same + operations. Two paths to one operation, with different authorization, is the shape of + **C1 and C6**. + +So each operation should be factored into **one internal module** — `meshbay_node/ops.py` +— with the CLI, the loopback API and the MNP handler as three thin adapters over it. +Parity becomes structural rather than a discipline, authorization checks live in one +place, tests have one target, and 13.12 becomes adapter code rather than a second +implementation. + +The refactor costs less now, with six commands left, than after 13.12 exists. + +### 6.7 Several roots per group + +**Decided 2026-08-17.** A group's content is not one directory but **a set of named +roots**, each mapping to a local directory. Names are unique within the group and a +duplicate is refused. The group's virtual root is the union of them: + +``` +/ (group virtual root) +├── Films/ → D:\Media\Films +├── Musique/ → E:\Audio (external drive) +└── Documents/ → C:\Users\me\Partage +``` + +`shared_dir` (one string per group, `config.py:103`) becomes a list of +`{name, path, kind}` entries. A bare `shared_dir` in an existing config migrates to a +single root. + +**Every index path gains a root segment**, uniformly — including in a single-root +deployment, so there is one code path and not two. Consequences, all one-time and all +worth accepting: + +- Existing deployments see every path change once (`/film.mkv` → `/Films/film.mkv`). + Resume positions, keyed by path in `localStorage`, are invalidated. The alternative — + leaving a lone root unprefixed — buys nothing and doubles the path logic forever. +- **MNP messages do not change shape**, only the data they carry, so this is a MINOR bump + (0.2 → 0.3) alongside the new `device_*` and `gek_rotate` messages, not a MAJOR one. An + 0.2 client asking for a path that no longer exists gets a clean not-found, not a + protocol error. +- **The hub needs no change at all.** `SwarmSource` carries `content_hash`, `node_id` and + `endpoint` — **no paths, no filenames** (`db/models.py:181`), and private groups + register nothing (H7). Verified 2026-08-17. Multi-root is a node + protocol + UI change, + end to end. + +**The name is the chosen directory's basename** (decided 2026-08-17), so adding a root is +one gesture with no naming step. Four rules make that safe: + +- **Derived once, at add time, then stored.** Never recomputed from the path at startup — + otherwise renaming `D:\Films` to `D:\Cinéma` on disk silently re-identifies the whole + library and breaks every stored reference to it. +- **A duplicate basename is refused**, compared **case-insensitively** (§6.8). Collisions + will be common in practice: `D:\Films` and `E:\Films`, `C:\Users\me\Music` and + `D:\Music`. Refusing is correct, but "rename your folder on disk" is a poor answer to + give a user, so an explicit **alias** should be available as the escape hatch (O11). +- **The basename becomes a path segment every member sees**, including Windows clients + downloading. It must therefore pass the portability rules itself: a Linux user sharing + `/home/me/AUX` would create a root Windows cannot write to (§6.8). Failing that check + forces an alias. +- **Uploads go to a root the operator designates.** Chat attachments land there too + (draft-v5 §5.2). If that root is unavailable the upload **fails with a clear reason** and + never silently falls back to another root; if none is designated, uploads are refused + rather than guessed. + +The requirement comes from what is planned on top of the file explorer and chat — a +video library, an audio library — which will not live in one folder on one disk. The +unique-name rule is what keeps the union well defined: it removes the ambiguous case +where the same relative path exists under two roots, which is the hard part of any union +mount, and it should be enforced at config load **and** at selection time. + +What it costs, and none of it is optional: + +1. **The root name is part of a file's identity.** Every index path becomes + `/`. Renaming a root rewrites every path under it and churns + the whole subtree of the index — so renaming is an explicit, warned operation, not a + cosmetic setting. +2. **No root may contain another.** `D:\Media` and `D:\Media\Films` mounted together + would index the same bytes twice under two identities. Canonicalize each root and + refuse a prefix relationship — **case-insensitively**, since on NTFS and exFAT + `d:\media` and `D:\Media` are the same directory (§6.8). +3. **Availability is per root, and this is the good news.** With one directory, an + unplugged disk was a hazard. With named roots it becomes a supported state: `Musique/` + goes unavailable, its subtree freezes, and `Films/` and `Documents/` carry on. The + "unavailable, not deleted" state (§6.9) is therefore designed **per root** from the + start, not retrofitted. +4. **Free space, quotas and capacity are per root** — different volumes. Anything the UI + says about space has to name which root it is talking about. +5. **Path resolution must be per root.** A file request names `/`; the node + resolves it against that root's canonical path and refuses anything escaping it. One + implementation, in the indexer, not one per transport. The quarantine and no-overwrite + rules (draft-v5 §5.2) are likewise per root. +6. **Reserve `kind` on each root** (`generic` | `video` | `audio` | `photo`), unused in + v1 — a default view hint, nothing more. See §6.10. + +### 6.8 Filesystem portability is a first-class constraint + +**Corrected 2026-08-17.** An earlier draft of this document filed exFAT and NTFS under +"accessory". That was wrong: sharing from an external drive formatted exFAT or NTFS is +expected to be the **common** case, not the exception, and Windows is expected to be the +majority platform for both client and node (§7.5). + +So the index and the upload path must be correct on case-insensitive, permission-less +filesystems — this is not a compatibility note, it is a correctness requirement. + +| Property | What it breaks | What has to be true | +|---|---|---| +| **Case-insensitive, case-preserving** (NTFS, exFAT) | `Film.mkv` and `film.mkv` cannot coexist; an index built on ext4 can be unrepresentable on NTFS | The index needs a defined canonical identity and a **case-folding collision check** at scan time, reported to the operator rather than resolved silently | +| **No-overwrite on upload** (C5a) | Nothing — see the correction below | `Path.exists()` is case-insensitive on the filesystems where it matters, so the existing check already holds | +| **Unicode normalization** | `Café.mkv` written on macOS (NFD) and on Windows (NFC) are different byte strings and compare unequal. For French filenames this is routine, not exotic | Normalize to NFC **for comparison and index identity**; preserve the original bytes for display and for opening the file | +| **Reserved names and characters** (Windows) | `CON`, `PRN`, `AUX`, `NUL`, `COM1`–`COM9`, `LPT1`–`LPT9`, the characters `< > : " / \ | ? *`, trailing dots and spaces. A group indexed from a Linux node can contain names Windows cannot create | The client sanitizes on save and **tells the user it renamed something**; the upload allowlist should be the intersection across platforms, or some files are simply undownloadable on Windows | +| **MAX_PATH 260** (Windows, unless long paths are enabled) | A deep media library exceeds it | Use `\\?\`-prefixed paths in the node and the client on Windows | +| **Timestamp granularity** (FAT/exFAT: 2 s, local time, no timezone) | mtime as a change-detection fast path produces spurious rescans and, worse, missed changes across a DST shift | Do not trust mtime alone on exFAT — size + mtime with tolerance, and rehash when in doubt | +| **No symlinks** | Nothing. This is a *simplification*: no traversal out of the root to defend against | — | +| **No POSIX permissions or ownership** | Nothing, since the node runs as the user and access is all-or-nothing per volume | — | + +**Correction (2026-08-18, found while implementing).** An earlier version of this +section called the no-overwrite check a security bug on case-insensitive +filesystems. It is not: `_free_name` uses `Path.exists()`, and on NTFS and exFAT +`stat()` is itself case-insensitive, so an upload of `readme.txt` beside an +existing `README.TXT` already gets a free name rather than replacing it. C5a is +not reachable through the filesystem. What case folding is actually needed for is +**index identity, collision reporting and root names** — where a comparison the +code performs itself, rather than one the OS performs, decides whether two names +are the same file. + +Two watcher consequences, one per platform: + +- **Windows**: watchdog uses `ReadDirectoryChangesW`, whose buffer overflows under a burst + of changes and **drops events silently**. A periodic reconciliation scan is not optional + there; it is the only thing that recovers a missed event. +- **Linux**: exFAT may be mounted through FUSE, where inotify does not reliably report + changes made outside the FUSE mount. Same answer: reconcile periodically. + +### 6.9 A root that goes away must freeze, not empty + +`indexer/indexer.py` runs a watchdog `Observer` and rebuilds on any change. When a volume +is unmounted, either watchdog emits deletions for the whole tree or the next scan sees an +empty directory — both end with that content's index emptied and the deletions propagated +as though the owner had erased their library. + +The node needs an explicit **per-root `unavailable` state** that freezes the subtree +instead of emptying it, and surfaces it to members as "temporarily unavailable" rather +than "gone". **This ships before root selection is offered.** + +Detecting it differs by platform, and neither method is exotic: + +- **Windows**: `WM_DEVICECHANGE` announces volume arrival and removal; the volume is + identified by `\\?\Volume{GUID}\`, never by drive letter, which moves. +- **Linux**: the mount table, plus `RequiresMountsFor=` on the unit so systemd orders the + service after the mount. Note a Linux-only trap that has no Windows equivalent: a unit + with `ProtectSystem` / `PrivateTmp` has its own mount namespace, so a volume mounted on + the host **after** the service started is invisible inside it — the directory reads as + empty even when everything else is configured correctly. + +### 6.10 The planned libraries are views, not a catalogue + +**Scope settled 2026-08-17, and it is deliberately small.** The planned "video library" +and "audio library" are **alternative views over the existing file index** — a variant of +the explorer. Presentation rests on the filename and the directory structure, plus a +thumbnail for video. There is **no catalogue, no metadata store, and no server-side +database, now or later**, and nothing about them reaches the hub. + +An earlier draft of this section over-designed against a richer feature than the one +wanted. Retracted: the `layout` field, the per-root scan policy, and "a file is not an +item". The governing rule is the opposite of that last one, and simpler: + +> **A file stays tied to its representation on the filesystem.** The view shows files and +> folders. Subdirectories are subdirectories — a "category", a "style", an "album" is +> whatever the folder is called. There is no item abstraction to keep in sync, and moving +> a file on disk makes it a different file, exactly as it does in the explorer today. + +#### What this means is already true + +| A view needs | Where it comes from | Work required | +|---|---|---| +| The whole group's file list, client-side | already cached in IndexedDB (10b.5), already searched client-side across groups (10b.6) | none | +| "Is this a video / audio file" | file extension, decided in the client | none | +| Folder hierarchy as categories or albums | the index already carries paths | none | +| A subtitle beside a video | a same-named `.srt` is fetched like any other file; the video view can hide it from the grid and offer it as a track | presentation only | +| A thumbnail for a video | the one thing that is not free — see below | one decision | + +So the answer to "is anything blocking?" is **almost nothing, and the architecture is +already in the right shape**. Four properties have to survive, and all four hold today: +the whole-group index is available client-side; files are identified by their path; there +is no server-side catalogue to build; and the hub is not involved. + +#### The one open point: video thumbnails + +There will usually be no embedded cover art — these are raw files. So a thumbnail is a +frame grab, which is genuinely derived data, and draft-v5 §5.2 forbids the node keeping a +second copy of an image it already holds. + +**The resolution follows the rule already in force for chat thumbnails**, and it is the +ridge line being looked for: + +> Enrichment happens **on the client**, from data it already has. Anything the client +> cannot compute is produced **on demand by the node** and cached **on the device that +> asked**. Neither the node nor the hub keeps durable derived state. + +Concretely: the node grabs a frame on request — it already decodes video for streaming, so +this is strictly less than it does today, over the same authorized path (GEK proof, group +membership, same transport) with no new capability and no new authorization surface. The +client caches the result locally, per device, disposable, never leaving it. + +That keeps the node free of a thumbnail store, keeps §5.2 intact, and is what makes +"no server-side state" affordable rather than merely principled. + +**One operational rule, and it is not optional.** A grid of fifty videos means fifty frame +grabs. Thumbnails must run in **their own small bounded pool with a short timeout, never +in the stream pool** (`[node] max_concurrent_streams`, default 8), where a slot is held +for the length of a film — one grid view would otherwise exhaust every streaming slot on +the node. And the pipe-draining rule from the streaming work applies unchanged: drain +stdout, wait with a timeout, release the slot regardless. A thumbnail is a short ffmpeg +run, which is exactly the shape that has deadlocked this codebase before. + +#### The mistake to refuse + +"Search across my whole media library" is the tempting one, and it would be a real +regression: the hub stores **no file names for private groups** and registers content +hashes for **public** groups only (H7). Cross-device resume is the same shape — CLAUDE.md +pins it as *"nothing new learns what you watch"*. Any library feature that seems to want a +server-side index wants the **node**, and most of the time wants nothing at all. + +--- + +## 7. Packaging and first run + +### 7.1 What already exists, and why it matters + +`packaging/systemd/meshbay-node.service` is a **per-user template** (`User=%i`, +`WorkingDirectory=%h`, state under `~/.local/share/meshbay`, config under +`~/.config/meshbay`), and `meshbay-node.spec` has **no `%post` scriptlets**. + +Consequence: in the standard case — client and node on the same machine — both run under +the **same UID**. The client reads `data_dir/pair-code` and the loopback session token +straight off the filesystem. No SSH, no polkit, no root. First `gek-init` and operator +pairing happen from the client, locally, with no terminal. + +### 7.2 An ambiguity to settle + +As written, `User=%i` / `Group=%i` makes it a **system** template: `sudo systemctl enable +--now meshbay-node@alice`. A true user unit cannot carry `User=`. The in-file comment says +"per-user service"; the directive says system. Ship **both**: + +| Unit | Enabled by | Persona | +|---|---|---| +| system template, `User=%i` | `sudo systemctl enable --now meshbay-node@alice` | server — an ordinary admin gesture, once | +| user unit, no `User=` | `systemctl --user enable --now meshbay-node` — **no privilege** | desktop — the client does it itself | + +With `loginctl enable-linger` where the node must run with no session open. + +### 7.3 The firm rule: installation places files, never secrets + +No identity material in `%post` / `postinst` / MSI custom actions. Three reasons, the +second being a trap: + +1. Fedora and Debian guidelines forbid interactivity in scriptlets and discourage state + generation. +2. **A package installed into a golden image or a container would give every machine the + same key.** Known, severe, and silent. +3. `%post` runs as root; the key would belong to root while the daemon runs as the user. + +Keys are generated at **first daemon start**, on that machine, under the identity running +it. + +### 7.4 Sequence, with no terminal + +``` +install files + units. Non-interactive. No service force-started (respect presets). +client 1st run no local node detected → "install a node here?" + → systemctl --user enable --now meshbay-node (no privilege) +daemon 1st run generates its Ed25519 pair into the keystore, creates data_dir, + writes the per-run session token (11.5.3) +wizard the client reads the token (same UID) and walks through, on loopback: + link the node to the hub account · create or attach a group · + gek-init · operator pair +thereafter everything over signed MNP, local or remote +``` + +A shared directory outside the user's home needs `ReadWritePaths=` extended — the unit +already says so. The client writes a drop-in at +`~/.config/systemd/user/meshbay-node.service.d/paths.conf` rather than weakening +`ProtectSystem=strict`. + +### 7.5 Windows + +**Corrected 2026-08-17.** Windows ships *after* Linux, but it is expected to be where +**most users run both the client and the node**. Build order and population are not the +same thing: nothing in the design may assume POSIX, systemd, or a case-sensitive +filesystem, even while Linux is the only target that has shipped. Everything in §7.1–§7.4 +is Linux-specific *hardening*; it is not the model. + +| | Linux | Windows | +|---|---|---| +| Autostart | `systemctl --user enable --now` | logon task (Task Scheduler) or a Startup entry | +| State | `~/.local/share/meshbay` | `%LOCALAPPDATA%` | +| Key storage | `safeStorage` → libsecret / kwallet | `safeStorage` → DPAPI, bound to the user account | +| Install | DEB / RPM, per-user unit, no privilege | **MSI per-user, no administrator rights** | +| Sandbox around the node | `ProtectSystem=strict`, `ReadWritePaths=`, mount namespace | **none** — the node is an ordinary user process | +| Volume identity | filesystem UUID | `\\?\Volume{GUID}\`, never a drive letter | +| Removable media events | mount table + `RequiresMountsFor=` | `WM_DEVICECHANGE` | + +The sandbox row is an asymmetry worth stating rather than papering over: on Linux the node +is confined by the unit, on Windows it has whatever the user has. That is a real +difference in exposure, and it is inherent to the platform, not to this design. + +Three Windows-specific facts that shape the product, not just the packaging: + +1. **A per-user node stops when the user logs off**, and there is no equivalent of + `loginctl enable-linger`. **Decided 2026-08-17: the installer offers a service mode**, + as a deliberate, administrator-elevated choice, alongside the per-user default. Three + consequences: + - **Not `LOCAL SYSTEM`.** A service running as SYSTEM would have *more* privilege than + its owner, which inverts least privilege. Use a **dedicated low-privilege local + account**. + - **Granting access to each root becomes an explicit step.** The user picks a folder + from their own session; the service account must then be granted read access to it. + This belongs in the root-selection flow, not in a troubleshooting page. + - **`safeStorage`/DPAPI is bound to a user account**, so the client (per-user) and a + node running as a service cannot share a DPAPI-protected secret. This is not a + problem: the node already has its own keystore scheme (`keystore.py`, Argon2id plus + an unlock key) and keeps it. The separation exists; it just has to be stated. + - In per-user mode the node is **available only while its owner is logged in**. That + must be visible in the UI and honest in the docs — availability is the entire point + of hosting content. +2. **The firewall prompt.** A node listening for QUIC needs an exception. A per-user MSI + cannot create firewall rules without elevation, so the first run raises the Windows + Firewall dialog, and allowing it on a public network needs administrator approval. The + first-run wizard has to expect this and explain it, rather than leaving the user with a + node that silently accepts no connections. +3. **SmartScreen.** An unsigned or newly-signed MSI is flagged until reputation + accumulates. Authenticode signing is a cost item and belongs with 13.9, and its absence + is felt on Windows far more than the absence of a GPG signature is felt on Linux. + +Same rule on secrets as everywhere else: **no key generation in an MSI custom action.** A +silent or imaged install has exactly the shared-key problem `%post` has. + +### 7.6 Android, later + +Phase 16 reuses this design. One structural difference is worth knowing now, stated +without jargon: + +**An Android app cannot freely read the phone's folders.** It gets its own private +directory, and for anything else the user must point at a folder in a system dialog; the +app then has access to that one tree, and that access can be revoked. + +A node has to read a directory tree continuously, watch it for changes, and serve files +around the clock. Android is hostile to all three: there is no reliable filesystem +watching through that mechanism, background processes are killed aggressively, and a +long-lived listening socket is not guaranteed. + +So: **an Android client, yes; an Android node hosting a library, no** — and nothing in the +root model (§6.7) should assume otherwise. This is a "do not assume", not a permanent +prohibition. + +### 7.7 Updates (priority 3) + +Prefer the **signed apt/dnf repository** (18.5) over a bespoke updater: the OS verifies +the signature, the key was installed out of band at install time, and `electron-updater` +handles deb/rpm poorly anyway. Windows later: `electron-updater` plus Authenticode. + +An updater is an injection channel. Without a release key whose fingerprint is published +somewhere the hub does not control, it *is* the new T3. + +--- + +## 8. The web tier + +### 8.1 URL-space contract + +| Space | Served by | Seen by the native client | +|---|---|---| +| `/`, `/about`, `/downloads`, `/news` | Caddy, `site/`, static, public | ❌ never | +| `/app/*` | hub (SPA) | ❌ never | +| `/v1/*`, signaling WS | hub (API) | ✅ only this | + +Testable, and worth a test: **the native client issues no request outside `/v1/` and the +signaling socket**. That recovers the intent of the route-inventory blindness test dropped +from Phase 12. + +### 8.2 Announcements + +Two channels, no third: + +1. **Existing notifications** (`api/notifications.py`, Phase 10.5) for anything aimed at a + signed-in user. Already reaches web and native. Add an admin-broadcast type. +2. **`GET /v1/hub/announcements`** — a small public JSON list (id, date, severity, title, + body, locale) read by the landing page *and* by the native client. One source, one tab + in the existing admin UI, no CMS. + +Two guards, because this lets the hub display text **inside** the native app, which is a +phishing surface: + +- plain text or a very restricted markdown subset, **never HTML**, never `innerHTML`; +- a visually distinct region labelled as a message from the hub operator, never a modal + that can imitate app UI, no in-app link targets. + +### 8.3 The download page is a security page + +`downloads.html` is where the release key fingerprint is published — and a hostile hub +serves that page too. Publish the fingerprint somewhere the hub does not control as well +(repository README, a signed tag, ideally a third channel). Otherwise the relocation of +trust described in `tmp-decisions.md` is circular. + +--- + +## 9. Security: what changes, what does not + +| Claim | Passive hub | Active hub | Malicious node operator | Malicious member | +|---|---|---|---|---| +| Client code integrity | ✅ ships in the package | ⚠️ **detectable, not prevented** — realised by 18.7 + published hashes, not by the packaging format | ✅ | ✅ | +| The hub can read content by shipping malicious code (**T3**) | ✅ removed for native | ✅ removed for native; **unchanged for the browser** | — | — | +| Device linking grants the hub authority | ✅ no | ✅ **no** — the countersignature is a key the hub has never held | ⚠️ a node can only add a device to itself, where it already reads everything | ✅ no | +| Keypair bundles (**C4**) | closed for native devices | closed for native devices | ⚠️ still open for any account that also uses a browser (§5.1) | — | +| Hub knows memberships, who spoke where and when | ❌ unchanged | ❌ unchanged | — | — | +| Hub can deny service (it relays signaling) | ❌ unchanged | ❌ unchanged; `group://` over QUIC is the way out | — | — | +| New surface | — | update channel, Chromium CVEs, sidecar loopback, node-admin panel in-process | — | — | + +**The claim this design supports:** *the hub's remaining power over a native client's +confidentiality is the artifact it ships, and an artifact can be hashed and compared.* + +**The claim it must not make:** that the native client makes the hub untrusted. It does +not, and a build signed with a key the hub operator holds relocates trust rather than +removing it. That value is realised by **18.7 reproducible builds** — for which this +project is unusually well placed, since the UI has no bundler or minifier and Electron's +own binaries have published upstream hashes. + +**And the sentence to repeat in user-facing docs:** an account used from a browser is only +as strong as the browser path. + +--- + +## 10. Execution order + +The desktop client comes **last**, and deliberately so: everything before it is work the +web SPA needs anyway, and shipping the client on top of a path model that is about to +change would mean shipping the change twice, into an installed base, with version skew +(§2.6) making it expensive. + +### Stage A — content model (node + protocol + UI, no client work) + +Everything in this stage is delivered to today's web SPA, and the hub is untouched. + +| # | Component | Prio | +|---|---|---| +| A1 | **Filesystem portability** in the indexer and upload path — case folding, NFC, reserved names, `\\?\`, reconciliation scans. **The case-folded no-overwrite check is a security fix** (§6.8) | 1 | +| A2 | **Per-root `unavailable` state** — freeze the subtree, never empty it (§6.9) | 1 | +| A3 | **Named roots per group** — `shared_dir` → `[{name, path, kind}]`, basename naming, no nesting, designated upload root, root segment in every path (§6.7) | 1 | +| A4 | **MNP 0.2 → 0.3** and the SPA updated for root-aware paths | 1 | + +A1 before A3: the collision rules have to be right before roots multiply the paths that +can collide. A2 before A3 is not strictly required but avoids a window where an unplugged +disk is destructive. + +### Stage B — operator surface (node, no client work) + +| # | Component | Prio | +|---|---|---| +| B1 | ✅ **DONE 2026-08-18** — `group list`, `gek init\|rotate`, `reload` (SIGHUP), `denylist show\|clear`, `file list\|rm`. **No operator action requires a browser any more.** `group create` and `group join` are not built: creating a group on the hub is a browser or API action and `group add` already hosts one | 1 | +| B2 | ✅ **DONE 2026-08-18** — `meshbay_node/ops.py`. The loopback API is one `_op(...)` line per endpoint; MNP handlers call the same functions. `test_ops.py` asserts the shape rather than trusting it | 1 | +| B3 | ✅ **DONE 2026-08-18** for `gek_rotate` and `member_unpin`, both operator-signed. Root and device management deferred: device management needs Stage C, and root management needs writing into a hand-written TOML block, which `ops.add_root` validates and then refuses with the exact lines to paste | 1 | + +B2 is what stops B3 from becoming a second implementation. Doing B1 without B2 means +refactoring the same six commands twice. + +### Stage C — identity, which the client cannot ship without + +| # | Component | Prio | +|---|---|---| +| C1 | **Device linking** — `identities` schema, `device_requests`, the two transcripts, operator surface (§4) | 1 | +| C2 | **`POST /v1/users/auth`** — device Ed25519 authentication (§5). *The only hub change in the whole plan* | 1 | +| C3 | **Minimum client version** in `GET /v1/hub/version` (§2.6, O8) | 1 | +| C4 | Device management in the SPA — list, approve, revoke | 1 | + +C1 lands in the SPA first, where both ends of a link can be exercised without a desktop +build existing. + +### Stage D — the desktop client + +| # | Component | Prio | +|---|---|---| +| D1 | Configurable hub base URL + storage/save-file adapter (old 13.1) | 1 | +| D2 | Electron shell — `app://`, privileged scheme, CSP, preload, sandbox, single instance, tray, window state | 1 | +| D3 | Local key storage: `safeStorage`, explicit fallback where no keyring exists | 1 | +| D4 | Hybrid registration and first sign-in (§5) | 1 | +| D5 | Node management panel over the Stage-B ops, root selection included | 2 | +| D6 | First-run wizard — detect, enable the unit, link, group, `gek-init`, pair (§7.4) | 2 | +| D7 | Native save dialog, streamed write from the main process | 2 | +| D8 | `.deb` + `.rpm` | 1 | +| D9 | Python sidecar — `group://` over QUIC | 2 | +| D10 | Video thumbnails, own bounded pool (§6.10) | 2 | +| D11 | Windows: MSI per-user, service mode offered, firewall and SmartScreen handled (§7.5) | 3 | +| D12 | Release key, signed apt/dnf repository, updates via the OS | 3 | +| ~~D13~~ | ~~Safety-number UI~~ — **dropped** (decision 19): no new code or fingerprint exchanges between people. Chat authentication is served by Tiers 1 and 2 (§4.8), which cost the user nothing | — | + +### Deleted from the old Phase 13 + +`13.4` aiortc client transport · `13.5` SQLite index cache · `13.6` loopback media server — +Chromium provides all three. `13.11` "retire the SPA?" is settled by E6. + +Deletions enabled once native is the recommended client are unchanged from +`devel-phases-next.md`, **except** that `transport.js`, `crypto.js`, `keyderive.js`, +`downloads.js` and `sw.js` are now *kept* — they are the client, not browser workarounds. + +--- + +## 11. Open items, each needing its own review + +| # | Item | Why it is not decided here | +|---|---|---| +| O1 | `gek-init` in the pre-proof window | Would let a headless node be initialised remotely. It runs before the GEK proof, which is the neighbourhood C4 and C5b came from. Deferred with E5 | +| O2 | LAN enrolment door | One endpoint, bounded window, one-time code, closes permanently on success. Small but it executes before any authentication | +| O3 | `device_policy {allow_bundle: false}` | The mechanism that actually closes C4 (§5.1). Needs to be signed by a pinned key, never settable by the hub | +| O4 | Node-admin panel isolation | Node-supplied strings (filenames, hub-originated usernames) rendered in a process holding the user's keys. H2 was exactly this. Separate window or partition at minimum | +| O5 | `MESHBAY_UNLOCK_KEY` in `node.env` | Plaintext in the user's home. The client could move it to the OS keychain for the desktop persona | +| O6 | Electron version floor | X25519 and Ed25519 in WebCrypto must be verified on the pinned version, not assumed | +| ~~O7~~ | Several directories in one group | **Decided 2026-08-17** — named roots, unique names, union root. See §6.7 | +| O8 | Minimum client version in `GET /v1/hub/version` | Needed before the first public package (§2.6). Trivial now, awkward once clients are in the wild | +| ~~O9~~ | Windows node availability | **Decided 2026-08-17** — the installer offers a service mode under a dedicated low-privilege account, alongside the per-user default (§7.5) | +| O10 | Canonical file identity across filesystems | Case folding and NFC normalization decide when two names are "the same file". It affects the index, the no-overwrite rule and cross-platform replication, so it must be defined once and shared (§6.8) | +| O11 | Root alias when the basename cannot be used | Duplicate basenames across volumes will be common, and a basename can fail the portability rules. Refusing is correct; "rename the folder on your disk" is a poor answer, so an explicit alias is the escape hatch (§6.7) | +| O13 | Hub identity pinning | The client points at a hub by URL and nothing pins that hub's identity. `GET /v1/hub/pubkey` exists; pinning it on first use — as `pk_node` already is (11.5.8) — would stop an account's hub being silently swapped. Bounded today, because a substituted hub cannot read content and no longer ships the code; worth doing all the same | +| ~~O12~~ | Derived thumbnails vs draft-v5 §5.2 | **Resolved 2026-08-17** — frame grab produced on demand by the node, cached on the requesting device. The node keeps no thumbnail store, so §5.2 stands unchanged (§6.10) | + +--- + +## 12. Decisions record + +Settled with the operator on 2026-08-17: + +| # | Question | Decision | +|---|---|---| +| 1 | Shell | Electron + optional Python sidecar. Structural decision 18 (pywebview) is reversed | +| 2 | Device approval | Pending request on the node, bound by a one-time code generated and displayed by the new device. No human digit comparison on the critical path | +| 3 | Device request TTL | **1 h**, configurable. Made a comfort setting rather than a security parameter by the code binding | +| 4 | Account creation | Hybrid: passphrase-derived `auth_key` at registration, device Ed25519 key thereafter | +| 5 | Node management | Signed MNP ops; the loopback admin API is never exposed to the network | +| 6 | First run and initial `gek-init` | Local, on the machine hosting the node | +| 7 | LAN enrolment of a headless node | Out of scope for v1, kept implementable (O2) | +| 8 | Sequencing | **Phase 14 (node CLI) before 13.12**, factored into one `ops.py` with the CLI, the loopback API and the MNP handler as adapters (§6.6) | +| 9 | Directory selection, removable media included | Allowed, and **gated on the per-root "unavailable" state** shipping first (§6.9) | +| 10 | One UI source | `meshbay_hub/static/` copied into the desktop package at build time, never forked; native-only features gated by capability (§2.7) | +| 11 | **Several roots per group** | Named roots, unique names, union virtual root, no nesting, `kind` reserved for the planned libraries (§6.7) | +| 12 | **exFAT/NTFS and Windows are the common case** | Not an edge case to accommodate later. The index and the upload path must be correct on case-insensitive, permission-less filesystems, and no design may assume POSIX or systemd (§6.8, §7.5) | +| 13 | Root naming | The chosen directory's **basename**, derived once at add time and stored. Duplicates refused, case-insensitively. The operator designates the upload root (§6.7) | +| 14 | Windows node availability | The installer **offers a service mode** under a dedicated low-privilege account; per-user remains the default (§7.5) | +| 15 | Future libraries | **Views over the existing index**, not a catalogue. No metadata store, no server-side database, ever. A file stays tied to its representation on the filesystem. Reserve `kind` as a view hint; `layout` and a scan policy were over-design and are retracted (§6.10) | +| 16 | Where enrichment happens | **On the client, from data it already has.** What it cannot compute, the node produces **on demand** and the requesting device caches. Neither node nor hub keeps durable derived state — the rule already in force for chat thumbnails (draft-v5 §5.2) | +| 17 | **Where group-related server state lives** | **On the node, always** (E9). The hub gains no row from anything in this document. Verified for multi-root: `SwarmSource` carries hashes and endpoints, no paths | +| 19 | **No new code exchanges between people** | Verification rituals between users are refused. Safety numbers are out for chat authentication, permanently. The device-linking code is between a user's **own** devices and is unaffected. Total user-visible cost of the authorship work: one "this account's key changed" notice (§4.8) | +| 18 | Execution order | Content model → operator surface → identity → client (§10). The client is last because everything before it is owed to the web SPA anyway, and shipping it first would mean shipping the path change twice into an installed base | + +Item 3 is a default for v1, not a constraint. Items 1, 2, 4, 5, 8, 9, 11, 12, 17 and 18 +are structural and should not be revisited without revisiting this document. diff --git a/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md index b03328b..e4218ee 100644 --- a/docs/meshbay-draft-v5.md +++ b/docs/meshbay-draft-v5.md @@ -1,8 +1,15 @@ # MeshBay — Architecture Draft v5 +> **SUPERSEDED 2026-08-17 by `docs/meshbay-draft-v6.md`.** v6 restates only what +> changed; everything below that v6 does not touch is still authoritative — the +> handshake (§4), node authority (§5), the hub's role (§6), cryptography (§7) and +> the testing posture (§10). Read v6 first, then this. +> > Status: **Phase 11.5 (security remediation) complete. Invite redesign complete > (2026-08-14) — H3 and M3 closed.** See `devel-phases-next.md` and, for the invite -> and pairing design, `docs/invite-pairing-v1.md`. +> and pairing design, `docs/invite-pairing-v1.md`. For the desktop client — shell, +> device linking, account creation, node management — `docs/desktop-client-v1.md` +> (2026-08-17) is authoritative and supersedes §8.2 here. > Supersedes `meshbay-draft-v4.md`. Sections not restated here are unchanged from v4. > > v5 exists because the second security review (`second-review.md`, 2026-08-13) found @@ -220,7 +227,7 @@ was 32 opaque random bytes signed blind — an unbound signing oracle. |---|---| | `file_delete` | Node operator, or the user who uploaded the file (verified by the key recorded at upload) | | `invite_create` | **Node operator only** (delegation designed, deferred) | -| GEK activation | **Local admin UI or CLI only** — no MNP message can activate a GEK | +| GEK activation | **Local admin UI or CLI only** — no MNP message can activate a GEK. The rule targets *key material arriving from outside* (C5b), not the instruction: a future operator-signed `gek_rotate` where **the node generates the key itself** is a different shape and is allowed (`docs/desktop-client-v1.md` §6.2). The initial `gek-init` stays local — with no GEK there is no completed session to carry a signed op | | ~~`gek_bundle_store`~~ | **Message deleted.** No member ever hands the node key material | `gek_bundle_store` previously accepted any member's bundle and auto-activated one @@ -513,17 +520,40 @@ end-to-end *integrity* for that path. ### 8.2 Native client -pywebview + aiortc, sharing ~69% of the existing UI unchanged (the SPA contains no direct -`crypto.subtle` or `RTCPeerConnection` usage; everything goes through three injected -globals and one fetch helper). Assets ship **inside the package** and load from disk — a -shell pointing its WebView at the hub's `/app/` would be a browser with a different icon -and would fix nothing. +**Electron** (decided 2026-08-17 — see `docs/desktop-client-v1.md`, which is authoritative +for the desktop client and supersedes this section's earlier pywebview form), with an +optional Python sidecar reusing `quic_client.py` for hub-less `group://`. + +Assets ship **inside the package** and load from disk — a shell pointing its WebView at the +hub's `/app/` would be a browser with a different icon and would fix nothing. That +constraint is unchanged and is the whole point. + +What changed is the engine. The SPA does not depend on "the web" in general but on +Chromium-class APIs: `RTCPeerConnection`, WebCrypto X25519/Ed25519, MSE, Service Workers, +File System Access. Keeping Chromium keeps `transport.js`, `crypto.js`, `keyderive.js`, +`downloads.js`, `sw.js` and the MSE player **unchanged**; replacing it with a system webview +meant reimplementing ~2500–3000 lines plus a loopback media server and native dialogs. The +"~69 % reused" figure quoted here previously was measured in August against an `app.js` of +~2600 lines; it is 4586 lines as of 2026-08-17, and `app.js` now contains 2 direct +`crypto.subtle` calls (`_pkFromSk`), so the three-globals seam had already leaked. Native does **not** remove trust in the hub operator; it converts an undetectable, per-request, per-user attack into a persistent artifact that can be hashed and compared. That value is realised by reproducible builds and published hashes, not by the packaging format. +Two properties of the desktop client bear on this document directly: + +- **Identity keys are generated and kept locally**, never bundled anywhere. C4 closes for a + native device unconditionally — but **not for an account that also uses a browser**, which + still needs a bundle on each node (§7.1). An account is only as strong as its weakest + client, and the docs must say so. +- **A second device is admitted by device linking**, not by an operator code: the + already-pinned key countersigns, bound by a one-time code the new device generates. The + hub cannot produce that countersignature — it has stored no user keys since 2026-08-14 + (§6.1) — so this adds no hub-reachable authority. `identities` gains a device dimension; + see `docs/desktop-client-v1.md` §4 before touching the roster. + --- ## 9. Open items @@ -546,7 +576,7 @@ format. | # | Item | Status | |---|---|---| -| C4 | Remote keypair bundles | Bounded (4 pre-proof fetches, audited); closes in Phase 13.3 when the native client stops storing them remotely | +| C4 | Remote keypair bundles | Bounded (4 pre-proof fetches, audited). **Closes for a native device unconditionally** (its keys are never bundled), but **stays open for any account that also uses a browser** — a browser has no durable storage of its own and needs a bundle on each node. Full closure needs the opt-out in `docs/desktop-client-v1.md` §5.1, signed by a pinned key so the hub cannot set it | | — | Chat encryption (Sender Keys) | Phase 15. Distribution must be **pairwise to identity keys**, never GEK-derived | | — | Chat attachments | Plaintext on the node; decide in Phase 15.7 | | — | Delegation | Designed, deferred: a group admin who does not run the node still cannot invite | diff --git a/docs/meshbay-draft-v6.md b/docs/meshbay-draft-v6.md new file mode 100644 index 0000000..6a161cd --- /dev/null +++ b/docs/meshbay-draft-v6.md @@ -0,0 +1,270 @@ +# MeshBay — Architecture Draft v6 + +> Status: **current specification.** Supersedes `meshbay-draft-v5.md`. +> **Sections not restated here are unchanged from v5**, which remains the reference for +> everything v6 does not touch — the handshake (§4), node authority (§5), the hub's role +> (§6), cryptography (§7) and the testing posture (§10) are all still v5's. +> +> v6 exists because a design discussion on 2026-08-17 settled the desktop client and, in +> doing so, changed four things v5 states: what a group's content *is*, how a person's +> devices are admitted, how authorship is established, and which shell the native client +> uses. It also records one rule v5 assumed without writing down. +> +> The v5 convention is carried forward and is not negotiable: **a claim in this document +> must name the adversary it holds against.** A property that holds against a passive hub +> and not an active one is written that way. + +--- + +## 0. Reading order + +| Read | For | +|---|---| +| **this document** | what is true now, and what changed from v5 | +| `meshbay-draft-v5.md` | everything v6 does not restate — still authoritative there | +| `second-review.md` | the findings (C1–C6, H1–H7, M*, L*) referenced throughout the code | +| `docs/invite-pairing-v1.md` | invitations, pairing codes, the node roster — **as built** | +| `docs/per-node-identity-v1.md` | identity keys are per node; the hub stores none | +| `docs/desktop-client-v1.md` | the desktop client in full — shell, device linking, roots, packaging, execution order | +| `devel-phases-next.md` | the roadmap | + +--- + +## 1. Changes from v5 + +| # | Category | Change | Source | +|---|---|---|---| +| 1 | Content model | A group's content is **several named roots**, not one directory. Names are unique, derived from the directory's basename, and form a union virtual root | E7 / decision 11, 13 | +| 2 | Identity | **Device linking**: one person may hold several devices on a node, admitted by a key the node already pinned, bound by a one-time code the new device generates | E2 / decision 2 | +| 3 | Client | The native client is **Electron**, not pywebview. Structural decision 18 reversed | E1 / decision 1 | +| 4 | Node authority | `gek_rotate` may be a signed MNP op — the C5b rule forbids *key material arriving from outside*, not an operator-signed instruction where the node generates the key | §2.3 below | +| 5 | Server state | **Group-related server state lives on the node, always.** Written down because v5 assumed it | E9 / decision 17 | +| 6 | Portability | exFAT/NTFS and Windows are the **common** case. Case folding and Unicode normalization become correctness requirements, not compatibility notes | E8 / decision 12 | +| 7 | Accounts | Native registration is **hybrid**: passphrase-derived `auth_key` (the recovery path) plus a device Ed25519 key for day-to-day authentication | E3 / decision 4 | +| 8 | Authorship | Chat senders are **cryptographically authenticated to each other**; an upload has a **provable owner** who may delete it, as the operator may. v5's node-asserted attribution is replaced | operator decision, §2.4b | + +--- + +## 2. What v6 changes in v5's text + +### 2.1 §5.2 Uploads — now per root + +v5 confines uploads to `shared_root/uploads/` with a filename allowlist, no overwrite, +chunk ordering and a size cap. All four protections stand. Two amendments: + +- There is no single `shared_root`. **The operator designates one root as the upload + destination**; the quarantine lives inside it. If that root is unavailable the upload + fails with a stated reason and never falls back to another; if none is designated, + uploads are refused rather than guessed. +- **The no-overwrite rule is unchanged and still holds on exFAT/NTFS.** An earlier + draft claimed a string comparison let `README.TXT` land on `readme.txt` there. It does + not: the check is `Path.exists()`, and `stat()` is itself case-insensitive on those + filesystems, so the upload already gets a free name. C5a is not reachable through the + filesystem. Case folding is needed for index identity, collision reporting and root + names — comparisons the code makes itself. + +v5's rule that nothing derived is written beside the originals is **unchanged**, and it +decides the video-thumbnail question: a frame grab is produced on demand and cached on the +device that asked, so the node keeps no thumbnail store. + +### 2.2 §5.5 Admission — devices, not one key per person + +v5 and `invite-pairing-v1.md` bind **one** key pair to an account per node: `identities` +has `user_id` as its primary key and `pin_identity` does `INSERT OR REPLACE`. A person +with a browser and a native client needs two keys on the same node, so: + +- `identities` becomes keyed by `(user_id, pk_ed25519)`, with `label`, `added_at`, + `added_by_pk` and `revoked_at`. **`INSERT OR REPLACE` must go** — today it silently + overwrites, which becomes a hole the moment a second key is legitimate. +- A new device is admitted when **a key the node already pinned countersigns it**, bound + by a one-time code the new device generates and displays, hashed together with the new + keys so the node cannot substitute them. +- The operator's one-time code remains available and is unchanged. Device linking is an + addition to admission, not a replacement. + +**Against an active hub this holds**, and for the same reason §5.5 holds: the hub has +stored no user keys since 2026-08-14, so it cannot produce the countersignature. Against a +malicious node operator it is not a new exposure — a node can only add a device to itself, +where it already reads everything it serves. + +**Where it does not hold:** approval performed *in a browser* inherits T3, because the hub +serves that browser its code and can read the typed code. The first browser-to-native link +is therefore the moment of highest exposure for an account, and it happens once. + +Full design: `docs/desktop-client-v1.md` §4. + +### 2.3 §5.1 GEK activation — the rule, stated precisely + +v5 says *"nothing arriving over MNP can activate a GEK"*. Read precisely: the rule targets +**key material arriving from outside** (C5b), not the instruction. An operator-signed +`gek_rotate` where **the node generates the key with its own CSPRNG** satisfies the +property v5 §5.5 actually establishes — the node produces every copy of the key — and is +allowed. + +**The initial `gek-init` stays local.** With no GEK, `join_result` answers `no_gek` and no +MNP session completes, so there is no authenticated session to carry a signed op. Placing +it in the pre-proof window is possible and is deliberately deferred; that window is where +C4 and C5b were born. + +### 2.4 §8.2 Native client — Electron + +pywebview is replaced by Electron plus an optional Python sidecar for hub-less `group://` +over QUIC. The non-negotiable is unchanged and is the entire point: **UI assets ship +inside the package and load from disk.** A shell pointing at the hub's `/app/` is a browser +with a different icon. + +What changes is the engine, not the claim. Native still does **not** remove trust in the +hub operator; it converts an undetectable, per-request attack into an artifact that can be +hashed and compared, and that value is realised by reproducible builds (18.7), not by the +packaging format. + +Two corrections to v5's client table: + +- **Key storage.** Identity keys are generated and kept locally, never bundled. C4 closes + for a native device unconditionally — and **stays open for any account that also uses a + browser**, which has no durable storage of its own and still needs a bundle on each + node. An account is only as strong as its weakest client. +- **Crypto.** The client keeps WebCrypto *and* gains local Argon2id and ChaCha20 in the + main process. v5 implied WebCrypto is lost with the browser engine; under Electron it is + not. + +### 2.4b §5.1 Authorship — authenticated, not asserted + +v5 §5.1 authorizes `file_delete` by "the node operator, or the user who uploaded the file +(verified by the key recorded at upload)". Two changes: + +**Authorization moves from the key to the account.** With several devices per person, +`_admin_exec_file_delete` — which verifies against `entry.uploader_pk`, the exact uploading +key — would refuse Alice's desktop the right to delete what her phone uploaded. It becomes +**any non-revoked device of `uploader_id` in the roster**, with `uploader_pk` kept as the +audit record of which device acted. This remains **roster-rooted, not token-rooted**: a hub +minting a token that claims to be Alice holds no key the node pinned for Alice, so the +signature fails — the property `per-node-identity-v1.md` established is preserved. + +**Ownership becomes provable.** The uploader signs `meshbay:upload:v1` over node, group, +root, path, content hash, account and timestamp; the node stores it with the index entry. +Ownership is then verifiable by any member rather than asserted by the node, and the C5a +path — overwriting a file to become its recorded uploader — is closed a second time. + +**Chat senders must be cryptographically authenticated to each other.** v5 relied on NS6, +where the node enforces `sender_id` from the authenticated session; that is the node's +word. Messages are signed with the sender's **device** key, clients pin `account → device +keys` on first sight using the device-add countersignatures as evidence, and the operator +may sign a roster attestation to close first contact. + +**Against whom this holds.** Against another member: fully — no member can forge another's +signature. Against someone holding the node's disk: fully — a stolen chat store cannot be +*extended* with messages that verify. Against the node operator: **partially, and the +partial part is worth having** — once a member's client has pinned Alice's device key, an +operator who turns malicious later cannot forge Alice to that member; forgery is limited to +accounts the victim has never seen. Full protection at first contact requires an +attestation rooted outside the node, which is what the operator-signed roster and safety +numbers provide. + +Design: `docs/desktop-client-v1.md` §4.8. + +### 2.5 §6.1 Hub role — one addition, one rule + +The hub gains exactly one endpoint from all of this: **`POST /v1/users/auth`**, device +Ed25519 authentication on the pattern of `POST /v1/nodes/auth`. Nothing else in the +desktop-client design adds a row or a column to the hub. + +And the rule v5 assumed without writing: + +> **Group-related server state lives on the node.** Files, indexes, members' devices, +> pending device requests, invitations, chat, per-root availability, and anything a future +> feature wants to keep about a group — all on the node. The hub holds accounts, the group +> registry and membership, signaling, notifications and the moderation surface, and +> nothing else about content. + +Verified for the multi-root change: `SwarmSource` carries `content_hash`, `node_id` and +`endpoint` — **no paths, no filenames** — and private groups register nothing (H7). The +content model changes end to end without the hub moving. + +### 2.6 §7 Cryptography — unchanged, one consumer added + +No parameter changes. `keyderive.py` now has a third consumer: the desktop client derives +`auth_key` exactly as the browser does at registration. `test_bundle_kdf_parity.py` covers +it, and the standing warning is unchanged — **never change those parameters in one +place**; a mismatch does not look like an error, it looks like an account nobody can open. + +--- + +## 3. Filesystem portability as a security property + +New in v6, because it was treated as an edge case and is not one. Most users are expected +to share from an external exFAT or NTFS volume, on Windows. + +| Property | Consequence | +|---|---| +| Case-insensitive, case-preserving | `Film.mkv` and `film.mkv` cannot coexist. The index needs a canonical identity and a **case-folding collision check** at scan time. The no-overwrite rule must be case-folded — **this one is a security fix** (§2.1) | +| Unicode normalization | `Café.mkv` written on macOS (NFD) and Windows (NFC) are different byte strings. Normalize to **NFC for identity**, preserve the original bytes for display and opening | +| Windows reserved names, `MAX_PATH` | A group indexed on Linux can hold names Windows cannot create. The client sanitizes on save **and says so**; use `\\?\` paths | +| FAT/exFAT timestamps (2 s, local time) | mtime alone is not a change detector. Size + mtime with tolerance, rehash when in doubt | +| Watcher reliability | `ReadDirectoryChangesW` drops events under load; inotify on a FUSE mount is unreliable. **Periodic reconciliation is mandatory on both platforms** | +| No symlinks, no POSIX permissions | Simplifications: nothing to defend against, and the node runs as the user anyway | + +A volume that disappears must **freeze** the affected root's subtree, never empty it. +Emptying propagates deletions for a whole library as though the owner had erased it. + +--- + +## 4. Security claims — deltas only + +v5 §2's table stands. Three rows change, and they are the honest version: + +| Claim | Passive hub | Active hub | Malicious node operator | +|---|---|---|---| +| Client code integrity | ✅ ships in the package (native) | ⚠️ **detectable, not prevented** — realised by 18.7, not by packaging | ✅ | +| Keypair bundles (**C4**) | closed for native devices | closed for native devices | ⚠️ **open for any account that also uses a browser** | +| Devices | ✅ | ✅ the hub cannot countersign a device — it holds no user keys | ⚠️ a node adds devices only to itself, where it already reads everything | + +**The claim v6 supports:** *the hub cannot read your content, and against a native client +its only remaining lever is the artifact it ships — which can be hashed and compared.* + +**The claim it must not make:** that a native client makes the hub untrusted, or that C4 +is closed for an account that still signs in from a browser. + +--- + +## 5. Still open + +v5 §9's list stands, with these movements: + +| # | Item | Status | +|---|---|---| +| C4 | Remote keypair bundles | **Partially closed.** Gone for native devices; open for browser-using accounts until the signed `device_policy {allow_bundle: false}` opt-out ships | +| T3 | Hub serves the SPA | **Accepted permanently** for browser users. Removed for native clients, whose value depends on 18.7 | +| — | Chat encryption (Sender Keys) | Phase 15, unchanged. Pairwise to identity keys, never GEK-derived — and **now to devices**, which multiplies the recipients per person | +| — | Delegation | Designed, deferred, unchanged | +| — | Hub identity pinning | New. `GET /v1/hub/pubkey` exists and nothing pins it; bounded, because a substituted hub can neither read content nor ship the code to a native client | + +**Phase 15 has been re-read against device linking (2026-08-17) and was wrong as written.** +The correction is recorded in `devel-phases-next.md` §15.0b; the load-bearing part: + +- **A sender key is per device, never per person.** A shared per-person chain advanced by + two devices produces key and nonce reuse — which is exactly why `first-review.md` C1 + rejected a shared Double Ratchet for groups. The same mistake, one level down. +- `senderkeys.py` already fails this silently: `GroupSenderKeyStore.add_sender` does + `self._states[dist.sender_id] = ...`, so a second device under the same `sender_id` + **overwrites the first and drops its chain**. `sender_id` must become a device + identifier. +- **Revoking a device must rotate**, like revoking a member. +- **A newly linked device cannot read history** until every sender redistributes, unless + the linking device hands over its own state sealed to the new device's key. +- **Sender attribution stays node-trusted.** A sender key proves a *device*; the mapping + from device to account comes from the node's roster. Encrypted chat does not make + senders cryptographically authenticated to each other, and the docs must not imply it. + +Ordering consequence: **device linking (Stage C) lands before Phase 15**, or Phase 15 is +built against an identity model that is about to change underneath it. + +--- + +## 6. Where the detail lives + +This document states what changed and what holds. It does not restate the desktop client's +design, which is long and belongs in one place: +**`docs/desktop-client-v1.md`** — shell requirements, device-linking protocol and schema, +account creation, node management over signed MNP ops, several roots per group, filesystem +portability, packaging and first run, the web tier, and the execution order for all of it. -- cgit v1.2.3