# MeshBay — Architecture Draft v6 > Status: **current specification.** Supersedes `meshbay-draft-v5.md`. > **Sections not restated here are unchanged from v5**, which remains the reference for > everything v6 does not touch — the handshake (§4), node authority (§5), the hub's role > (§6), cryptography (§7) and the testing posture (§10) are all still v5's. > > v6 exists because a design discussion on 2026-08-17 settled the desktop client and, in > doing so, changed four things v5 states: what a group's content *is*, how a person's > devices are admitted, how authorship is established, and which shell the native client > uses. It also records one rule v5 assumed without writing down. > > The v5 convention is carried forward and is not negotiable: **a claim in this document > must name the adversary it holds against.** A property that holds against a passive hub > and not an active one is written that way. --- ## 0. Reading order | Read | For | |---|---| | **this document** | what is true now, and what changed from v5 | | `meshbay-draft-v5.md` | everything v6 does not restate — still authoritative there | | `second-review.md` | the findings (C1–C6, H1–H7, M*, L*) referenced throughout the code | | `docs/invite-pairing-v1.md` | invitations, pairing codes, the node roster — **as built** | | `docs/per-node-identity-v1.md` | identity keys are per node; the hub stores none | | `docs/desktop-client-v1.md` | the desktop client in full — shell, device linking, roots, packaging, execution order | | `devel-phases-next.md` | the roadmap | --- ## 1. Changes from v5 | # | Category | Change | Source | |---|---|---|---| | 1 | Content model | A group's content is **several named roots**, not one directory. Names are unique, derived from the directory's basename, and form a union virtual root | E7 / decision 11, 13 | | 2 | Identity | **Device linking**: one person may hold several devices on a node, admitted by a key the node already pinned, bound by a one-time code the new device generates | E2 / decision 2 | | 3 | Client | The native client is **Electron**, not pywebview. Structural decision 18 reversed | E1 / decision 1 | | 4 | Node authority | `gek_rotate` may be a signed MNP op — the C5b rule forbids *key material arriving from outside*, not an operator-signed instruction where the node generates the key | §2.3 below | | 5 | Server state | **Group-related server state lives on the node, always.** Written down because v5 assumed it | E9 / decision 17 | | 6 | Portability | exFAT/NTFS and Windows are the **common** case. Case folding and Unicode normalization become correctness requirements, not compatibility notes | E8 / decision 12 | | 7 | Accounts | Native registration is **hybrid**: passphrase-derived `auth_key` (the recovery path) plus a device Ed25519 key for day-to-day authentication | E3 / decision 4 | | 8 | Authorship | Chat senders are **cryptographically authenticated to each other**; an upload has a **provable owner** who may delete it, as the operator may. v5's node-asserted attribution is replaced | operator decision, §2.4b | --- ## 2. What v6 changes in v5's text ### 2.1 §5.2 Uploads — now per root v5 confines uploads to `shared_root/uploads/` with a filename allowlist, no overwrite, chunk ordering and a size cap. All four protections stand. Two amendments: - There is no single `shared_root`. **The operator designates one root as the upload destination**; the quarantine lives inside it. If that root is unavailable the upload fails with a stated reason and never falls back to another; if none is designated, uploads are refused rather than guessed. - **The no-overwrite rule is unchanged and still holds on exFAT/NTFS.** An earlier draft claimed a string comparison let `README.TXT` land on `readme.txt` there. It does not: the check is `Path.exists()`, and `stat()` is itself case-insensitive on those filesystems, so the upload already gets a free name. C5a is not reachable through the filesystem. Case folding is needed for index identity, collision reporting and root names — comparisons the code makes itself. v5's rule that nothing derived is written beside the originals is **unchanged**, and it decides the video-thumbnail question: a frame grab is produced on demand and cached on the device that asked, so the node keeps no thumbnail store. ### 2.2 §5.5 Admission — devices, not one key per person v5 and `invite-pairing-v1.md` bind **one** key pair to an account per node: `identities` has `user_id` as its primary key and `pin_identity` does `INSERT OR REPLACE`. A person with a browser and a native client needs two keys on the same node, so: - `identities` becomes keyed by `(user_id, pk_ed25519)`, with `label`, `added_at`, `added_by_pk` and `revoked_at`. **`INSERT OR REPLACE` must go** — today it silently overwrites, which becomes a hole the moment a second key is legitimate. - A new device is admitted when **a key the node already pinned countersigns it**, bound by a one-time code the new device generates and displays, hashed together with the new keys so the node cannot substitute them. - The operator's one-time code remains available and is unchanged. Device linking is an addition to admission, not a replacement. **Against an active hub this holds**, and for the same reason §5.5 holds: the hub has stored no user keys since 2026-08-14, so it cannot produce the countersignature. Against a malicious node operator it is not a new exposure — a node can only add a device to itself, where it already reads everything it serves. **Where it does not hold:** approval performed *in a browser* inherits T3, because the hub serves that browser its code and can read the typed code. The first browser-to-native link is therefore the moment of highest exposure for an account, and it happens once. Full design: `docs/desktop-client-v1.md` §4. ### 2.3 §5.1 GEK activation — the rule, stated precisely v5 says *"nothing arriving over MNP can activate a GEK"*. Read precisely: the rule targets **key material arriving from outside** (C5b), not the instruction. An operator-signed `gek_rotate` where **the node generates the key with its own CSPRNG** satisfies the property v5 §5.5 actually establishes — the node produces every copy of the key — and is allowed. **The initial `gek-init` stays local.** With no GEK, `join_result` answers `no_gek` and no MNP session completes, so there is no authenticated session to carry a signed op. Placing it in the pre-proof window is possible and is deliberately deferred; that window is where C4 and C5b were born. ### 2.4 §8.2 Native client — Electron pywebview is replaced by Electron plus an optional Python sidecar for hub-less `group://` over QUIC. The non-negotiable is unchanged and is the entire point: **UI assets ship inside the package and load from disk.** A shell pointing at the hub's `/app/` is a browser with a different icon. What changes is the engine, not the claim. Native still does **not** remove trust in the hub operator; it converts an undetectable, per-request attack into an artifact that can be hashed and compared, and that value is realised by reproducible builds (18.7), not by the packaging format. Two corrections to v5's client table: - **Key storage.** Identity keys are generated and kept locally, never bundled. C4 closes for a native device unconditionally — and **stays open for any account that also uses a browser**, which has no durable storage of its own and still needs a bundle on each node. An account is only as strong as its weakest client. - **Crypto.** The client keeps WebCrypto *and* gains local Argon2id and ChaCha20 in the main process. v5 implied WebCrypto is lost with the browser engine; under Electron it is not. ### 2.4b §5.1 Authorship — authenticated, not asserted v5 §5.1 authorizes `file_delete` by "the node operator, or the user who uploaded the file (verified by the key recorded at upload)". Two changes: **Authorization moves from the key to the account.** With several devices per person, `_admin_exec_file_delete` — which verifies against `entry.uploader_pk`, the exact uploading key — would refuse Alice's desktop the right to delete what her phone uploaded. It becomes **any non-revoked device of `uploader_id` in the roster**, with `uploader_pk` kept as the audit record of which device acted. This remains **roster-rooted, not token-rooted**: a hub minting a token that claims to be Alice holds no key the node pinned for Alice, so the signature fails — the property `per-node-identity-v1.md` established is preserved. **Ownership becomes provable.** The uploader signs `meshbay:upload:v1` over node, group, root, path, content hash, account and timestamp; the node stores it with the index entry. Ownership is then verifiable by any member rather than asserted by the node, and the C5a path — overwriting a file to become its recorded uploader — is closed a second time. **Chat senders must be cryptographically authenticated to each other.** v5 relied on NS6, where the node enforces `sender_id` from the authenticated session; that is the node's word. Messages are signed with the sender's **device** key, clients pin `account → device keys` on first sight using the device-add countersignatures as evidence, and the operator may sign a roster attestation to close first contact. **Against whom this holds.** Against another member: fully — no member can forge another's signature. Against someone holding the node's disk: fully — a stolen chat store cannot be *extended* with messages that verify. Against the node operator: **partially, and the partial part is worth having** — once a member's client has pinned Alice's device key, an operator who turns malicious later cannot forge Alice to that member; forgery is limited to accounts the victim has never seen. Full protection at first contact requires an attestation rooted outside the node, which is what the operator-signed roster and safety numbers provide. Design: `docs/desktop-client-v1.md` §4.8. ### 2.5 §6.1 Hub role — one addition, one rule The hub gains exactly one endpoint from all of this: **`POST /v1/users/auth`**, device Ed25519 authentication on the pattern of `POST /v1/nodes/auth`. Nothing else in the desktop-client design adds a row or a column to the hub. And the rule v5 assumed without writing: > **Group-related server state lives on the node.** Files, indexes, members' devices, > pending device requests, invitations, chat, per-root availability, and anything a future > feature wants to keep about a group — all on the node. The hub holds accounts, the group > registry and membership, signaling, notifications and the moderation surface, and > nothing else about content. Verified for the multi-root change: `SwarmSource` carries `content_hash`, `node_id` and `endpoint` — **no paths, no filenames** — and private groups register nothing (H7). The content model changes end to end without the hub moving. ### 2.6 §7 Cryptography — unchanged, one consumer added No parameter changes. `keyderive.py` now has a third consumer: the desktop client derives `auth_key` exactly as the browser does at registration. `test_bundle_kdf_parity.py` covers it, and the standing warning is unchanged — **never change those parameters in one place**; a mismatch does not look like an error, it looks like an account nobody can open. --- ## 3. Filesystem portability as a security property New in v6, because it was treated as an edge case and is not one. Most users are expected to share from an external exFAT or NTFS volume, on Windows. | Property | Consequence | |---|---| | Case-insensitive, case-preserving | `Film.mkv` and `film.mkv` cannot coexist. The index needs a canonical identity and a **case-folding collision check** at scan time. The no-overwrite rule must be case-folded — **this one is a security fix** (§2.1) | | Unicode normalization | `Café.mkv` written on macOS (NFD) and Windows (NFC) are different byte strings. Normalize to **NFC for identity**, preserve the original bytes for display and opening | | Windows reserved names, `MAX_PATH` | A group indexed on Linux can hold names Windows cannot create. The client sanitizes on save **and says so**; use `\\?\` paths | | FAT/exFAT timestamps (2 s, local time) | mtime alone is not a change detector. Size + mtime with tolerance, rehash when in doubt | | Watcher reliability | `ReadDirectoryChangesW` drops events under load; inotify on a FUSE mount is unreliable. **Periodic reconciliation is mandatory on both platforms** | | No symlinks, no POSIX permissions | Simplifications: nothing to defend against, and the node runs as the user anyway | A volume that disappears must **freeze** the affected root's subtree, never empty it. Emptying propagates deletions for a whole library as though the owner had erased it. --- ## 4. Security claims — deltas only v5 §2's table stands. Three rows change, and they are the honest version: | Claim | Passive hub | Active hub | Malicious node operator | |---|---|---|---| | Client code integrity | ✅ ships in the package (native) | ⚠️ **detectable, not prevented** — realised by 18.7, not by packaging | ✅ | | Keypair bundles (**C4**) | closed for native devices | closed for native devices | ⚠️ **open for any account that also uses a browser** | | Devices | ✅ | ✅ the hub cannot countersign a device — it holds no user keys | ⚠️ a node adds devices only to itself, where it already reads everything | **The claim v6 supports:** *the hub cannot read your content, and against a native client its only remaining lever is the artifact it ships — which can be hashed and compared.* **The claim it must not make:** that a native client makes the hub untrusted, or that C4 is closed for an account that still signs in from a browser. --- ## 5. Still open v5 §9's list stands, with these movements: | # | Item | Status | |---|---|---| | C4 | Remote keypair bundles | **Partially closed.** Gone for native devices; open for browser-using accounts until the signed `device_policy {allow_bundle: false}` opt-out ships | | T3 | Hub serves the SPA | **Accepted permanently** for browser users. Removed for native clients, whose value depends on 18.7 | | — | Chat encryption (Sender Keys) | Phase 15, unchanged. Pairwise to identity keys, never GEK-derived — and **now to devices**, which multiplies the recipients per person | | — | Delegation | Designed, deferred, unchanged | | — | Hub identity pinning | New. `GET /v1/hub/pubkey` exists and nothing pins it; bounded, because a substituted hub can neither read content nor ship the code to a native client | **Phase 15 has been re-read against device linking (2026-08-17) and was wrong as written.** The correction is recorded in `devel-phases-next.md` §15.0b; the load-bearing part: - **A sender key is per device, never per person.** A shared per-person chain advanced by two devices produces key and nonce reuse — which is exactly why `first-review.md` C1 rejected a shared Double Ratchet for groups. The same mistake, one level down. - `senderkeys.py` already fails this silently: `GroupSenderKeyStore.add_sender` does `self._states[dist.sender_id] = ...`, so a second device under the same `sender_id` **overwrites the first and drops its chain**. `sender_id` must become a device identifier. - **Revoking a device must rotate**, like revoking a member. - **A newly linked device cannot read history** until every sender redistributes, unless the linking device hands over its own state sealed to the new device's key. - **Sender attribution stays node-trusted.** A sender key proves a *device*; the mapping from device to account comes from the node's roster. Encrypted chat does not make senders cryptographically authenticated to each other, and the docs must not imply it. Ordering consequence: **device linking (Stage C) lands before Phase 15**, or Phase 15 is built against an identity model that is about to change underneath it. --- ## 6. Where the detail lives This document states what changed and what holds. It does not restate the desktop client's design, which is long and belongs in one place: **`docs/desktop-client-v1.md`** — shell requirements, device-linking protocol and schema, account creation, node management over signed MNP ops, several roots per group, filesystem portability, packaging and first run, the web tier, and the execution order for all of it.