summaryrefslogtreecommitdiffstats
path: root/docs/meshbay-draft-v5.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/meshbay-draft-v5.md')
-rw-r--r--docs/meshbay-draft-v5.md622
1 files changed, 0 insertions, 622 deletions
diff --git a/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md
deleted file mode 100644
index 49aef75..0000000
--- a/docs/meshbay-draft-v5.md
+++ /dev/null
@@ -1,622 +0,0 @@
-# MeshBay — Architecture Draft v5
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the architecture draft v5; its design
-> content now lives in §2 (claims), §5 (protocol), §6 (node), §7 (hub), §4 (cryptography), §12 (testing).
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> **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`. 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 draft v4 (archived in `old-draft.md`). Sections not restated here are unchanged from v4.
->
-> v5 exists because the second security review (`second-review.md`, 2026-08-13) found
-> that v4 described a system the code did not implement, and because several v4 claims
-> were simply wrong. This document states what is true, what is chosen, and what is
-> knowingly accepted. **Where a property does not hold, it says so.**
-
----
-
-## 0. How to read this document
-
-v4 described intent. Several of its statements were contradicted by the implementation:
-
-| v4 said | Reality found in the code |
-|---|---|
-| "ALL operations require passing the GEK proof first" (§4.2.x) | True on WebRTC only. QUIC, TCP and the node HTTP API each had their own weaker handshake |
-| "The node NEVER serves the GEK in plaintext" | True, but the node's HTTP API served the *decrypted files themselves* with no authentication at all |
-| "Argon2id parameters updated: memory_cost 262144" | Applied to the hub's password verifier only; the node keystore was still at 64 MB |
-| "GEK bundles: opaque blobs; hub cannot decrypt them" | True, and irrelevant — the hub was the key directory, so it could substitute a key at invite time and be handed the GEK legitimately *(fixed 2026-08-14; the invite path no longer reads the directory — §5.5)* |
-| "hub stores no content metadata" | The node registered blake3 hashes of **private** files with the hub |
-
-So v5 carries an explicit rule: **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.
-
----
-
-## 1. Changes from v4
-
-| # | Category | Change | Source |
-|---|---|---|---|
-| 1 | Transport | TCP+TLS transport and the node HTTP file API **removed**. Two listeners remain: WebRTC DataChannel (primary) and QUIC (LAN / port-forwarded / hub-less) | 11.5.1–2, findings C1/C6 |
-| 2 | NAT traversal | **ICE/STUN is the traversal mechanism**, for native clients too. `punch_nat()` is a direct-connection helper, not a traversal stack | Decision D3 |
-| 3 | Handshake | One implementation in `meshbay_common.handshake`, shared by every transport | 11.5.4, finding C6 |
-| 4 | Handshake | **Mutual authentication**: the node proves GEK possession and signs the transcript. Authentication used to run one way only | 11.5.7, finding C3 |
-| 5 | Handshake | Transcripts are length-prefixed and domain-separated; `group_id` is mandatory; missing channel binding is refused | L4, M1 |
-| 6 | Admin ops | Destructive operations sign a structured transcript naming operation, node, group, subject, nonce and time — not 32 opaque random bytes | Finding H5 |
-| 7 | Node authority | **Nothing arriving over MNP can activate a GEK.** Member-supplied bundles are not gated but *gone*: `gek_bundle_store` was deleted when the node took over wrapping | Finding C5b |
-| 7b | Admission | **The node wraps the group key itself**, for a key the recipient proved possession of, and admits people from its own roster using one-time codes the hub never sees (§5.5) | Findings **H3**, **M3** |
-| 8 | Uploads | Confined to a per-user quarantine directory; no overwrite | Finding C5a |
-| 9 | Node identity | Hub verifies node ownership on WebSocket registration; group claims are derived from the database | Finding C2 |
-| 10 | Revocation | Group revocations are honoured by nodes; denylist persists across restarts | Finding H4 |
-| 11 | Privacy | Only **public** groups register content hashes with the hub | Finding H7 |
-| 12 | Client architecture | The hub **keeps serving the web UI**. A native desktop client is offered **alongside** it | Decision D1/D2 |
-| 13 | Hub role | "Hub minimization" is **deferred and may be dropped**. The hub stays in the trusted path by choice | Decision D4 |
-
----
-
-## 2. Security claims — what holds, against whom
-
-This replaces the informal assurances scattered through v4 §4.2.x and §13.
-
-| Claim | Passive hub | **Active hub** | Malicious node operator | Malicious group member | Network attacker |
-|---|---|---|---|---|---|
-| File content is unreadable | ✅ | ❌ **T3** (browser) · ✅ native | ❌ by design — the operator hosts the files | ❌ members share the GEK | ✅ |
-| Chat content is unreadable | ✅ | ❌ T3 · ✅ native | ❌ plaintext at rest until Phase 15 | ❌ | ✅ |
-| File index is unreadable | ✅ | ❌ T3 · ✅ native | ❌ | ❌ | ✅ |
-| Content cannot be modified | ✅ | ✅ | ❌ by design | ✅ | ✅ |
-| Node cannot be impersonated | ✅ | ✅ | — | ✅ | ✅ |
-| Client code integrity | ❌ **T3, accepted** | ❌ T3 | ✅ | ✅ | ✅ |
-| Node content authority | ✅ | ✅ | ✅ sovereign | ✅ | ✅ |
-| Hub cannot obtain the group key | ✅ | ✅ **since H3 closed** — except in `join_policy = "open"` groups, where it can join legitimately | — | — | ✅ |
-| Deleting your account erases you | ✅ hub-side | ✅ hub-side | ❌ **files, pinned identity and bundle stay on the node** — §6.1 | — | — |
-| Your identity keys stay yours | ✅ | ✅ | ⚠️ **offline attack on the bundle they hold** — see §7.1. Succeeds against a weak passphrase, and yields the identity used **on that node only**: nothing on anyone else's | ✅ | ✅ |
-
-**The claim this project can make:** *the hub cannot read your content unless it ships you
-malicious client code.* Since H3 closed (2026-08-14) that is the **only** remaining path,
-it is not silent, and it does not exist for a native client.
-
-**What changed.** The hub used to be the key directory on the invite path: the inviter
-fetched the invitee's `pk_x25519` from the hub and wrapped the GEK for whatever came back,
-so a hub answering with its own key was handed the group key by an honest member following
-the protocol exactly. That path is gone. The node holds the GEK and wraps it itself, for a
-key the recipient proved possession of over an authenticated channel, bound to an identity
-the operator admitted with a one-time code the hub never sees (§5.5).
-
-**The claim it must not make:** *"everything is encrypted and unreadable by other parties,
-even the hub."* Three reasons, all deliberate:
-
-- **T3 — the hub serves the SPA.** Accepted permanently for browser users (decision D1). A
- hub that ships the code can lift keys from the page regardless of protocol design — and
- can read a pairing code out of it. This is an artifact-level attack, not a silent
- directory lie: the native client (Phase 13) plus reproducible builds (18.7) remove it.
-- **Open-join groups.** Where `join_policy = "open"`, anyone the hub calls a member is
- served the key, so the hub can join through the front door. That is a property of open
- joining, not a defect, and the setting is read from `node.toml` — the hub cannot declare
- a group open.
-- **Members and the operator read everything.** Inherent: "end-to-end" here describes
- *client ↔ node*, never *client ↔ client*.
-
-One boundary deserves naming: an operator hosts your content by design, but they should not
-be able to become *you*. They can still try — a bundle sits on their disk and a weak
-passphrase gives it up — but since 2026-08-14 what it gives up is **the identity you use
-with them**, which unlocks nothing they did not already have. Reading what they host is by
-design; reading what *other* operators host is not, and no longer follows.
-
----
-
-## 3. Transport (replaces v4 §6.4, §6.5, §7.1)
-
-### 3.1 Listeners
-
-| Listener | Role | Status |
-|---|---|---|
-| **WebRTC DataChannel** (aiortc) | Primary, browser **and** native clients | Unified handshake enforced |
-| **QUIC** (aioquic) | LAN, port-forwarded, hub-less `group://` | Unified handshake enforced |
-| ~~TCP + TLS 1.3~~ | — | **Removed** (C6) |
-| ~~HTTP file API~~ | — | **Removed** (C1) |
-
-The HTTP API bound `0.0.0.0` for every group, private ones included, and served the
-Mesh Group Index and raw plaintext files with no authentication. It was deleted rather
-than repaired: it duplicated MNP without any of its controls.
-
-### 3.2 NAT traversal — correction
-
-v4 presented `punch_nat()` as the native-client traversal mechanism. It is not one. It
-is a single UDP probe to one address (`quic_server.py`), with:
-
-- no STUN client (the `aioice` dependency is pulled in by `aiortc` only)
-- no candidate gathering, no candidate pairs, no dual-stack fallback
-- a requirement that the client already know its own external IP:port and connect from a
- fixed source port
-
-It was validated on one ISP and one NAT type. **ICE/STUN is the traversal path** —
-validated across two ISPs, two browsers, IPv4 STUN + IPv6 direct, and 4G CGNAT.
-
-Native clients therefore run `aiortc` in Python rather than reimplementing traversal.
-Every native benefit (local keystore, ChaCha20, Argon2id, Ed25519 auth) comes from being
-native, not from the transport. QUIC is retained for LAN, port-forwarded and hub-less
-access, where a signaling channel is unavailable or unwanted.
-
----
-
-## 4. MNP handshake (replaces v4 §4.2.x handshake description)
-
-One implementation: `meshbay_common/handshake.py`.
-
-```
-client → node handshake {token, group_id, nonce_c}
-node authorize_token() JWT · scope · denylist · group_id · membership · hosting
-node → client handshake_challenge {nonce_s, node_pk}
- ── pre-proof window: keypair/GEK bundle fetch, join ──
-client → node handshake_response {proof}
-node verify HMAC(GEK, client transcript)
-node → client handshake_ack {proof, sig, node_pk, is_node_admin}
-client verify HMAC(GEK, node transcript) + Ed25519(node_pk)
- and that ack.node_pk is the key announced above
-```
-
-`node_pk` is announced in the challenge because joining needs it before the ack: a
-first-time member signs a transcript naming this node (§5.5) and has no GEK to complete a
-handshake with. It is unverified at that point and is never a substitute for the ack — the
-ack proves possession and signs the transcript, and the client refuses a peer whose key
-changed between the two. A wrong value there only makes the node's own verification fail.
-
-**Refusals carry a code**, not only a sentence. `not_a_member` in particular is usually a
-token issued before the person was added to the group — `groups` is baked in at sign-in and
-the hub pushes no updates — so the client refreshes once and retries rather than telling
-someone who was invited a minute ago that they are not a member.
-
-### 4.1 Transcript
-
-```
-"meshbay:mnp:handshake:v1" ‖ len‖role ‖ len‖group_id ‖ len‖nonce_c ‖ len‖nonce_s ‖ len‖binding
-```
-
-Every field length-prefixed; the role bound in so a client proof cannot be replayed as a
-node proof. v4's transcript was `nonce ‖ offer_fp ‖ answer_fp` — bare concatenation, and
-a missing fingerprint silently degraded it to nonce-only, dropping MitM detection.
-**An absent channel binding is now refused.**
-
-### 4.2 Channel binding
-
-| Transport | Anchor |
-|---|---|
-| WebRTC | both DTLS certificate fingerprints |
-| QUIC | SHA-256 of the server certificate. aioquic exposes no RFC 5705 exporter; the client's access to the peer certificate is a guarded private attribute, and on a **resumed** session — which carries no certificate — the anchor travels with the session ticket |
-
-### 4.3 Authorization rules
-
-- `group_id` is **mandatory**. Omitting it previously skipped the membership check and
- fell back to the node's first group.
-- Node-scoped daemon tokens are **refused** on the client path.
-- The denylist is consulted for user, `jti` **and** group.
-
-### 4.4 Mutual authentication
-
-The node proves GEK possession over a client-chosen nonce **and** signs the transcript
-with its long-term key. Previously the client received an unverifiable `node_pk` and
-trusted `is_node_admin` from whoever answered — so a peer that had hijacked signaling
-could serve a forged index, forged chat history and forged permissions.
-
-The client also **pins** `pk_node` per node on first use (11.5.8) and refuses a changed
-key outright, with a deliberate reset path in Settings for a legitimate rotation.
-
----
-
-## 5. Node authority (replaces v4 §4.2.x)
-
-### 5.1 Privileged operations
-
-Destructive and privileged operations require an Ed25519 signature over a structured
-transcript, never a JWT — the hub controls JWT issuance and can therefore never
-establish node-level authority.
-
-```
-"meshbay:admin:v1" ‖ len‖op ‖ len‖node_pk ‖ len‖group_id ‖ len‖subject ‖ len‖nonce ‖ len‖ts
-```
-
-TTL 120 s. The client reconstructs the transcript from announced fields and **refuses to
-sign** if the operation or subject does not match what the user requested. v4's challenge
-was 32 opaque random bytes signed blind — an unbound signing oracle.
-
-| Operation | Authority |
-|---|---|
-| `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. 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
-addressed to the operator. Since the operator's X25519 public key is published in
-`handshake_ack`, any member could wrap a key of their choosing for it and take over the
-group. It was first gated behind an operator signature, then removed outright when the
-node took over wrapping (§5.5) — the message has no remaining purpose, and a path that
-does not exist cannot be mis-authorized.
-
-**Who can invite.** Still the node operator only: `invite_create` is verified against the
-operator keys in the node's roster. A group admin who does not run the node cannot admit
-anyone until delegation ships (§6.2 of `invite-pairing-v1.md`, deferred by decision
-2026-08-13). What *did* change is the timing: the operator issues a code and is then out
-of the loop, so nobody has to be online when the invitee actually joins.
-
-### 5.2 Uploads
-
-Confined to `shared_root/uploads/`, filename allowlist, no overwrite (a colliding name
-gets a suffix), chunk ordering enforced, size capped. Chat attachments land there too, so
-there is one directory an operator can look at, empty, or back up. Nothing derived is
-written beside them: a chat thumbnail is the decrypted file scaled by the browser, so the
-node stores no second copy of an image it already holds.
-
-The destination has moved twice — `shared_root/` → `.uploads/{user_id}/` → `uploads/` —
-and the four protections above were carried across each move deliberately. The original
-version accepted a client-chosen path and overwrote whatever was there, which defeated
-deletion authorization as well: overwriting a file made the attacker its recorded
-uploader, and therefore the party allowed to delete it (C5a).
-
-### 5.2b Removing a directory
-
-Creating a directory is not privileged (§5.2); removing one is, and it is refused
-unless the directory is empty. The emptiness rule is the safety property: whatever
-the caller intended, and whatever the browser sent, this operation cannot destroy
-content. It is checked twice — once before the challenge is issued, and again
-after the signature comes back, because a file can land during the round trip to
-the operator's browser.
-
-Authorized by `_verify_admin_sig` alone. A file also accepts its uploader's key,
-since the node recorded who put it there; a directory has no uploader, so there is
-no second key to accept.
-
-Archives are not the node's business: a member downloading a folder as a zip
-fetches the same encrypted chunks as any other download and assembles the archive
-in the browser. The node serves no bundles, holds no temporary files, and cannot
-be asked to compress anything — one fewer place where a request turns into work
-on someone else's disk.
-
-### 5.3 Operator interface
-
-Two personas, and they need different tools:
-
-| Operator | Reaches the node via |
-|---|---|
-| Desktop | the local admin UI in their own browser |
-| **Headless / SSH** | the CLI — `status`, `ui`, `gek-init`, `operator pair`, `member list\|invite\|revoke\|unpin` |
-
-The CLI is the primary interface for servers, which is the normal deployment.
-`status` deliberately reads the keystore and config directly so it works while
-the daemon is stopped — the state an operator is most often in, since the daemon
-will not stay up before its key is linked or before a group exists. Anything the
-UI can do should eventually have a CLI equivalent (Phase 14). Inviting, revoking and
-pairing now have one; deleting a file is the remaining browser-only operation.
-
-### 5.4 Local admin UI
-
-Loopback plus a **per-run session token** (`?t=` or `X-MeshBay-Token`), printed at
-startup. "Localhost only" is not authentication: any local process can reach it, as can
-a page in the operator's browser via DNS rebinding — and this API re-initialises group
-keys, issues invitations and reads the audit log. All interpolated values are
-HTML-escaped — filenames chosen by members, and usernames that originate at the hub;
-CSP contains exfiltration but cannot prevent injected inline script, so escaping is the
-actual fix.
-
-### 5.5 Admission and key delivery (closes H3)
-
-The node decides who receives the group key, and produces every copy of it itself.
-
-```
-grenet (SSH) meshbay-node member invite bob → CODE R3H8-TB6V
- (or the same from the SPA's Members tab, signed by the paired browser)
-grenet sends the code to bob out of band
-bob opens the group; the client holds no GEK
-bob → node join_request {pk_ed25519, pk_x25519, code, sig} ← pre-proof window
-node code valid for this account → pin the identity, admit to the group
-node → bob the GEK, wrapped for the X25519 key bob just proved he holds
-```
-
-Four properties, each load-bearing:
-
-1. **No public key is ever fetched from the directory.** The invitee's key arrives from
- the invitee, signed by their identity key in the same transcript (`meshbay:join:v1`),
- so the identity key vouches for the encryption key. This is the H3 fix.
-2. **The code binds the key to the account**, and the hub never sees it. 40 bits,
- single-use, one account, node-wide lockout on repeated failures. Codes live 7 days
- for invitations and 24 h for operator pairing, both configurable.
-3. **The node's roster is the authority**, not hub membership. A hub that invents an
- account, adds it to a group and mints it a token gets `not_authorized_for_group`.
- Exception by configuration: `join_policy = "open"` pins the first comer (TOFU) and
- serves them — read from `node.toml`, never from the hub.
-4. **Wrapping happens on every connection**, so key rotation propagates by itself and
- revocation actually takes effect. No member bundle is stored; nothing outlives the
- decision to stop serving someone. (Rotating the GEK after a revocation is still
- required — the ex-member holds the old one.)
-
-`gek-init` no longer pre-wraps the key for anyone but the node's own keystore. It used to
-fetch every member's public key from the hub and wrap for what came back, which was H3
-with the node as the victim rather than the inviter.
-
-Node authority is established the same way, once per node: `meshbay-node operator pair`
-prints a code, the operator types it into their browser, and the node pins that identity
-key. It is never learned from the hub — a hub able to name the operator's key could
-install itself as node administrator (finding M3, which the old keystore auto-pin left
-inert and failing closed).
-
----
-
-## 6. Hub (replaces v4 §4.1)
-
-### 6.1 Role — chosen, not minimal
-
-Hub minimization was considered and **deferred** (decision D4). The hub keeps serving
-the web UI and remains in the trusted path by choice. This section describes what it
-*does*, not an aspiration.
-
-**Stores:** accounts (username, encrypted email, status, role), group registry and
-membership, IP logs (1 year, legal), node registrations, refresh tokens, notifications,
-moderation blocklist.
-
-**No longer stores user identity keys** (2026-08-14). `users.pk_ed25519` and
-`users.pk_x25519` are dropped, `PUT /me/keys` is gone, and `/pubkeys` returns an account id
-and a node's linking key — nothing to wrap for. Tokens carry no `pk_user` claim either: the
-node used to record it as the uploader's identity, which let whoever issued tokens decide
-who could delete a file.
-
-**Does not store:** file content, file names, private-group indexes, message content,
-private keys, GEK bundles, keypair bundles, node IPs beyond ephemeral signaling.
-
-**Knows, unavoidably:** who is a member of what, when nodes connect, when a chat message
-was posted and by whom, and content hashes for **public** groups only.
-
-The "by whom" is a `user_id`, not a display name, since 2026-08-15. The node has to name
-the author for the hub to skip them when creating notifications — it used to name nobody,
-so the hub substituted the node's own account and told every author about their own
-message. The exchange is `chat_notify` over the node's hub socket and it carries no
-content, but it is a stable identifier: the hub can build who-talks-in-which-group-when
-without reading a word. Minimizing it was in the dropped phase, and it stays a known
-metadata leak rather than a solved problem.
-
-**Erases on request, and what survives.** A user can delete their own account from
-Settings (passphrase re-entered — a live token may be a borrowed laptop), and a hub admin
-can delete one. The row is tombstoned rather than dropped: username released, email and
-password hash cleared, node linking key dropped, memberships, notifications and refresh
-tokens removed, active access tokens refused at once by status check rather than left to
-expire. Two things survive on purpose. The IP log is kept for its legal retention period
-and stays attributable, since detaching it would keep the data and lose the only thing it
-is for — the name is copied onto those rows as the account goes, since the join that used
-to supply it would answer with the tombstone. And **nothing on a node is touched**: files, the pinned identity and the keypair
-bundle live on machines the hub does not command, which is the same sovereignty that makes
-§5.5 work. Deleting the hub account is not an erasure request to the operators who host
-you — the operator interface (§5.3) is where that happens. Deletion is refused outright
-while the account still owns groups, rather than cascading into other people's data.
-
-**No longer decides:** who holds the group key. The hub still publishes public keys, and
-they are still used to look up an account by name — but nothing wraps a key for a key
-that came from there. Hub membership lets someone *reach* a node; the node's roster
-decides whether it wraps anything for them (§5.5). A pairing code never touches the hub.
-
-### 6.2 Node registration
-
-Registration on `/v1/nodes/ws` requires a node-scoped token, verifies `Node.user_id`
-against the token subject, and derives group claims from the database — a node may
-narrow the set to what it hosts but cannot widen it. It previously took `node_id` and
-`group_ids` verbatim from the client's first message, letting any user hijack a victim
-node's signaling and impersonate it to browsers.
-
-### 6.3 Signaling
-
-Rate-limited, SDP size bounded, capped per user, and the caller must share an **active**
-group with the target node. Previously reachable by any authenticated user for any node,
-making a third party's machine allocate peer connections on demand. `peer_ip` in the NAT
-punch request must match the caller's source address.
-
-### 6.4 Client addresses
-
-`X-Forwarded-For` is honoured only from a trusted proxy, rightmost hop. It was trusted
-unconditionally, so anyone could forge the IP written to the compliance log.
-
----
-
-## 7. Cryptography (amends v4 §6)
-
-Unchanged: GEK wrapping (ECIES, X25519 + HKDF + AEAD), per-chunk key derivation, domain
-separation, on-the-fly encryption.
-
-| Parameter | Value |
-|---|---|
-| Node keystore KDF | Argon2id **256 MB**, iterations 3, lanes 4 — parameters recorded per envelope so they can be raised without orphaning existing keystores |
-| Hub password verifier | Argon2id 256 MB over a PBKDF2-derived `auth_key` |
-| Browser bundle key | **Argon2id 128 MB, t=3, p=1** (was PBKDF2-SHA512 600k until 2026-08-14) |
-| Browser `auth_key` | PBKDF2-SHA512, 600 000 iterations — the hub's Argon2id verifier is what protects it at rest |
-
-### 7.1 The keypair bundle, and what it is worth to an attacker (C4)
-
-A bundle carries **one node's** identity keys, encrypted under the owner's passphrase, and
-is stored on that node. It is what lets a second browser open the same account there — the
-ordinary expectation, and the only mechanism available to a browser that keeps nothing
-durable of its own.
-
-The adversary is concrete: an operator holding their own node's disk, attacking offline at
-their leisure.
-
-**What cracking one yields.** The identity that person uses **on that node** — where the
-operator already holds the content, the index and every byte they serve. It is not a key
-anywhere else: each node gets its own, generated the first time its owner joins it, and a
-key one node pinned is a stranger to the next (which asks for a code, like any first
-contact). Until 2026-08-14 a single identity was copied to every node joined, so one crack
-yielded content on *other* operators' nodes and the ability to sign as that user anywhere —
-that was the part worth attacking, and it is gone.
-
-Two smaller consequences fall out. Two operators can no longer tell they host the same
-person by comparing keys. And the hub publishes no user keys at all now (§6.1), so there is
-no directory left to substitute from.
-
-**Why Argon2id.** PBKDF2 is compute-only, which is exactly what a GPU is good at. Measured
-on the development machine: PBKDF2-SHA512 600k costs 241 ms per guess on one core,
-Argon2id 128 MB/t=3 costs 88 ms — the defender pays *less* — but only one of them forces
-an attacker to find 128 MB per guess.
-
-The honest size of the gain: on a single high-end card the ceiling moves from roughly 8k
-guesses/s to roughly 2k, a factor of four, not a thousand. What it really buys is the cost
-of scale — 128 MB per lane caps a 24 GB card near 187 concurrent guesses, and makes custom
-hardware unattractive, where SHA-512 silicon is cheap.
-
-**The passphrase decides this, not the KDF.** At ~2k guesses/s, a dictionary-and-rules run
-of 10⁹ candidates — which covers a large share of human-chosen passwords — takes about six
-days on one card. Four random words (~52 bits) outlasts the sun. No parameter choice saves
-a weak passphrase; it only moves it from hours to days. The client therefore refuses
-passphrases below 12 characters and ~60 estimated bits, and that floor can only live in the
-client: with the password split (T1) the hub never sees a passphrase and cannot enforce
-anything about one.
-
-**Operational notes.**
-
-- Argon2id runs in WebAssembly, vendored under `static/vendor/` with its provenance —
- the CSP forbids external hosts, and 12.2 must keep `wasm-unsafe-eval` in `script-src`.
-- Two implementations exist (the browser's WASM, `argon2-cffi` in QE). A parity test holds
- them byte-identical, because a disagreement would not look like an error — it would look
- like an account nobody can open.
-- Bundles written under the old KDF carry no marker, are still readable, and are
- re-encrypted the first time their owner's browser backs them up.
-- Cost is paid **once per sign-in**: 650 ms for the bundle key, plus 239 ms for `auth_key`.
- Opening a group, downloading, streaming and reloading the page all reuse it — the key
- lives in IndexedDB for the session.
-- The pre-proof window that serves bundles is still bounded (4 fetches) and audited.
-
-C4 is **reduced, not closed**: bundles still sit on disks their owner does not control, and
-a weak passphrase still gives up the key for that node. It closes when the native client
-stops storing them remotely (Phase 13.3) — the material belongs on the user's own device,
-not on the hub *or* on other people's nodes.
-
----
-
-## 8. Clients (replaces v4 §4.3)
-
-### 8.1 Two clients, deliberately
-
-| | Hub-served web SPA | Native desktop client |
-|---|---|---|
-| Distribution | Served by the hub | Installed, signed release |
-| Code integrity | **T3 accepted** — the hub can inject | Detectable *if* reproducible builds ship |
-| Key storage | IndexedDB / sessionStorage | OS-protected local keystore |
-| Crypto | WebCrypto (AES-GCM) | Full (ChaCha20, Argon2id) |
-| Transport | WebRTC | WebRTC **+ QUIC** |
-| Positioning | **Convenience tier** — zero install | Recommended for sensitive use |
-
-**Several browsers, one identity per node.** A browser keeps nothing durable the user
-controls, so the identity it creates for a node is left with that node, encrypted under the
-passphrase. Any other browser recovers it there with the passphrase alone: same identity on
-that node, same pin, no second code. Joining a *different* node creates a different key and
-needs that operator's code, which is the first contact it has always needed.
-
-This is what makes the product behave the way people expect, and it is also finding C4
-(§7.1) — with a blast radius of one node. The native client removes the need for it
-entirely, which is a large part of why it exists.
-
-The SPA is not deprecated. It is the zero-install path and it stays. It must be labelled
-honestly: `/app/` states that the hub serves this code, and the docs never claim
-end-to-end *integrity* for that path.
-
-### 8.2 Native client
-
-**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
-
-**Phase 11.5 is complete.** All six critical and all seven high findings from
-`second-review.md` are closed, bounded, or deferred by explicit decision.
-
-### Closed since this document was drafted
-
-| # | Item | Closed by |
-|---|---|---|
-| **C6** | GEK proof on the QUIC path | QUIC now runs the same challenge/response as WebRTC — client nonce, role-bound transcript, GEK proof, and the node proving itself with a GEK proof plus an Ed25519 signature |
-| 11.5.6 | QUIC channel-binding anchor | Certificate hash. Server reads its own certificate from disk; the client's access to the peer certificate is guarded and fails loudly if aioquic moves it. **A resumed TLS session carries no certificate**, so the anchor travels with the session ticket — sound, because the ticket is derived from the handshake where the certificate was presented |
-| 11.5.8 | `pk_node` TOFU pinning | Pinned per `node_id` on first handshake; a change is **refused**, with a deliberate reset in Settings |
-| **M8** | Node announce proof-of-possession | Announcer signs `meshbay:node_announce:{user_id}:{pk_node}:{timestamp}`; re-announcing updates in place |
-| **H3** | Hub key substitution | **Closed 2026-08-14** by the invite redesign (§5.5), not by safety numbers: no public key is fetched from the directory at all, so there is nothing to substitute. See `docs/invite-pairing-v1.md` |
-| **M3** | Node authority inert as shipped | Closed with it — authority comes from the roster, established locally by pairing |
-
-### Still open
-
-| # | Item | Status |
-|---|---|---|
-| 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 |
-| **T3** | Hub serves the SPA | **Accepted permanently** (D1). Now the *only* way an active hub reads content — and it can also read a pairing code out of the page it served. Removed for native clients by Phase 13 + reproducible builds (18.7) |
-
-### Scope note on pinning
-
-With C6 closed, `pk_node` pinning is defence in depth rather than the primary control: a
-substituted node already fails the GEK proof. Pinning covers the case where an attacker
-*holds* the group key — an ex-member, or a leaked GEK — and swaps the node underneath,
-which the proof alone cannot distinguish from the genuine node.
-
-### Sender Keys — scope correction
-
-Sender Keys protects chat from someone holding the node's disk who is **not** a group
-member — a seized machine, a hosting provider, a compromised node. It does **not**
-protect chat from the node operator, who is a group member and therefore a legitimate
-recipient. Distributing sender keys under the GEK would make the encryption decorative,
-since anyone with the GEK would recover every sender key.
-
----
-
-## 10. Testing posture
-
-The pre-remediation suite passed 209 tests while every finding in the review was live. It
-tested that features work, and in doing so **pinned four vulnerabilities in place** as
-expected behaviour: GEK auto-activation, the transport-wide chat store, the blind admin
-challenge, and private-group hashes registered with the hub. A refactor that accidentally
-fixed one would have been reported as a regression.
-
-Security tests are therefore written as **negative assertions** — "this attack does not
-work" — and verified to fail against the pre-fix source before being trusted.
-`test_security_regressions.py`, `test_node_ws_auth.py` and `test_handshake.py` hold them.