# 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. > > **Amended 2026-08-28** with three changes that post-date that discussion: the hub gains a > runtime instance-policy store (public groups can be switched off hub-wide), group names > become unique per owner account (`name@owner`), and chat gains link previews. See §1 > rows 11–13 and §§2.8–2.10. Wire versions at that point: MNP 0.12, MHP 0.1; packages 0.9.0. > > **Amended 2026-08-30** with WebRTC transport resilience: STUN server fallbacks on both > browser and node, configurable on the node side (Node page, CLI, `node.toml`); ICE > interface filtering exposed on the Node page. See §1 row 15 and §2.12. > > **Amended 2026-09-01**: §2.11/§2.12 corrected to match what was built — the Node page > reaches the daemon over its token-gated loopback HTTP API, not MNP. The MNP `node_*` > message types remain, unused, for a possible future browser-only path. See > `docs/refactor-node-ui.md`. > > 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 | | `docs/apps.md` | the group UI's plug-in architecture — as built, and how to add an application | | `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 | | 9 | Node authority | The operator decides **which directories accept uploads**, per root. Signed MNP op, stored on the node, enforced by the node — the hidden button is a courtesy, the refusal is the control. **Superseded 2026-09-06** by `docs/refactor-groups.md` §1.1: the group-wide `member_upload` switch this section described is replaced by RO/RW per root, and the "everyone but the operator" carve-out is gone | §2.1b | | 10 | Client | A group's UI is a **set of pluggable applications** (Chat, Files today), not one monolithic page. Which are shown is a per-group, operator-signed setting on the same pattern as change 9 | §2.7 | | 11 | Hub role | The hub gains a **runtime instance-policy store** (`hub_settings`). First policy: an admin switches **public groups off** hub-wide, enforced server-side on every hub-mediated path. `suspend` vs `revoke` on a group are now written down as the distinct things they are | §2.8 | | 12 | Group registry | A group name is **unique per owner account**, not globally; the group's identity is still its UUID. Listed everywhere as `name@owner` | §2.9 | | 13 | Enrichment | **Chat link previews** — the node unfurls a pasted URL into an OpenGraph card. A new instance of the §2.7 "node on demand, asking device caches, nothing durable" rule; new SSRF surface, gated. MNP 0.12 | §2.10 | | 14 | Node page | The Node page (D5) **exposes five `[node]` settings** — TTLs for invitations, pairing and device requests, the concurrent stream cap, and the transcode toggle. Editable from the panel, persisted in both `roster.db` and `node.toml` | §2.11 | | 15 | Transport | WebRTC ICE gathering uses **several public STUN servers** (three as of 2026-09-04) instead of one, configurable on the node side. ICE interface filtering is exposed on the Node page. Both follow the §2.11 persistence pattern | reliability — single-server STUN was a silent SPOF, §2.12 | --- ## 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`. **Each root is read-only or read-write**, and an upload goes to the folder the sender is looking at, inside a writable root. If that root is unavailable the upload fails with a stated reason and never falls back to another; if the group has no writable root, uploads are refused rather than guessed. (Amended 2026-09-06 — the original text designated *one* root as the upload destination, and the client named none. See `docs/refactor-groups.md` §1.1.) - **There is no `uploads/` quarantine directory any more** (2026-09-06). It was the last of v5's, the per-user layer having gone on 2026-08-14, and it went for the same reason: a folder appearing beside the operator's library because somebody sent a file is the node deciding how their disk is arranged. **What made the quarantine worth having was never the subdirectory** — it is the filename allowlist, the size cap, the chunk ordering and the no-overwrite rule, and all four are unchanged. The client now names the destination folder, which is safe for one reason and only one: it is resolved through `RootSet.resolve()`, which refuses `..`, absolute segments and anything escaping its root, symlinks included. A member answers "which of this group's folders", never "which path on the operator's disk". - **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.1b §5.2 Uploads — the operator may close them > **Superseded 2026-09-06.** `member_upload` is gone; the mechanism is `writable` on > each root. What the three load-bearing properties below say is *unchanged* — read > "the root's `writable` flag" for "`member_upload`" and every word of them still > holds, which is why they are kept rather than deleted. What did change: > > - **It is per root, not per group.** A group can publish one library read-only and > accept uploads into another, which the single switch could not express. > - **There is no carve-out for the operator.** Read-only means read-only for > everyone, because a published library that quietly accepts writes from whoever > holds admin authority is not one. The paragraph below justifying the setting by > "the only way to get a curated library was to designate no upload root at all, > which refuses the operator too" is therefore the reasoning that was reversed: that > *is* the model now, and refusing the operator is the point rather than the defect. > - **The client names the destination root.** With several writable roots the node > cannot choose without guessing, and a guess sends a member's file to a disk the > operator did not intend. It names a root, never a path; everything below the root > is still decided by the node. > - The signed op is `OP_ROOT_UPDATE` (plus `OP_ROOT_EJECT` / `OP_ROOT_PLUG`) rather > than `OP_MEMBER_UPLOAD`, and the flags live in `node.toml` — they are > configuration — while the *ejected* runtime state lives in `roster.db`. > `member_upload` survives on the handshake ack alone, computed as "any root is > writable", for MNP 1.0 clients that read no other field. > > See `docs/refactor-groups.md` §1.1 and §1.5b. New. A group where every member may add files is the default and stays the default; some groups want a library the operator curates, and until now the only way to get one was to designate no upload root at all, which refuses the operator too. `member_upload` is a per-group setting, and three things about it are load-bearing: - **It lives on the node**, in `roster.db`, not in `node.toml` and not on the hub. Not the hub because a hub that decides who may write to someone else's disk has authority over that node, which is the arrangement this design exists to avoid (change 5). Not `node.toml` because that file is hand-written, full of comments recording decisions, and `ops.py` deliberately appends to it rather than round-tripping it through a writer — a setting changed from a panel must not rewrite the operator's file, and must not need a restart. - **Changing it is a signed operator instruction** (`OP_MEMBER_UPLOAD`, MNP `member_upload`), on the same path as removing a member. An unsigned one would let any member turn it back on, which makes the control a suggestion. The transcript's subject is `on` or `off` — what the operator is shown before signing has to name the outcome, not the operation. - **The node enforces it**; the interface merely stops offering it. `handshake_ack` carries `member_upload` so a client knows whether to draw the Upload button and the chat paperclip, and the node broadcasts `member_upload_ack` to everyone connected when it changes. None of that is the control: a member on an old tab, or one speaking MNP directly, is refused by the node with `member_upload_off`. **Absent means allowed**, at every layer — no row in `group_settings`, no key in the group context, no field in the ack. A node or client that predates the setting behaves exactly as it did, and an upgrade never silently closes a group. The operator is always exempt. Turning it off otherwise locks them out of their own node, with a config file and a restart as the only way back. ### 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, the moderation surface, and > instance policy (`hub_settings`, §2.8) — 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. ### 2.7 The group UI becomes a set of applications New (2026-08-23). A group had two fixed tabs, Chat and Files, both defined inside one monolithic `static/app.js`. Two things motivated splitting it before adding to it: the file had become the thing every unrelated change touched, and the roadmap wants three more group-level surfaces — a Netflix-style video browser, a Spotify-style music player, a photo album viewer. None of the three need a protocol change: the node's indexer already classifies files as `video`/`audio`/`image`, and they would read the same `index_sync` / `file_req` / `stream_req` messages Files and the video player already use. What they need is somewhere to live that is not one file, and a way for an operator to turn one off. **The shape.** `group-page.js` is now the shell: the WebRTC connection, the file index, the tab bar, and the video/preview modals, none of which are Files- or Chat-specific. `apps.js` holds the registry — `[{ key, icon, labelKey, Component }]` — and every registered component receives the same props object from the shell, spread rather than hand-listed, so adding an app changes no code in the shell itself. Chat and Files each moved to their own file (`chat-app.js`, `files-app.js`) to prove the mechanism; nothing else exists yet. **Enablement is the same pattern as change 9, on purpose.** `apps_enabled` is a per-group setting: lives on the node (`roster.db`, not the hub, not `node.toml`, for the identical reason `member_upload` does — a hub or a config file that decided this would have authority over the node), changed by a signed operator instruction (`OP_APPS_ENABLED`), enforced by the node refusing to store an unrecognised or empty set rather than by the client's honesty. **Settings itself is not an app** and cannot be disabled — the one way back if everything else were turned off. **What this does not change.** No new server state on the hub (change 5 stands unmoved: the enabled-apps set is group-related state, and it lives on the node like everything else in that category). No new adversary or trust boundary — this is a display policy, not a key or a permission over content; a member whose client shows a hidden tab's data anyway would still be a member the node already serves that data to. Full detail — the props contract, the file layout, and a checklist for adding a new application — is `docs/apps.md`, on the same basis `docs/desktop-client-v1.md` holds the desktop client's detail: this document states what changed and what holds, not how to build on it. ### 2.8 §6.1 Hub role — instance policy, and public groups can be switched off New (2026-08-28). The hub had no runtime-configurable setting: everything was `hub.toml` (read once, restart to change) or code. It now has `hub_settings`, a key/value table an admin edits from the panel. The first entry is `allow_public_groups`. **This does not contradict change 5 / §2.5.** `hub_settings` is *instance policy, not group content*: it says how this hub behaves, and holds nothing about any group's files, index, membership or keys. "Group-related server state lives on the node" is untouched. **What "off" does** — server-side, on every path the hub mediates, read live: - `create_group` refuses `visibility=public` — staff included; the way back is to re-enable it, not to slip past - the public directory (`GET /v1/groups`) returns nothing, local **and** federated - open-joining a public group is refused - `group_online_nodes` hands a non-member no node to connect to - `webrtc_offer` drops its "the node hosts an open-join group, admit anyone" fallback - the MHP directory export advertises nothing to peers Flipping it back restores every path. **Existing members of a group that predates the switch keep their membership row and their access** — this is a directory-and-brokering control, not a purge. **What it is not.** The node still reads `visibility` and `join_policy` from its own `node.toml`, never the hub (`invite-pairing-v1.md`). A node whose operator set `join_policy = "open"` still TOFU-pins and serves whoever reaches it directly over MNP. The switch removes the hub-provided ways to *find and reach* such a node; it is **not a remote kill**. For a hard stop on one group, `POST /v1/admin/revoke` is the tool. **`suspend` vs `revoke`, made explicit** — the two moderation verbs on a group were never written down as distinct, and they are: | | `suspend` (`PATCH /v1/admin/groups/{id}`) | `revoke` (`POST /v1/admin/revoke`) | |---|---|---| | Hub DB | `status = "suspended"` | `status = "revoked"` | | Node | nothing | signed revocation broadcast → denylist + live sessions dropped, **persisted across a node restart** | | Reversible from the panel | yes | no | The client shows the real state now (`Group is revoked`, not a blanket `Group is suspended`). ### 2.9 §6.1 Hub role — group names unique per owner, shown as `name@owner` New (2026-08-28). "The name is unique" was an unenforced expectation — the `groups` table had only a non-unique index. Two changes: - A functional unique index `uq_groups_owner_name (admin_id, lower(name))`. Uniqueness is **per owner account**, case-insensitively, trimmed — two different owners may each have a `photos`. `create_group` returns 409 on a clash, with an `IntegrityError` backstop for the race. The migration pre-flights and **aborts listing the offending pairs** rather than renaming anyone's group. - **The group's identity is still its UUID.** `#/group/`, `_node_groups`, `node.toml [[groups]] id`, membership — all unchanged. `name@owner` is a *label plus a create-time check*, not an addressing scheme. `owner_username` (derived from `admin_id`, nothing stored anew) rides `/v1/groups/mine`, `GET /v1/groups` (local rows), the join response and `/v1/admin/groups`. The SPA renders it under the name, smaller and grey, prefixed `@`. The handle is **hub-local**: `foofoo@grenet` on two federated hubs are different groups, and a federated row shows `@`, not an account. Design record and the decisions behind it: `~/next/groupnames.md` (kept out of the repo). ### 2.10 Chat link previews New (2026-08-28). A URL pasted in a group's chat unfurls into an OpenGraph card — title, description, site name, image. **Whose request it is: the node's**, never the browser's or the hub's. The browser cannot (a strict `img-src`/`connect-src` and CORS), and a direct fetch would leak every reader's IP to the linked host on each render. The hub must not touch group content (§2.5). The node already fetches third-party metadata for the Videos and Music apps, over the same authorised path. This is a new instance of the §2.7 / draft-v5 §5.2 rule: **the node produces enrichment on demand and the asking device caches; neither node nor hub keeps durable derived state.** The card text lives in a bounded in-memory TTL cache on the node; the OG image rides the existing `media_cache` thumb store (blake3-keyed) and the browser fetches it over the normal `file_req` path, exactly like a poster. Nothing reaches the hub — this is node↔ browser P2P. **New adversary surface: SSRF.** The URL is a *member's* choice and it triggers an outbound HTTP request from the operator's machine. `linkpreview.safe_url` is the gate — http(s) only, no credentials, and every resolved address must be globally routable (loopback, private, link-local, multicast, reserved and cloud-metadata ranges refused); redirects are followed by hand so each hop is re-checked. Residual, documented in the module: a name that resolves clean at check time and internal at connect time (rebinding), closed properly by pinning the checked IP — deferred. **MNP 0.12** — `link_preview_req` / `link_preview_resp`, additive: an older node logs "unknown type" and the client shows the bare link. ### 2.11 Node page — `[node]` settings exposed to the operator New. The Node page (D5) shows groups, roots and the roster; it does not show the daemon's own tuning. Five `[node]` settings from `node.toml` are worth surfacing there — they affect what the node does, not only how it starts, and their current value is invisible until something goes wrong (an invitation that expired silently, a viewer told "server busy" with no knob to turn). | Setting | Type | Default | What it controls | |---|---|---|---| | `invite_ttl_hours` | positive int | 168 (7 days) | How long a member invitation code remains valid. An invitation travels through a human conversation and may be read days later; too short and the recipient finds a dead code with no message telling them what happened | | `pair_ttl_hours` | positive int | 24 | How long an operator pairing code remains valid. Shorter than an invitation because the code is typed during the SSH session that printed it | | `device_request_ttl_minutes` | positive int | 60 | How long a device-add request may wait for approval from an existing device. Comfort rather than security: the code is bound to the requesting keys by its hash, so a longer window widens nothing an attacker can use | | `max_concurrent_streams` | positive int | 8 | Maximum simultaneous video streams. One ffmpeg process runs per viewer; it remuxes rather than re-encodes so CPU cost is low, but each uses about 50 MB of memory. When reached, new viewers are told the server is busy. Override: `MESHBAY_MAX_CONCURRENT_STREAMS`. Zero or negative values are ignored with a warning | | `transcode_incompatible_video` | bool | true | When true, HEVC and other browser-incompatible video codecs are transcoded to H.264 during streaming so browsers can play them. Unlike remuxing, this costs real CPU per concurrent viewer. Set to false only when all viewers are known to decode the source codec natively | **Editable from the panel, persisted in both `roster.db` and `node.toml`.** The panel writes to both stores simultaneously: `roster.db` for immediate effect (no restart needed), `node.toml` so the value survives a DB wipe or a fresh install. On startup, `node.toml` is read as today; if `roster.db` holds an override for a key, it takes precedence. A manual `node.toml` edit plus a reload overwrites the roster value. The TOML write is a targeted line replacement (the same technique `ops.py` uses for roots), not a round-trip through a writer — comments and formatting survive. **Transport — the loopback API, not MNP.** The Node page talks to the daemon over its token-gated `127.0.0.1` HTTP API (`GET`/`PUT /api/node-settings`), the same API the CLI uses. `GET` returns the five effective values (roster override if present, config default otherwise); `PUT` writes changes and returns the updated keys. Both require the per-run session token, which the desktop client's main process reads from the daemon's data directory and the renderer never sees. An earlier draft of this section specified an MNP path (`node_status_ack` carrying a `settings` object, `node_settings_set` / `node_settings_set_ack`). Those message types exist in `protocol.py` and are handled in `webrtc_server.py`, but the Node page does not use them: the loopback API needs no signaling round-trip, no GEK and no joined group, and works while the node is still starting. The MNP handlers are reserved for a possible future browser-side (no desktop client) path. See `docs/refactor-node-ui.md`. ### 2.12 WebRTC transport — STUN fallbacks and ICE interface filtering The WebRTC transport relied on a single hardcoded Google STUN server (`stun:stun.l.google.com:19302`) on both sides. If that server was unreachable or slow, ICE gathering waited the full 4-second timeout before completing — a silent single point of failure that added seconds to every connection. **Three default servers, two providers deep.** ``` stun:stun.l.google.com:19302 stun:stun1.l.google.com:19302 stun:stun.cloudflare.com:3478 ``` Both browser (`transport.js`) and node (`config.py`) carry the same three defaults. The two sides gather ICE candidates independently and exchange them via SDP through the hub — they do not need to use the same STUN server, and neither learns which server the other used. > **Amended 2026-09-04:** a fourth default, `stun:stun.services.mozilla.com:3478`, > was dropped — Mozilla retired the service and the name no longer resolves, so > every gather waited out its DNS timeout. Google and Cloudflare still give > two-provider coverage. **Node-side configuration.** The node's STUN list is editable three ways: the Node page (a dedicated section with add, remove, reorder, save and reset-to-defaults), the CLI (`meshbay-node stun list|add|remove|reset`), and `node.toml` (`stun_servers` under `[node]`). Persistence follows the §2.11 pattern: `roster.db` for immediate effect, `node.toml` so the value survives a DB wipe, roster override wins on startup. The live transport's STUN list is hot-swapped on save — no restart. The Node page reaches these settings over the loopback API described in §2.11, not MNP. **Browser-side: hardcoded, not configurable.** The browser's `iceServers` array is set in `transport.js`, shipped by the hub. No mechanism exists for a node to push STUN configuration to a browser — the hub relays SDP, not ICE policy — and none is needed: the browser is always on a consumer connection where the four public servers will work. **ICE interface filtering** (`ice_interfaces`) was already configurable in `node.toml` and enforced by a monkey-patch on `aioice`'s `get_host_addresses` (`ice_filter.py`). It is now also editable from the Node page — same persistence, same hot-swap (the filter is reinstalled on save). Two modes: auto (empty list — excludes virtual and VPN adapters by heuristic) and manual (explicit interface whitelist). The filter is **node-side only**: it controls which local addresses the node offers as ICE candidates. The browser's ICE gathering is governed by Chromium's own WebRTC stack and is not configurable from this application. **What prompted this.** A VPN client (ProtonVPN) adding virtual network interfaces caused the browser-side ICE gathering to include those interfaces' candidates. The remote node then spent seconds attempting to resolve `.local` mDNS candidates for addresses it could not reach, inflating connection time from ~1 s to ~6 s. The STUN fallback makes the server-reflexive path resilient; the ICE filter lets the operator exclude problematic interfaces on the node side. The browser-side delay from VPN interfaces is a Chromium behaviour outside this application's control. **Trust note.** A STUN server learns the querier's public IP and NAT mapping — that is its purpose. No content, no credentials, no group metadata passes through STUN; the protocol is a single binding request/response. Adding a STUN server is trusting its operator to learn your NAT topology, nothing more. --- ## 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 | **Built 2026-09-07, and not as Sender Keys** — see `docs/chat-sender-keys.md`. The row that stood here ("pairwise to identity keys, never GEK-derived") was reversed on 2026-09-03 and then overtaken entirely. Per group, off by default, MNP 1.2 | | — | 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 was re-read twice and abandoned as written.** The first correction (2026-08-17, `devel-phases-next.md` §15.0b) said a sender key must be per device, never per person, because a shared chain advanced by two devices produces key and nonce reuse — `first-review.md` C1, one level down. That is still true, and it is why the design that was built has no shared mutable sending state at all. The second correction (2026-09-07) ended the protocol choice. **`docs/chat-sender-keys.md` is the specification; this records only what changed.** Once distribution is under the group key *and* the node serves history to devices that were not present, the node must retain each chain's earliest key — and a chain key at iteration *i* yields every message key from *i* on by pure HKDF. Forward secrecy is therefore zero either way, so the ratchet bought no confidentiality over one AEAD while adding stateful client code with silent failure modes. Three were reproduced in that document; the worst is that under group-key distribution **any member could sign as any other**, because `add_sender` accepts any distribution and the signing key inside one is bound to nothing. What was built instead: one key per group, per epoch, per **device**, derived by name from an epoch key the node generates and delivers wrapped under the group key. A new epoch opens whenever the set of devices that may read future messages shrinks; old epochs are kept and still delivered, so the history stays readable to everyone who could already read it — and rotating the group key becomes a re-wrap rather than the destruction of the whole archive, which is what a group-key-derived archive key would have caused on the first `member unpin`. Messages are signed over the ciphertext with the device key the node pinned. Two properties of the old plan survive unchanged: - **Revoking a device opens a new epoch**, exactly as revoking a member does. - **Sender attribution is device-rooted, and the device-to-account mapping comes from the node's roster.** Encryption does not by itself make senders cryptographically authenticated to each other; the *signature* does, and only as far as the reader's roster is honest. The docs must not imply more. Ordering consequence, unchanged and now satisfied: **device linking (Stage C) lands first**, or this is built against an identity model 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. **`docs/apps.md`** — the group UI's plug-in architecture (§2.7): the props every application receives, the enablement mechanism end to end, and a checklist for adding one.