# MeshBay — Architecture Draft v5 > 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`. > 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 > 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 | | ~~`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 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. 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. --- ## 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 in Phase 13.3 when the native client stops storing them remotely | | — | 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.