# MeshBay — Design > **Status: the specification.** This document states what MeshBay is, how it is > built, and why each part has the shape it has. It replaces the draft series > (`meshbay-draft-v5.md`, `meshbay-draft-v6.md`, `old-draft.md`) and the design > notes that grew around it; §16 maps every reference those documents and the > code make onto a section here. > > **The convention that is not negotiable: a claim in this document names the > adversary it holds against.** A property that holds against a passive hub and > not an active one is written that way. A section that says "this buys nothing" > is as load-bearing as one that says it buys something. > > **The second convention: this document states design, not history.** Where a > label like `C1`, `NS6` or `T3` appears — and hundreds of code comments cite > them — it names the invariant that holds today, not the incident that produced > it. §13 is the register of those labels. > > Wire versions at the time of writing: **MNP 3.0** (oldest peer accepted 3.0), > **MHP 0.1**, packages **0.14.0**. The normative source for the wire format is > `MESHBAY_NODE_PROTOCOL.md`; this document states the design the protocol > serves, not its byte layout. --- ## 0. How to read this ### 0.1 Scope | Read | For | |---|---| | **this document** | the architecture, the trust model, and the reason each decision is what it is | | `MESHBAY_NODE_PROTOCOL.md` | the MNP wire format, message by message | | `transfers-v1.md` | the transfer system's failure-mode analysis, kept because a synthesis cannot carry "every way a slot can be lost" | | `playlists.md` | the playlist design in full — decided, not built (§9.10) | | `cast-smart-tv.md` | the DLNA/UPnP device backend — designed, not built (§11.4) | | `WINDOWS-PORT.md` | the Windows port's audit and packaging detail (§11.2) | | `PACKAGING-GUIDE.md`, `HTTPS.md`, `MAIL-SERVER.md`, `windows-build.md` | installation and server operations | | `CLAUDE.md` | project conventions and the engineering lessons that govern how changes are made | **There is no user guide.** The one that existed described the system before per-node identity, named roots and the sealed wire, and was retired rather than repaired — a document a reader cannot tell the sound parts of is worse than none. Writing a new one starts here. ### 0.2 Reference labels Code comments, tests and commit messages cite short labels — `C1`, `H3`, `NS6`, `T3`, `C5b`, `W2`, `E9`, `F1`. **Every one of them is defined in §13**, stated as the rule it names today. Three label families collide, and the register keeps them apart: - **`C*` / `H*` / `M*` / `L*` with no qualifier mean the second review's numbering** (§13.3). That is the numbering the code uses. The first review's `C1`/`C2` and the third review's `H1`/`H2`/`M1`–`M6`/`L1`–`L11` are always written with the review named (§13.1, §13.4). - `M2a`, `M2b`, `M2c` and `C5a`, `C5b` are sub-items and are unique across the set. - **`D1`–`D4` are the client-architecture decisions** (§14.2); **`D1`–`D12` written as "Stage D*" are desktop-client build stages** (§15.2). ### 0.3 The one-sentence version > **The hub cannot read your content unless it ships you malicious client code — > and against a native client it cannot do that undetectably.** Everything below either supports that sentence or states precisely where it stops. --- ## 1. What MeshBay is ### 1.1 Positioning MeshBay is a platform for **private, encrypted, self-hosted groups with an application store**. A group is a set of people, a set of directories on somebody's machine, and a set of applications over them — chat, a file explorer, a video library, a music player, a photo album. It is **not** a public file-sharing network. Public groups exist as an optional hub feature and are switched off on the reference deployment (§7.4). The design optimises for the private case throughout: the hub keeps no file names for private groups, registers no content hashes for them, and holds no key that opens them. ### 1.2 The three parties ``` ┌─────────┐ accounts, group registry, signaling relay, ┌─────────┐ │ hub │ notifications, moderation, instance policy │ peer │ └────┬────┘ — never content, never a group key │ hubs │ │ └─────────┘ MHP 0.1 │ signalling (SDP/ICE, <1 KB), presence, revocation push MHP │ ┌────┴────┐ MNP 3.0 ┌──────────┐ │ node │◄──────── WebRTC DataChannel / QUIC ──────────►│ client │ └─────────┘ index, file chunks, streams, chat, admin └──────────┘ holds the files browser SPA or desktop holds the group key client; both are the is the content authority same UI source ``` - **The hub** is a registrar and a signaling relay. It is in the trusted path by choice, not by necessity (§14.2 D4), and it is never in the data path. - **The node** is the daemon that holds a group's directories and its group key. It is the **sole content authority**: it decides who is served, what is served, and who may change anything about the group's content. - **The client** is the browser SPA or the desktop application. Both are built from one source tree (§8.4). ### 1.3 The rule that settles arguments > **Group-related server state lives on the node. Always.** Files, indexes, members' devices, pending device requests, invitations, chat, chat epoch keys, per-root availability, application settings, transfer limits — all on the node. The hub holds accounts, the group registry and membership, signaling, notifications, the moderation surface and instance policy, and nothing else about content. This is decision **E9**, and it is the rule any new feature is measured against. A feature that wants a row on the hub about a group's content is a feature that has misunderstood the model. It has been re-verified at each content-model change: `SwarmSource` carries a content hash, a node id and an endpoint — **no paths, no filenames** — and private groups register nothing at all (**H7**). --- ## 2. Trust model ### 2.1 Adversaries Every claim below is written against one of these, and they are the only ones the document uses: | Adversary | What they can do | |---|---| | **Passive hub** | Read everything the hub legitimately stores and relays | | **Active hub** | Also lie: forge tokens, invent accounts, substitute values it publishes, ship modified client code to a browser | | **Malicious node operator** | Read and alter everything on their own machine, including the plaintext files they host | | **Malicious group member** | Everything a member may do, plus anything the protocol fails to refuse | | **Network attacker** | Observe and tamper with traffic between any two parties | | **Local attacker** | Reach loopback services and files on a client or node machine | | **Registered hub user with no membership** | Reach every hub endpoint that does not check membership | | **Federated peer hub** | Push directory rows and revocations over MHP | ### 2.2 Security claims | Claim | Passive hub | Active hub | Malicious node operator | Malicious member | Network attacker | |---|---|---|---|---|---| | Data never transits the hub | ✅ | ✅ | — | — | ✅ | | File content is unreadable | ✅ | ❌ **T3** (browser) · ✅ native | ❌ by design — the operator hosts the files | ❌ members share the group key | ✅ | | The file index is unreadable | ✅ | ❌ T3 · ✅ native | ❌ | ❌ | ✅ | | Chat content is unreadable | ✅ | ❌ T3 · ✅ native | ❌ — the operator is a member | ❌ | ✅ | | Chat is unreadable **off a stolen disk** | ✅ | ✅ | ✅ without the keystore passphrase | ✅ | ✅ | | Content cannot be modified | ✅ | ✅ | ❌ by design | ✅ | ✅ | | The node cannot be impersonated | ✅ | ✅ | — | ✅ | ✅ | | Client code integrity | ❌ **T3, accepted** (browser) · ✅ ships in the package (native) | ❌ T3 · ⚠️ native: **detectable, not prevented** | ✅ | ✅ | ✅ | | The hub cannot obtain the group key | ✅ | ✅ **except** in an open-join group, where it can join legitimately (§7.3) | — | — | ✅ | | Node content authority | ✅ | ✅ | ✅ sovereign | ✅ | ✅ | | Devices cannot be added by the hub | ✅ | ✅ — the hub holds no user key and cannot countersign | ⚠️ a node adds a device only to itself, where it already reads everything | ✅ | ✅ | | Chat senders are authenticated to each other | ✅ | ✅ | ⚠️ only for accounts the reader has already seen (§3.3) | ✅ | ✅ | | Keypair bundles (**C4**) | closed for native devices | closed for native devices | ⚠️ **open for any account that also signs in from a browser** | — | — | | Deleting your account erases you | ✅ hub-side | ✅ hub-side | ❌ files, pinned identity and bundle stay on the node (§7.7) | — | — | | Your identity keys stay yours | ✅ | ✅ | ⚠️ offline attack on the bundle they hold — succeeds against a weak passphrase, and yields the identity used **on that node only** | ✅ | ✅ | ### 2.3 What the project must not claim Three sentences are forbidden, each for a deliberate reason: - **"Everything is encrypted and unreadable by other parties, even the hub."** A hub that ships the code can lift keys from the page regardless of protocol design (**T3**). That is an artifact-level attack, not a silent directory lie, and it is removed for native clients — not for browsers. - **"A native client makes the hub untrusted."** 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. A build signed with a key the hub operator holds *relocates* trust; it does not remove it. - **"C4 is closed."** It is closed for a native device unconditionally, and open for any account that also uses a browser. **An account is only as strong as its weakest client.** "End-to-end" here describes **client ↔ node**, never client ↔ client. Members and the operator read everything in their group; that is what a group is. ### 2.4 One boundary worth naming An operator hosts your content by design. They should not be able to become *you*. They can still try — a keypair bundle sits on their disk and a weak passphrase gives it up — but what it gives up is **the identity you use with them**, which unlocks nothing they did not already hold. Reading what they host is by design; reading what *other* operators host is not, and does not follow (§3.2). --- ## 3. Identity ### 3.1 Accounts An account lives on the hub: a username, an encrypted email address, a status and a role. A username is 8 to 64 characters, checked at registration only — accounts created under the older 3-character floor keep signing in. The passphrase never leaves the client. It derives **two independent values**, both salted by the trimmed username: | Value | Derivation | Consumer | |---|---|---| | `auth_key` | PBKDF2-SHA512, 600 000 iterations, domain `meshbay:auth:v1:` | hub authentication — the hub stores an Argon2id hash of it | | `bundle_key` | Argon2id 128 MB / t=3 / p=1, domain `meshbay:bundle:v2:` | AES-GCM key for the per-node identity bundle, held on each node | This split is **T1**, and its consequence is structural: **the hub never sees a passphrase**, so the passphrase floor — 12 characters and roughly 60 estimated bits — can only be enforced client-side, and is. The two values have different fates. The hub holds a verifier for `auth_key` and can reset it from an email code. **Nobody can reset `bundle_key`**: the hub has held no key material since the invite redesign, and cannot reach a `keypair_bundles` row, which is served only over MNP to authenticated members. That asymmetry is why passphrase change and passphrase recovery are two features and not one (§3.6). ### 3.2 Identity keys are per node A person's identity keypair is created **at first contact with a node**, encrypted under `bundle_key`, and left on that node. It is never reused elsewhere. The reason is blast radius. The adversary is concrete: an operator holding their own node's disk, attacking a bundle offline at their leisure. What cracking one yields is 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, and a key one node pinned is a stranger to the next, which asks for a code like any first contact. Two consequences fall out and both are wanted. Two operators cannot tell they host the same person by comparing keys. And **the hub stores and publishes no user keys at all** — `users.pk_ed25519` / `pk_x25519` are dropped, `PUT /me/keys` does not exist, and `/pubkeys` returns an account id and the node's linking key. There is no directory to substitute from, which is what **H3** was. Two further rules follow directly: - **Tokens carry no `pk_user` claim.** A key chosen by the hub must never become the identity a node records for an upload, or whoever issues tokens decides who may delete a file. Attribution uses the roster pin. - **There is no key rotation endpoint.** Rotation is per node: `member unpin` plus a fresh code, which already exists. Registration therefore generates nothing, which has a pleasant side effect: a scripted signup produces a real account, and a wiped hub and node can be taken to a working demo without a browser. ### 3.3 Devices One person may hold several devices on one node — a browser and a desktop client, two laptops. `identities` is keyed by `(user_id, pk_ed25519)` with `label`, `added_at`, `added_by_pk`, `revoked_at` and the evidence columns below. Pinning is never `INSERT OR REPLACE`: a silent overwrite is a hole the moment a second key is legitimate. **A new device is admitted by a key the node already pinned.** The authority is therefore a key the node established locally, exactly as for operator authorisation — and **the hub cannot produce it**, because it holds no user keys. Device linking adds no hub-reachable authority. The binding is a **one-time code the new device generates and displays**, hashed together with the new keys: `code_hash = sha256(code ‖ new_pk_ed25519 ‖ new_pk_x25519)`. The approving device asks the node for this account's pending requests **with their stored hashes** and recomputes the hash for each until one matches. > **The code never reaches the node.** That is what makes a substituted key > impossible rather than merely detectable: a node offering fabricated keys would > have to produce a hash over a code it has never seen. The approval is deliberately **not** a human comparing digits. Safety numbers were evaluated and refused, permanently, under decision 19: **no new code exchanges between people.** A device-linking code is a code between a person's own devices, which is a different thing and was already accepted. Bounds, all of them anti-abuse rather than the security boundary — the security comes from the hash binding: | Bound | Value | |---|---| | Request TTL | 1 h, `[node] device_request_ttl_minutes` | | Devices per account per node | 5 | | Attempts per connection | 5, then a node-wide lockout | | Filing a request | requires the account to have at least one pinned identity already | `member unpin ` removes **every** device. `device revoke` marks one rather than deleting the row, because a deleted row is a key the node would happily pin again. **Which device is on a connection** is proved separately from which account. The handshake proves the account; `device_hello` — signed over a transcript naming the node, the group and this connection's nonce — proves the device, and is refused unless the key is a live device of that account in the node's own roster. Without it the node would fall back to the account's oldest key and record it as the author of everything. **Members can verify each other's device keys** (Tier 2). `group_roster_req` / `group_roster_resp`, sealed under the group key, answers **any member** with, for each live device of each active member: the key, the key that countersigned it, and the signature, nonce and timestamp that prove it. The client walks the chain itself — the node decides nothing, because the node is the party the property holds against. A device the node lists but cannot evidence never enters the verified set, so a fabricated key is not laundered in by being mentioned. Three rules keep that honest: - **A root is a device that names no countersigner**, not one that fails to produce a signature. Treating "no proof" as "root" would admit anything a node chose to write. - **First sight pins everything the node says**, not the verified subset — an alarm that fires on legitimate second devices stops being read, and the budget for this whole feature is exactly one notice: *"this account's key changed"*. - **A device pinned before the evidence columns existed is unevidenced and reads as such.** The property, stated exactly: > Once a member's client has seen an account, **a node that later substitutes a > key for it is detected.** Nothing is gained at first sight, where the client > has nothing to compare against. That second sentence is not a caveat to be dropped. Closing first sight needs an attestation rooted outside the node — an operator-signed roster (Tier 3), which is deferred with nothing depending on it, and worth building only where the operator is not the machine. **The cost, which is real:** the roster is member-visible, so every member of a group learns how many devices every other member holds and what their public keys are. It stays inside the group, the hub is not involved, and it is scoped to one group. A member who cannot see the keys cannot check them, so this is not avoidable. **Where device linking does not hold:** an approval performed *in a browser* inherits **T3** — the hub serves that browser its code and can read the typed code. The first browser-to-native link is the moment of highest exposure for an account, and it happens once. An account created natively does that first link in the safe direction. ### 3.4 Admission: invitations and pairing codes **The node wraps the group key**, 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. ``` operator (SSH) meshbay-node member invite bob → CODE R3H8-TB6V (or the same from the group's Settings tab, signed by the paired browser) operator sends the code to bob out of band bob opens the group; the client holds no group key 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 group key, wrapped for the X25519 key bob just proved he holds ``` Four properties, each load-bearing: 1. **No public key is ever fetched from a directory.** The joiner's keys arrive from the joiner, both signed together in one transcript (`meshbay:join:v1`), so the identity key vouches for the encryption key. 2. **The code binds a key to an account**, and the hub never sees it. 40 bits, Crockford base32 rendered `XXXX-XXXX`, single use, valid for exactly one account in one group, stored only as `sha256(code)`. A password KDF over 40 uniformly random bits would buy nothing. Guessing is bounded by 5 attempts per connection and a node-wide lockout, and every attempt is an audit event. 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`. 4. **Wrapping happens on every connection.** Nothing is stored per member, so key rotation propagates by itself and revocation actually takes effect. (Rotating the key after a revocation is still required — the ex-member holds the current one, and no protocol can take that back.) Node authority is established the same way, once per node: `meshbay-node operator pair` prints a code, the operator types it into their own browser, and the node pins that identity. **It is never learned from the hub** — a hub able to name the operator's key could install itself as node administrator, which is **NS4**/**M3**. Code lifetimes differ because the acts differ: | Code | Default | Setting | |---|---|---| | Member invitation | **7 days** | `[node] invite_ttl_hours` | | Operator pairing | 24 h | `[node] pair_ttl_hours` | | Device add request | 1 h | `[node] device_request_ttl_minutes` | An invitation waits for someone to read their messages; a pairing code is typed during the SSH session that printed it. **Why a code and not something lighter** — the question is what stops the hub from being bob on his first connection: | Option | What an active hub can do | | |---|---|---| | Wrap for whatever key the peer presents | Forge a token for bob, present its own key, receive the key | worse than nothing | | Bind to the key the inviter fetched from the hub | Substitute at invite time | **H3**, relocated | | TOFU: first connection wins | Race the real bob with a forged token | small window, total consequence | | Safety-number comparison | Nothing — but it needs two humans reading digits at the worst moment | correct, unusable as a default | | **One-time pairing code** | **Nothing: the code never reaches the hub** | **adopted** | **Delegation is designed and deferred.** `invite_create` is authorised as a *role* check against the roster rather than an equality test against the operator, and the `delegate` role value is reserved, so a group admin who does not run the node becomes a roster row and a CLI command — no protocol change, no migration. ### 3.5 Open-join groups A group whose `join_policy` is `open` pins on first contact (TOFU) and wraps the key immediately. A code there protects nothing — the hub can create an account, join through the front door, and be a legitimate member — so it would be pure friction. Stated plainly, per the convention: **in an open-join group the hub can obtain the group key.** That is a property of open joining, not a defect of this design. Content in such a group is protected from the network and from non-members, and from nobody else. Note the axis. **`visibility`** (public/private) controls discoverability and swarm hash registration (**H7**). **`join_policy`** (open/request/invite) controls admission. Only the second decides whether a code is required: a public group with `join_policy = "invite"` keeps the code, because being findable is not being open. **`join_policy` is read from `node.toml`, never from the hub.** A hub able to declare a group open would be handed its key. An unknown group reads as `invite`. ### 3.6 Passphrase change and recovery **Changing a known passphrase** re-wraps every reachable node's identity bundle from the old `bundle_key` to the new one **before** touching the hub — if the fan-out fails, the account is unchanged. Only then is `POST /v1/users/password` called with the old and new `auth_key`. Nodes that were unreachable are named to the user, with the operator fallback (`member unpin` plus a fresh code) as the way to fix each one. Every refresh-token family is revoked. **Recovering a lost passphrase** splits into what each key can reach: | | Recovered by | |---|---| | Hub login (`auth_key`) | an email code alone | | Per-node identity keys — group key unwrap, provable upload ownership, chat identity, device countersigning | the **recovery key**, per reachable node | | An identity on a node with no recovery-wrapped copy, or offline at recovery time | operator `member unpin` plus a fresh code | The recovery key is a full-entropy 32-byte secret `R` **generated by the client**, rendered as a grouped mnemonic. `recovery_key = HKDF-SHA256(R, info = "meshbay:recovery:v1:" + username)` — HKDF and not Argon2, because `R` has 256 bits and there is nothing to brute-force. Every time an identity bundle is written to a node, a **second copy** is written beside it wrapped under `recovery_key` (`bundle_enc_recovery`, additive on the wire). `R` is a pass-through: offered in the registration email by default, never written to any database, never logged. The reset endpoints are built to leak nothing. `POST /v1/users/password/reset-request` requires **the username and the email on file as a pair**, checked against a blind index and never decrypted; a mismatch, an unknown username and a non-active account all take the identical no-op path and return the same `200 {"status": "sent_if_exists"}`. So it cannot be used to spray reset mail at an inbox from a username alone. A reset **deletes every `UserDevice` row** on the hub. Three different things are called "device" here and only one is touched: | | What it is | A reset | |---|---|---| | `user_devices` (hub) | an Ed25519 key that lets a client skip the passphrase prompt on launch. A hub-login convenience — no group key is wrapped for it | **deleted** | | per-node identity (`identities` on each node) | the keys that unwrap the group key, prove upload ownership and sign chat — **this is group access** | **recovered** from the recovery copy, or via the operator fallback | | the roster pin | which identities a node has admitted | untouched | Deleting `user_devices` costs one passphrase prompt per client, which is the point: after a "control may be lost" event, a laptop still carrying a stored hub-auth key must stop signing in on its own. ### 3.7 The keypair bundle, and what it is worth (C4) A bundle carries **one node's** identity keys, encrypted under the owner's passphrase, 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, which keeps nothing durable of its own. **Why Argon2id.** PBKDF2 is compute-only, which is exactly what a GPU is good at. Measured: 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 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. Operational facts that constrain changes: - Argon2id runs in **WebAssembly, vendored** under `static/vendor/` with its provenance. The CSP forbids external hosts and must keep `wasm-unsafe-eval` in `script-src`. - **Never change these parameters in one place.** `keyderive.js`, `keyderive.py`, the desktop client and the test harness are held byte-identical by `test_bundle_kdf_parity.py`. A mismatch does not look like an error — it looks like an account nobody can open. - Bundles carry an `MBK2` marker; an older PBKDF2 form is still readable and is re-encrypted on the next backup. - Cost is paid **once per sign-in** (≈650 ms bundle + ≈239 ms `auth_key`). Reloading a page derives nothing: the key lives in IndexedDB. - The pre-proof window that serves bundles is bounded (4 fetches) and audited. **C4 is reduced, not closed.** Bundles still sit on disks their owner does not control. It closes for a native device unconditionally, because that device's key is in no bundle anywhere. It closes for an *account* only when no browser needs a bundle on that node — which needs `device_policy {allow_bundle: false}`, **signed by a pinned key** so the decision is the user's and never the hub's (open item O3). --- ## 4. Cryptography ### 4.1 Key hierarchy ``` User identity key Ed25519 signing, authentication — per node (§3.2) User exchange key X25519 key agreement — per node Group encryption key AEAD 256-bit content and index encryption — the group secret Chat epoch key 32 bytes per group, per epoch — node-generated (§4.5) Session keys X25519/HKDF per-connection, from DTLS/TLS ``` Every private key lives in an encrypted keystore on the machine that owns it. The hub never sees one. **Domain separation is consistent and mandatory.** Every derivation uses a distinct `info` string, and the AES variant adds an `:aes` suffix so two ciphers can never derive the same key from one group key. This is a small detail that prevents cross-protocol key reuse, and it is checked rather than assumed. ### 4.2 Group key wrapping (ECIES) ``` wrap: sk_eph, pk_eph = X25519.generate() # fresh per bundle shared = X25519(sk_eph, pk_recipient) wrap_key = HKDF(shared, salt=pk_eph, info="meshbay:gek_wrap:v1", len=32) wrapped = AEAD(wrap_key).encrypt(nonce, gek, aad=pk_recipient) bundle = pk_eph ‖ nonce ‖ wrapped unwrap: shared = X25519(sk_recipient, pk_eph) # same derivation ``` Three properties are why this shape: - **The ephemeral keypair is fresh per bundle**, so the same key to the same recipient produces different ciphertext every time. - **The AAD binds the bundle to its recipient**, so a bundle reused for a different member is rejected by the tag rather than by a check somebody has to remember to write. - **A wrong private key fails at the AEAD tag** — an immediate, unambiguous refusal. The node produces every copy of the key itself, from its own CSPRNG. **Nothing arriving over MNP can activate a group key** (**C5b**). Read that precisely: it targets *key material arriving from outside*, not the instruction. An operator-signed `gek_rotate` where the node generates the key is a different shape and is allowed. The initial `gek-init` stays local, because with no key there is no completed session to carry a signed op. ### 4.3 On-the-fly encryption Files are stored **in plaintext on the operator's disk** and encrypted at read time. This avoids double storage and makes key rotation feasible without re-encrypting terabytes. ``` disk (plaintext) → compress → per-chunk AEAD under a group-derived key → transport → client ``` - Chunk size 1 MB: amortises AEAD overhead and enables seeking, because each chunk is independently decryptable. - `chunk_key = HKDF(GEK, salt=None, info="file:" ‖ blake3(file) ‖ ":chunk:" ‖ index)`. The salt is omitted deliberately: the group key is CSPRNG output and already uniform, so the file and chunk context belongs in `info`, which is the correct HKDF usage (**M5**, first review). - **Chunk authentication is the AEAD tag**, not a per-chunk signature. The tag authenticates the ciphertext under a key only members hold, which is what the signature was for. - Compression precedes encryption, because compression is ineffective on ciphertext. - Upload chunk size is 48 KB, which is what fits the SCTP limit after msgpack overhead. Crypto is not the bottleneck: encrypt-and-send costs single-digit milliseconds per megabyte against tens to hundreds for the network. ### 4.4 The group envelope `meshbay_common/groupbox.py`, mirrored by `sealGroup`/`openGroup` in `static/crypto.js`, is **one envelope with purpose-separated subkeys** derived from the group key. AAD is `"|"`. | Purpose | Info string | Carries | |---|---|---| | `index` | `meshbay:index:v1` | `index_sync`, `index_delta` | | `ack` | `meshbay:ack:v1` | the handshake ack's configuration payload | | `upload` | `meshbay:upload:v1` | `file_upload` / `file_upload_ack` | | `chat_keys` | `meshbay:chat_keys:v1` | chat epoch key delivery | | `roster` | `meshbay:roster:v1` | `group_roster_req` / `resp` | **Never reuse the chunk key with a pseudo-file for this.** The purposes are what keep two message classes from sharing a key. Three things about the sealed messages must stay straight: - **Sealing the ack line is integrity, not confidentiality.** The handshake transcript names no ack field, so `is_node_admin`, `enabled_apps`, the roots table and the rest were authenticated by the channel alone. The AEAD tag comes from a key the hub does not hold. - **Sealing the index is defence in depth against our own next bug**, of a class that has shipped twice (**C1**, **C6**). It buys nothing against an observer, the hub or a member. That is the whole claim. - **A payload that does not open ends the session**, never a default. An unopenable `enabled_apps` reads as "the operator disabled every app" and an unopenable index as "the group is empty" — both indistinguishable from legitimate states. What stays in clear, and none of it is content: the handshake itself; `type`, `v`, `group_id` and the ack's `node_pk`/`proof`/`sig`, because a receiver must route and **authenticate** before it would trust a decryption; `upload_id` and `chunk_index`, because the node routes and orders on them; `index_progress`, which is counters only, every 2 s; transfer-lease fields, which are an opaque id and two numbers (§5.5); and the media-metadata replies. ### 4.5 Chat encryption Chat is encrypted with 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: ``` epoch_key 32 random bytes, generated BY THE NODE (C5b) device_key(d) = HKDF(epoch_key, info="meshbay:chat:dev:v1|"+group_id+"|"+d) where d = base64(device pk_ed25519), the roster's own identifier ``` Every member derives every device's key from the epoch key, so **nothing is distributed per device and nothing is stored per device**. **There is no mutable sending state at all.** That is the point, and it is a stronger guarantee than per-device chains rather than a weaker one: a shared chain advanced by two clients produces key and nonce reuse (first review **C1**, one level down), and a design with no sending state cannot have that hazard. > **Two clients of one account normally share a device key.** A second browser > recovers the existing identity from the keypair bundle rather than minting a new > one; device *linking* is the exception, not the rule. What makes that safe is > the nonce, not the derivation: **96 random bits, never a counter.** Two > independent senders under one key collide only on the birthday bound, which at > chat volume is unreachable; two senders advancing one counter collide > immediately. Each message is **sealed and then signed over the ciphertext** with the device key the node pinned, so a receiver verifies before decrypting and anyone holding the roster can verify. The AAD binds the group and the epoch, so a ciphertext cannot be replayed into another group or attributed to another epoch. `sender_name` lives **inside** the sealed payload — as a wire field it was free to spoof. `sender_id` stays a clear field **set by the node from the authenticated session** (**NS6**). It is what the store keys on and what the UI groups by; it is not what authenticates the message. The *device* claim is checked against the connection's own `device_hello`, or a member could sign as anyone. Replay is refused by a unique `(device, nonce)` — a replay is a validly signed copy, so nothing about the signature refuses it. **Epochs.** A new epoch is opened when, and only when, the set of devices that may read *future* messages shrinks: `member revoke`, `member unpin`, `device revoke`, `gek_rotate`, or an explicit `chat rotate`. Epoch 1 is opened at group load — a group with no epoch is a group nobody can speak in. **Old epochs are kept and still delivered.** That is what keeps history readable to everyone who could already read it, and it is why rotating the group key is a **re-wrap** rather than the destruction of the whole archive: the archive is not encrypted under the group key, only the epoch keys are wrapped with it in transit. Nothing anywhere deletes an epoch. Epoch keys are stored ECIES-wrapped to the node's own X25519 key in `bundles.db`, never raw — a plaintext key store beside `chat.db` would collapse the threat model silently, and it is the obvious thing to write. **Why not a ratchet.** Under group-key distribution *and* server-served history, the node must retain each chain's **earliest** key, and a chain key at iteration *i* yields every message key from *i* onward by pure HKDF. **Forward secrecy is therefore zero either way.** What a ratchet was left buying is a large amount of stateful client code with silent failure modes, three of which are concrete: any member could sign as any other, a second device dropped the first's chain, and the skipped-key cache grew without bound (§13.6, F1–F3). Forward secrecy is given up **deliberately and on the record**. If it ever becomes a real requirement it belongs in 1:1 DM, where there is no server-side history to contradict it. **Neither a sender-key nor a ratchet implementation exists in the tree.** Both were written, neither was ever called, and both are deleted — the reasoning that ruled them out lives at the top of `chatbox.py`, the module that replaced them, where it stands on its own instead of pointing at a file to compare against. > **Kept code that nothing calls is worse than absent code.** It reads as an > alternative somebody may reach for, its green tests read as evidence of a > protection that is not in the product, and it has to be maintained past every > refactor to stay compiling — maintenance spent on a decision already made. If > forward secrecy ever becomes a real requirement, it belongs in 1:1 DM, where > there is no server-side history to contradict it, and it starts from the > requirement rather than from a module somebody left behind. **What chat encryption protects against, in the words the user-facing docs should use:** someone who obtains the node's storage **without the keystore passphrase** — a hosting provider imaging the machine, a leaked backup, a seizure where the passphrase is not surrendered. It does **not** protect chat from the operator or any current member (they hold the group key, and the chat key is delivered under it); from anyone holding any one device of any member; from a former member, for messages sent before the epoch changed; from the hub as regards *metadata*; or from the node as regards *links posted*, which it fetches to unfurl. **Deliberately not encrypted**, stated so nobody reads more into the feature than it does: `sender_id`, timestamps, message sizes and the fact of a message are in the clear to the node, which is the relay and cannot route otherwise. The hub learns per message the group, the time and the sender's account id, so it can skip the author when creating notifications — it can build a social graph with timings without reading a word, and that is a known metadata leak rather than a solved problem. Attachments are ordinary files on a root and stay plaintext on disk; the *reference* to one is inside the sealed payload, but the file and its name are in the index. ### 4.6 Parameters | Parameter | Value | |---|---| | Node keystore KDF | Argon2id **256 MB**, t=3, lanes=4 — recorded per envelope, so raising it does not orphan existing keystores | | Hub password verifier | Argon2id **256 MB**, t=3, over the client-derived `auth_key` — `pw_version` migrates transparently on next login | | Browser bundle key | Argon2id **128 MB**, t=3, p=1 | | Browser `auth_key` | PBKDF2-SHA512, **600 000** iterations | | Chunk cipher | AEAD, 1 MB chunks, per-chunk key by HKDF | | Chat nonce | 96 random bits per message, never a counter | | Invite / pair codes | 40 bits, Crockford base32, single use, stored as `sha256` | --- ## 5. The node protocol (MNP) ### 5.1 Transports | Listener | Role | Status | |---|---|---| | **WebRTC DataChannel** | primary, browser **and** desktop client | the path everything is tested on | | **QUIC** | LAN, port-forwarded, hub-less `group://` | **off by default** (`[node] quic_enabled`) — nothing ships a QUIC client yet, and it does not implement chat (below) | | ~~TCP + TLS~~ | — | does not exist (**C6**) | | ~~HTTP file API~~ | — | does not exist (**C1**) | **QUIC carries no chat, deliberately.** It implements neither the per-device sealing nor the device identification the chat rules require, and a message reaching a group's archive without them would be a plaintext row in an encrypted history — indistinguishable from one somebody actually wrote. The handler was **removed rather than gated**: refusing there would mean maintaining a second, weaker set of rules for a transport with no client, which is how a bypass branch survives. An unimplemented message type is logged and dropped, like every other one this transport does not have. > That is the general form of the parity rule. **A transport either implements a > content rule fully or does not serve that content at all.** "Implements the > authentication but not the authorization" is the shape of finding **C6** and of > the third review's **M2**, twice. **ICE/STUN is the NAT traversal mechanism**, for native clients too — via `aiortc` in Python. `punch_nat()` is a direct-connection helper, not a traversal stack: one UDP probe to one address, no STUN client, no candidate gathering, no dual-stack fallback, and it requires the client to already know its own external address. It was validated on one ISP and one NAT type. ICE has been validated across two ISPs, two browsers, IPv4 STUN and IPv6 direct, and 4G CGNAT (§11.1) — **no TURN relay is needed**. **Several STUN servers, two providers deep**, because a single server is a silent single point of failure that adds the full gathering timeout to every connection when it is slow: ``` stun:stun.l.google.com:19302 stun:stun1.l.google.com:19302 stun:stun.cloudflare.com:3478 ``` Both sides carry the same defaults and gather independently; neither learns which server the other used. **A STUN server learns the querier's public IP and NAT mapping — that is its purpose.** No content, no credentials and no group metadata passes through it; adding one is trusting its operator to learn your NAT topology, nothing more. The node's list is editable three ways (Node page, `meshbay-node stun`, `node.toml`) and hot-swapped on save. **The browser's list is hardcoded and not configurable**: the hub relays SDP, not ICE policy, and no mechanism exists — or is wanted — for a node to push ICE configuration to a browser. **ICE interface filtering** (`ice_interfaces`) is node-side only, and controls which local addresses the node offers as candidates. Two modes: auto (excludes virtual and VPN adapters by heuristic) and manual (an explicit whitelist). It exists because VPN clients add virtual interfaces whose `.local` mDNS candidates a remote node spends seconds failing to resolve. The browser's own gathering is governed by the engine and is outside this application's control. ### 5.2 The handshake One implementation, `meshbay_common/handshake.py`, called by both transports. Two implementations of one security check is **C6** waiting to happen. ``` client → node handshake {token, group_id, nonce_c, v, v_min} node authorize_token() JWT · scope · denylist · group_id · membership · hosting node → client handshake_challenge {nonce_s, node_pk} ── pre-proof window: bundle fetch, join ── client → node handshake_response {proof} node verify HMAC(GEK, client transcript) node → client handshake_ack {proof, sig, node_pk, sealed configuration} client verify HMAC(GEK, node transcript) + Ed25519(node_pk), and that ack.node_pk is the key announced above ``` **The transcript is length-prefixed, domain-separated and role-bound:** ``` "meshbay:mnp:handshake:v1" ‖ len‖role ‖ len‖group_id ‖ len‖nonce_c ‖ len‖nonce_s ‖ len‖binding ``` Every field is length-prefixed so the concatenation is unambiguous, and the role is bound in so a client proof can never be replayed as a node proof (**L4**). **Channel binding is mandatory and an absent one is refused** — never degraded to nonce-only, which would silently drop MitM detection: | Transport | Anchor | |---|---| | WebRTC | both DTLS certificate fingerprints | | QUIC | SHA-256 of the server certificate. On a **resumed** session, which carries no certificate, the anchor travels with the session ticket — sound, because the ticket derives from the handshake where the certificate was presented | **Authentication is mutual** (**C3**). The node proves possession of the group key over a **client-chosen** nonce *and* signs the transcript with its long-term key. The client verifies both, refuses a bare ack, and **TOFU-pins `pk_node`** per node, refusing a changed key outright with a deliberate reset path in Settings for a legitimate rotation. > `node_pk` is announced in the challenge because joining needs it before the ack: > a first-time member signs a transcript naming this node and has no group key to > complete a handshake with. It is unverified at that point and is never a > substitute for the ack. **The pre-proof window is three messages, and that is a bound to defend.** Only the two bundle fetches and `join_request` are served before the proof, because each is something a caller needs *in order to* prove possession of the group key: a returning browser has to recover its identity, and a new member has no key to prove with. Nothing else qualifies. Device messages are **authenticated-only** — a device request is countersigned later by a device already pinned, so requiring the caller to finish its own handshake first costs nothing and keeps the surface at three. The bundle fetches are additionally bounded (4 per connection) and audited, because that window is the neighbourhood **C4** and **C5b** came from. **Pinning is defence in depth, not the primary control.** A substituted node already fails the key proof. Pinning covers the case where an attacker *holds* the group key — an ex-member, a leaked key — and swaps the node underneath, which the proof alone cannot distinguish from the genuine node. **Authorization rules:** - `group_id` is **mandatory**. Omitting it once skipped the membership check and fell back to the node's first group (**M1**). - `scope == "user"` is enforced by default; node-scoped daemon tokens are refused on the client path (**M9**, **NS7**). - The denylist is consulted for user, `jti` **and** group. - The node **refuses connections when it holds no group key** — there is no `gek_required: false` bypass (**NS8**). **Refusals carry a code**, not only a sentence, because a client can act on a code. `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. ### 5.3 Correlation and liveness **Every reply carries the request id it answers** (`req_id`). Matching by arrival order is a guess that fails silently and asymmetrically: the victim is never the request that was answered wrongly, it is the unrelated one now waiting for a reply already delivered elsewhere. The node stamps `req_id` on the reply from `_send`, via a ContextVar so a handler's spawned work still answers under the right id, and never on a broadcast, which answers nothing. `PING`/`PONG` is liveness on an **already-open** channel and never a discovery mechanism — opening a connection costs a full ICE/DTLS handshake, measured at 0.6–7 s. ### 5.4 Operator-signed 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 is not what the user asked for** (**H5**) — a challenge of opaque random bytes signed blind is an unbound signing oracle. The transcript's subject names the *outcome*, not the operation: what the operator is shown before signing has to be what happens. Verification is against `roster.operator_pks()`, rebuilt from node state, **never** from anything in the response. | Operation | Authority | |---|---| | `file_delete` | the operator, or **any non-revoked device of the uploading account** | | `dir_delete` | the operator alone, and only on an empty directory | | `invite_create` | the operator (or a delegate, when delegation ships) | | `gek_rotate` | operator-signed; the node generates the key itself | | initial `gek-init` | **local admin API or CLI only** | | root add/remove/update/eject/plug, `apps_enabled`, app directories, transfer limits | operator-signed | | ~~`gek_bundle_store`~~ | **the message does not exist.** No member ever hands the node key material | `gek_bundle_store` was deleted rather than gated. The operator's X25519 public key is announced in the handshake ack, so any member could wrap a key of their choosing for it; a path that does not exist cannot be mis-authorised (**C5b**). **Authorisation is against the account, not the key.** With several devices per person, verifying against the exact uploading key would refuse a person's desktop the right to delete what their phone uploaded. `uploader_pk` stops being the authorisation key and becomes the **audit record** of which device acted. This remains **roster-rooted, not token-rooted**: a hub minting a token that claims to be someone holds no key the node pinned for them, so the signature fails. **Ownership is recorded by the node, from the authenticated session, and it is durable.** The record is written when the last chunk lands and the file reaches its final name; the entry is stamped from it when the indexer creates the entry, which is a **later** moment — the entry does not exist while the upload is still arriving, so an attribution written against the index at the end of the upload matches nothing, silently, and leaves every uploaded file owned by nobody. It is stored beside the hash cache rather than on the entry alone, because the index is rebuilt from disk at every start and an owner the node forgets on restart is a right quietly taken away. It is validated against a live `stat()`, so whatever later occupies that path inherits nothing. **It is asserted by the node, not proved by the uploader.** A member verifies nothing here: they are told who uploaded a file, by the node that served them the index — the same trust they already extend to every other field in it. The overwrite path where an attacker could become a file's recorded uploader (**C5a**) is closed by the no-overwrite rule (§6.4), not by a signature. An upload transcript the uploader signs — over node, group, root, path, content hash, account and timestamp, stored with the entry — would make it verifiable by any member instead. That is an open item (§15.3), not something the product has. **One implementation, several front doors.** `meshbay_node/ops.py` holds every operation. The loopback API, the CLI and the signed MNP handlers all call these functions; they take the daemon state, raise `OpError`, and know nothing about HTTP. Two implementations of one operation with two authorization checks is **C1**/**C6** one size down. ### 5.5 Transfer leases A download used to be invisible to the node: a client sent eight independent chunk requests and reassembled the answers, and nothing said a transfer had started or ended. There was nothing to count and therefore nothing to cap. **The unit is the lease** — the node's record that a peer is transferring something, held for the length of the transfer and released by name (`transfer_open` / `transfer_close` / `transfer_state`). Six properties are decisions: - **`tr` is drawn by the client.** Re-opening after a reconnect with the same `tr` is idempotent, so a reconnect cannot charge a member twice for one transfer. - **A lease is scoped to the connection**, never to the account. It dies with the session, which makes the primary reclaim deterministic. - **A lease covers a job, not a file.** A directory zip is dozens of files and one lease. - **Nothing is persisted.** A restart drops every session anyway; a lease that outlived the process would be a slot nothing can release. - **Leases are counted, not bytes.** What a slot protects is concurrency — open file handles, disk seeks, the channel buffer each transfer keeps full. - **Per-member first, then node-wide.** A member at their own cap queues behind their own transfers and never holds a node-wide slot a second member has none of. Reversed, whoever arrives first takes everything. | Bound | Default | Why it exists | |---|---|---| | Node-wide concurrent transfers | 8 | operator's machine | | Per account, per group | 2 | absent means this, not "unlimited" | | Grant deadline | 30 s | a grant nobody takes up is a slot nobody can use | | Idle timeout | 120 s | catches a peer that vanished without the connection noticing | | Queued per member per kind | 32 | an unbounded queue is how a node runs out of memory politely | | Missed grants before closing | 3 | without a bound the requeue is a permanent cycle | **Browsing a group is never subject to a transfer slot.** Not the poster grid, not the album covers, not the video thumbnails, not the file list, not opening a photo or a document to look at it. **A member must be able to browse a group that is at capacity exactly as they browse an idle one.** That is a requirement, not a tuning parameter, and it is met structurally rather than by choosing a lucky threshold. Navigation proper never touches this path at all: listings and metadata are their own message types, sealed under the group key, with no relationship to a chunk request. That half needs no rule — only a test that fails if someone later routes a listing through the chunk path. The chunk path itself carries three genuinely different things, and they are distinguished **structurally, by what the id resolves to and by which function asked**: | What | Resolves to | Rule | |---|---|---| | Thumbnails, posters, cover art, cached audio conversions | a **media-cache id**, not an index entry | **Never leased, never counted, never queued.** One chunk each, out of a bounded cache the node built itself | | Looking at one file — a photo full size, a document, an image | a real index entry, fetched whole | **Not leased**, subject to the bound below | | Downloading and uploading | a real index entry | **Leased** | The rule stated as a sentence someone can check by reading: > **A transfer is something the transfers panel shows. If it does not appear > there, it does not take a slot.** The two sets are the same three call sites, > which is what makes this verifiable rather than a judgement call at each new one. **Which of the two a request is, is the node's answer and not the client's.** `tr` is drawn by the client, so it is a claim: the node resolves it against its own record and serves the chunk as a leased transfer only when that record is a **granted** lease **of this connection**. A lease it holds but has not granted is refused with a code — a member reading while queued is the cap not applying — and an id it has no record of is treated as leaseless and bounded below, because that is also what a reconnect looks like from here, where the session's leases died with the old connection and the client is re-opening them. Read as a bare presence check, the field made every cap on this page decoration: any non-empty string skipped the ceiling, and the queue held only the clients that chose to wait. **Why the exemption is expressed in concurrency and not in size or bytes**, which is the durable part of this decision: - **A size threshold does not separate the two.** A raw photo out of a camera is 60–80 MB and is *browsing*; a 40 MB archive is a *download*. Any threshold letting the photo through lets the archive through too. - **A byte-rate budget does not either.** It would have to be large enough for that same photo, at which point it is large enough to be a download channel. - Concurrency is the thing being rationed, so concurrency is what the exemption is expressed in. The bound is therefore a ceiling on **how many distinct files one session may read leaselessly at once** (12, with a 60-second idle expiry). Per session and not per member, because this is a ceiling on what one connection can do while claiming to be browsing, not a resource pool — a member with three tabs open is browsing in three tabs. Two rules keep it from becoming a bug: **a file already being read is always admitted**, whatever the count, because refusing a chunk halfway through a photo is worse than never having admitted it; and **entries expire on idle**, because a viewer closed mid-file stops asking and says nothing, and dead entries would eventually refuse every later preview. **A preview never shows "waiting", because a preview never queues.** If the bound is somehow reached, the request is refused with a stated reason and the person tries again — it does not silently become a queued transfer in a panel they were not looking at. **What the residual is, stated plainly.** A client that lies — labelling a bulk download as a view — gets that bound's worth of files at a time instead of its member cap. It is bounded, it is audited, and it is the same class of statement as the cap itself: **this is a fairness control among cooperating clients**, in the company of the stream cap. It is not a defence against a member determined to saturate the operator's disk, and must never be described as one — that member is a member, and the answer to them is `member revoke`. **The numbers are visible or the queue is unprovable.** `transfer_state` carries `used`, `cap` and `ahead` so a client can say "waiting — 2 of your 2 slots are busy" rather than showing a bare spinner; the same counters reach the loopback API for the CLI and the Node page, and a periodic debug line. When someone reports a transfer stuck at "waiting", that line is the only thing that will say whether the node ever had them in a queue at all. The lease module is free of asyncio and of the transport: it decides, and the caller does the I/O. A queue that reveals itself only through a DataChannel is a queue nobody can prove things about. ### 5.6 Versioning and flag days MNP and MHP version independently of the package version. Every wire message carries `v`; both peers declare `v` and `v_min` on the handshake and refuse each other with a code (`version_too_old` / `version_too_new` / `version_unreadable`). **A mismatch is a refusal, not a field that turns up missing.** A stated refusal is a bug report; a feature that quietly does not work is a support case. Additive changes are MINOR and cost nothing. A change to what a peer must be able to *do* is MAJOR even when the messages are additive — a peer that cannot ask for a transfer lease is either refused, or not refused and transferring outside every cap the operator set. **There is no compatibility switch, by policy.** An opt-in flag leaves the old branch reachable on every node, which is **C6**'s lesson one feature later. Where a break is required, `MNP_MIN_SUPPORTED` moves with `MNP_VERSION` and the deployment is coordinated: the hub serves the SPA, so a browser picks up the new client on reload; the desktop client ships its own UI, which is why `GET /v1/hub/version` carries `client.minimum` and the client checks it **before** connecting and says "this version can no longer connect" rather than showing a handshake refusal nobody can act on. **The floor being the current version is what keeps capability branches out of the client.** `MNP_MIN_SUPPORTED` equals `MNP_VERSION`, so `check_version` refuses every older peer at the handshake — which means **every capability is true of every peer the client can reach**, and there is nothing to test for. An upload is sealed or it is not sent; a transfer has a real lease or it does not run; there is one app-directories op and no wrappers behind it. The client records the version its peer declared, for diagnostics, and **branches on none of it**. > A capability flag on a peer whose floor already guarantees the capability is a > branch that can only ever take one path — until somebody lowers the floor, at > which point it silently takes the other. **A field kept "just in case" is how > the branches come back.** Where a break leaves data behind, a migration runs with the node stopped, backs the database up first and is idempotent. But **a migration that has to be remembered is a migration that does not happen**, so anything that *can* be a read-time fallback is one instead: the roster reads an older settings key when the new one is unset and leaves it behind on the first write, and `node.toml` keeps being read in its older spelling. Only a transformation nothing can infer — two settings that disagree, where only the operator knows which they meant — is allowed to need a step somebody has to run. --- ## 6. The node ### 6.1 Node authority The node is the content authority. Its authority comes from **its own roster**, established locally by pairing, and from nowhere else — never from the hub, never from a config file, never auto-pinned from the keystore (**NS4**). A configuration that still names a legacy admin key is warned about at startup and never obeyed. The division of trust: **the hub certifies identity; the node authorises content operations.** Hub membership lets someone *reach* a node; the node's roster decides whether it wraps anything for them. ### 6.2 Roots A group's content is **a set of named roots**, each mapping to a local directory, forming one union virtual root: ``` / (group virtual root) ├── Films/ → D:\Media\Films ├── Music/ → E:\Audio (external drive, removable) └── Documents/ → C:\Users\me\Share ``` **The name is the chosen directory's basename, derived once at add time and stored.** Never recomputed from the path: renaming a folder on disk would otherwise silently re-identify a whole library and break every stored reference to it. Four rules make basename naming safe: - **A duplicate basename is refused, case-insensitively.** Collisions are common in practice (`D:\Films` and `E:\Films`). Refusing is correct; an explicit alias is the escape hatch (open item O11). - **No root may contain another**, compared case-insensitively and after canonicalisation. Two nested roots would index the same bytes twice under two identities. - **The basename becomes a path segment every member sees**, so it must itself pass the portability rules (§10) — a root Windows cannot write to is a root nobody on Windows can download from. - **Every index path carries a root segment**, uniformly, including in a single-root deployment. One code path, not two. Five consequences, none optional: 1. **The root name is part of a file's identity**, so renaming a root rewrites every path under it. Renaming is explicit and warned, never a cosmetic setting. 2. **Availability is per root.** With one directory an unplugged disk was a hazard; with named roots it is a supported state — one root freezes, the others carry on. 3. **Free space, quotas and capacity are per root** — different volumes. Anything the UI says about space names which root it means. 4. **Path resolution is per root**, in one place: a request names `/`, `RootSet.resolve()` resolves it against that root's canonical path and refuses `..`, absolute segments, symlinks and anything escaping the root. 5. **`kind` is a view hint** (`generic`/`video`/`audio`/`photo`), nothing more. **Each root is read-only or read-write.** - `writable = false` (the default) means read-only **for everyone, including the operator**. A published library that quietly accepts writes from whoever holds admin authority is not one, so refusing the operator is the point rather than the defect. - `writable = true` means any group member may upload there. Several roots may be writable and none need be — a fully read-only group is valid. The operator toggles this with a signed op. > **There is one answer to "may this member write", and it is the root.** A single > flag over the group cannot express "this library is published read-only and that > folder is a drop box", which is the ordinary arrangement — so the group-wide > switch that used to exist is gone entirely: the message, the signed operation, > the field on the handshake ack, and the `upload` alias each root used to carry > beside `writable`. **Two sources for one question is one too many**: whichever > the code consulted first decided it, and a client falling back to the group flag > whenever a root omitted `writable` is exactly that bug with a compatibility > justification. Two names for one boolean is the same fault one size down. > **A control that writes must name where.** With two writable roots the node > cannot choose without guessing, and a guess sends a member's file to a disk the > operator did not intend. The client names a **root**, never a path; everything > below the root is decided by the node. **A root that goes away must freeze, not empty.** The indexer runs a watchdog observer and rebuilds on change; unmounting a volume either emits deletions for the whole tree or presents an empty directory to the next scan. Both propagate as though the owner erased their library. So a root has two independent runtime states: - **`ejected`** — operator-controlled, persisted in `roster.db`. - **`available`** — computed as `not ejected and is_live()`. This is what clients and the indexer see. The distinction matters: between clicking eject and physically unplugging, `is_live()` is still true, and without `ejected` the availability sweep would immediately flip the root back. **Eject** stops that root's observer, marks it unavailable, freezes its entries and propagates the availability change to connected peers — the operator can then safely unplug. **Plug** checks the path is accessible first, then rescans: the plan called for a reconciliation, but a device people carry around can come back arbitrarily different, and the hash cache means unchanged files are not re-read. **`ejected` is persisted and restored at startup**, because a restart is exactly what an operator does after noticing a drive fell off, and an in-memory flag would let the following scan read the empty mount point as an erased library. It lives in `roster.db` and not in `node.toml`: it is runtime state, and an operator's hand-written config must not be rewritten because a USB drive was unplugged. **Auto-eject is the safety net.** If a `removable` root's path disappears, the availability sweep sets `ejected` as though the operator had clicked it, and reports it so the daemon persists it. Nothing is deleted: index entries, cached metadata, thumbnails, chat history referencing those files and app directory configurations all survive, the last flagged as temporarily invalid rather than wrong. ### 6.3 Indexing The index is **content-addressed**: `GroupIndex` is keyed by blake3, so the same bytes at two paths inside one group are **one** entry. This is why a scan can report ten files and index nine, and it decides how reconciliation must work: > **Anything comparing disk against index must compare ids, not paths.** Comparing > paths makes the second path of a duplicated file look like a missed event, every > sweep, forever — rewriting the entry, bumping the version and pushing an index > update to every connected peer. **Hashing is partial above 40 MB.** A hash exists for content identity, and a 4 GB file does not need 4 GB of I/O to be identified with overwhelming probability: | Size | Method | `hash_version` | |---|---|---| | ≤ 40 MB | full read | `1` | | > 40 MB | blake3 over the first 20 MB ‖ last 20 MB ‖ 5 MB at the midpoint | `2` | Head and tail catch container headers, trailers and files that differ only at one end; the mid-sample catches files sharing a header and trailer. Below the threshold a partial read would sample the whole file anyway, so the full path is simpler and produces the same value — which is what keeps small files cross-comparable between nodes of different versions. A large file indexed by a node of each version produces different ids and does not merge in cross-group search; that resolves itself when both upgrade, and is the accepted cost of not reading 4 TB to build a library. `hash_version` is an additive index field with a default, so an entry written before it deserialises correctly and needs no protocol bump. The hash cache carries the column and auto-migrates on open; large files are re-hashed lazily on the first scan after an upgrade. **Periodic reconciliation is mandatory on every platform**, not a backstop: `ReadDirectoryChangesW` drops events under load on Windows, and inotify is unreliable on a FUSE-mounted volume on Linux (§10). **Progress accounting covers the real-time path too.** A file's size joins the total the moment its debounce timer is first scheduled — not on every re-trigger, or a cancelled-and-rescheduled timer double-counts — and joins the scanned total when its hash finishes. The scanning flag clears only when no timer is pending *and* no hash is running, because the in-flight hash of a large file is the entire reason to show progress. `index_progress` is deliberately **not** sealed: counters only, every 2 s. ### 6.4 Uploads Five protections, and they are the substance: - a **filename allowlist**; - **no overwrite** — a colliding name gets a free one. The check is `Path.exists()`, and `stat()` is itself case-insensitive on NTFS and exFAT, so this already holds there; - **strict chunk ordering**; - a **size cap** — 4 GB per file. There is deliberately no aggregate quota yet, and that gap is named in §15.3 rather than left to be discovered: a member can still fill the operator's disk one capped file at a time; - the target root must be **writable and available**, enforced by the node. **There is no quarantine subdirectory.** A folder appearing beside the operator's library because somebody sent a file is the node deciding how their disk is arranged. What made a quarantine worth having was never the subdirectory — it is the four rules above, and they are unchanged. The client now names the destination folder, which is safe for exactly one reason: it is resolved through `RootSet.resolve()` (§6.2). **A member answers "which of this group's folders", never "which path on the operator's disk".** If the named 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. **The node enforces it; the interface merely stops offering it.** Each root's `writable` flag rides the handshake ack and the index payload, so a client knows whether to draw the Upload button and the chat paperclip, and changes are broadcast to everyone connected. None of that is the control: a member on an old tab, or one speaking MNP directly, is refused by the node. **Files can be dropped onto the Files tab**, files and folders alike, under the Upload button's rule (a folder on screen, in a writable root, in a group — not in Search). A drop is decided whole before anything is sent: **a name already in the folder refuses it**, compared without case, because the node's no-overwrite rule would otherwise store a colliding file under a free name nobody asked for and refuse a colliding folder half-way through; and a name outside the filename allowlist refuses it too, from a client copy of the rule that a test holds to the node's answers. A folder is rebuilt one `dir_create` at a time, parents first, and its files are fed to the transfer store a few at a time so the member's queue (§5.5) never reaches its cap. None of this is a control — the node still enforces every rule above; it is what keeps a drop from ending in a partial copy. In a group with **no** writable root the interface says so plainly rather than picking one — a fallback that chooses whatever comes first only moves the failure to send time, where the person has already chosen the file. **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. Directory creation is not privileged. Directory **removal** is, and is refused unless the directory is empty. The emptiness rule is the safety property: whatever the caller intended and whatever the client sent, the operation cannot destroy content. It is checked twice — before the challenge is issued and again after the signature returns — because a file can land during the round trip to the operator's browser. ### 6.5 Derived data and enrichment The governing rule: > **Enrichment happens on the client, from data it already has. What the client > cannot compute, the node produces — and where the node produces it for > everybody, it caches it in its own `data_dir`, never in a shared root.** The client half is unchanged and is why a chat image thumbnail costs the node nothing: the browser already decrypted the image and scales it itself. The node half was decided against the alternative and the reasoning is worth keeping, because it looks like a violation of "no derived state" and is not: 1. **A third-party API quota is per credential, not per device.** A token shipped inside every install and called from every client scales with the number of *devices* in existence. A node making the calls on behalf of its own members makes the number of *nodes* the denominator, and one lookup per unique title serves every member indefinitely. 2. **Thin clients benefit from a node that does more.** A phone should receive a small ready-made image and a JSON blob, not decode video or hold its own multi-gigabyte cache. 3. **Keeping the credential server-side is a strict improvement** over shipping it to every renderer, and it preserves the desktop client's tested invariant: it issues no request outside `/v1/` and the signaling socket. **The cache lives in the node's own `data_dir`** — beside `chat.db`, `audit.db`, `bundles.db` — and never inside a shared root. That is not a compromise, it is strictly better, for reasons independent of sovereignty: a shared root is routinely a read-only backup mount or a share the node cannot write to; a dot-prefixed folder is not hidden on Windows and would appear as an ordinary folder full of previews of a private group's content to anyone who plugs the drive into another machine; and it would need filtering out of every listing path consistently, forever. **Delivery reuses the chunk path.** A thumbnail is addressed by its own blake3 exactly as a file is by its id, so the chunk handler resolves a requested id against either a real file or the media cache. Same transport, same group-derived encryption, same backpressure — no parallel mechanism, and no new authorization surface. **Cache lifecycle is tied to the index.** A file's thumbnail and its third-party match are pruned by the same event that removes its index entry. Third-party metadata is cached per external id with its own refresh window, since several files of one show share one fetch. **A visible node-wide toggle turns third-party calls off entirely**, independently of any application being enabled, for an operator who wants zero third-party network traffic. This is genuinely new node behaviour — egress to a third party, and a disk-resident cache with a real deletion obligation — and it is stated rather than left implicit. **Anything that shells out to a media tool obeys three rules**, all of which this codebase has paid for: - **its own small bounded pool with a short timeout, never the streaming pool** — a grid of fifty videos would otherwise exhaust every streaming slot on the node, since a stream slot is held for the length of a film; - **drain the pipes, then wait with a timeout, and release the slot regardless** — a process that outruns a paced reader cannot finish closing while its stdout is full, SIGKILL or not; - **a background task must be held**, or the loop may collect it mid-flight and the slot is lost for good. **Link previews** are a further instance. A URL pasted in chat is unfurled **by the node**: 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 card text lives in a bounded in-memory TTL cache; the image rides the same blake3-keyed store as any other thumbnail. **The new surface is SSRF**, because the URL is a member's choice and it triggers an outbound request from the operator's machine: http(s) only, no credentials, a port allowlist, every resolved address must be globally routable, redirects followed by hand so each hop is re-checked, the connect address re-checked against the checked one, a response-size guard, and a per-member rate limit. The operator can switch previews off per group. **Every new outbound or cross-trust surface needs a bound and a named adversary in the same commit.** That is the standing rule this section exists to enforce. ### 6.6 Chat storage One SQLite database per group at `data_dir//chat.db`. Rows carry the sealed ciphertext, the epoch, the sending device, the nonce and the signature, with a unique `(device, nonce)` refusing replays. Paging is **backwards** — `get_recent` / `get_before` / `has_before` — because a chat opens at the newest page. A forwards pager is not what a chat opens with. `meshbay-node chat prune ` deletes **messages only, never an epoch key**. An epoch with no messages is harmless; an epoch key deleted while messages still need it is an unreadable archive. **A message is bounded in size and in rate, like every other member-supplied write.** Sending one costs the operator a row that nothing expires, every other connected member a relayed copy, and every member of the group a notification — so the two bounds answer the two halves: **64 KB of ciphertext** for what one message may cost, and **60 a minute per account per group** for how often one member may impose it. Both are checked before anything is stored or relayed, and a refusal names itself (`chat_too_large`, `chat_rate_limited`) and is audited. The numbers are meant to be invisible. The sealed payload is the text, a thread id, a display name and a timestamp — an attachment is a file on a root and travels as a reference (§4.5) — so 64 KB is some sixty thousand characters, and sixty a minute is far above a person typing. The rate is keyed by **account**, not by connection: a second tab does not make anyone type faster, and keying on the session would hand a script one budget per socket it opens. **There is deliberately no node-wide chat ceiling** beside the per-account one, and the contrast with link previews is the reason. A preview spends the *node's* egress and its third-party quota, which is one shared thing and deserves a shared bound; a chat message spends the sender's own group. A node-wide ceiling would let a busy group silence a quiet one — the same defect this bound closes, one level up. ### 6.7 Operator surface Two personas need different tools, and the headless one is the normal deployment: | Operator | Reaches the node via | |---|---| | Desktop | the desktop client's Node page | | **Headless / SSH** | the CLI | **Every operation is reachable over SSH with no browser on the host.** `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 a group exists. ``` meshbay-node status meshbay-node group add --dir [--no-writable] meshbay-node root list|add|remove|set|eject|plug meshbay-node gek init|rotate meshbay-node operator pair meshbay-node member list|invite|revoke|unpin meshbay-node member device list|revoke meshbay-node denylist show|clear meshbay-node file list|rm meshbay-node chat status|prune|encrypt-history meshbay-node stun list|add|remove|reset meshbay-node reload ``` `member revoke`/`unpin` resolve a username against the roster and **refuse an unknown one** rather than acting on nobody — a typo must not look like success. Revocation tells the operator what it does *not* do: the ex-member stops receiving the key on their next connection but still holds the current one, so the message ends with the command that rotates it. **The node's local control API is JSON only, on loopback, behind a per-run session token** (`X-MeshBay-Token`, printed at startup, file mode 0600). "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. There is no server-rendered dashboard; the desktop client's Node page and the CLI are the two consumers, and each operation endpoint is one `_op(...)` line onto `ops.py` (§5.4). **Over MNP, the node's own controls need a proved operator device.** The node-wide surface — `node_status`, which lists every group on the machine with each root's absolute path, plus `node_settings_set`, `roster_read`, `denylist_read`, `denylist_clear` and `node_reload` — is reachable when two things hold: the account is the one the node belongs to, *and* the device on the connection has proved (`device_hello`, §3.3) a key the roster holds as an operator. The first alone is a claim in a token the hub issued, and **NS4** does not allow it to be authority: a hub that can name the operator is a hub that can be one. The second is what it cannot forge, since it holds no user keys and cannot countersign a device — the same property device linking rests on. A browser that has never been paired therefore reads nothing here, exactly as it can already sign nothing (§5.4). **The accepted cost, recorded as a choice:** on a headless server the only admin path is the CLI. The CLI covers every operation, so this is acceptable — but it is a real capability reduction, not an oversight. > **MNP is the path that must exist; loopback is the fallback.** The operator of a > node is not necessarily sitting at it. Any operator-facing control needs its MNP > route first, or it renders for nobody on the web. ### 6.8 Node settings Five `[node] `settings affect what the node does rather than how it starts, and their value is invisible until something goes wrong — so they are surfaced on the Node page: | Setting | Default | What it controls | |---|---|---| | `invite_ttl_hours` | 168 | how long a member invitation stays valid | | `pair_ttl_hours` | 24 | how long an operator pairing code stays valid | | `device_request_ttl_minutes` | 60 | how long a device request waits for approval. Comfort, not security: the code is bound to the keys by its hash | | `max_concurrent_streams` | 8 | simultaneous video streams. One process per viewer, ~50 MB each; a slot is held for the length of a film, so this counts viewers | | `transcode_incompatible_video` | true | whether browser-incompatible video is transcoded during streaming. Unlike remuxing this costs real CPU per viewer | > **Turning transcoding off does not mean the same thing for every source.** A > codec with an MSE codec string falls back to a copy and the viewer's own decoder > decides; a codec with none has nothing to fall back to and the stream is refused, > naming this setting. **Settings are persisted in both `roster.db` and `node.toml`**: the database for immediate effect with no restart, the file so the value survives a wipe or a fresh install. On startup the file is read and a database override wins. The TOML write is a targeted line replacement, never a round-trip through a writer — that file is hand-written, full of comments recording decisions, and a setting changed from a panel must not rewrite the operator's file. Transfer limits (§5.5) follow the same pattern, with per-group per-member caps as an operator-signed op. --- ## 7. The hub ### 7.1 Role — chosen, not minimal Hub minimisation was considered and **deferred, and may be dropped** (decision D4). The hub keeps serving the web UI and remains in the trusted path by choice. That is a legitimate product call; what follows from it is carried deliberately rather than by accident (§2.3). **Stores:** accounts (username, encrypted email, status, role), the group registry and membership, IP logs (one year, legal retention), node registrations, refresh tokens, notifications, the moderation blocklist, instance policy, and per-account device keys for hub login. **Does not store:** file content, file names, private-group indexes, message content, private keys, group keys, keypair bundles, user identity keys, node IPs beyond ephemeral signaling. **Knows, unavoidably:** who is a member of what, when nodes connect, and when a chat message was posted in which group and by which account id. The last is a stable identifier the hub needs in order to skip the author when creating notifications; it carries no content, and it is a known metadata leak rather than a solved problem. **Decides nothing about keys.** Hub membership lets someone reach a node; the node's roster decides whether it wraps anything for them. `user_devices` **is not the key directory that was H3**: nothing reads it but the hub, nothing wraps a group key for it, and it is a different key from the per-node identities. What it does cost is metadata — the hub knows how many devices an account has and when each last signed in. ### 7.2 Node registration and signaling Registration on the node socket requires a **node-scoped token**, verifies the node record 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, and cannot displace a live registration (**C2**). Narrowing goes all the way down: **an empty claim is a claim on nothing**, never on everything. Reading it as "all of this account's groups" made an unconfigured node a registered source for groups it could not serve — including other members' — and since `/v1/groups/{id}/nodes` answers in registration order, one such node reaching the hub first made a group unopenable for everyone in it (2026-09-11). The ceiling applies to **every** message that changes the set, not only to the registration: a node that may narrow on connecting and widen on reload has no ceiling. The socket is accepted before anyone is known, so **the auth message must arrive within ten seconds** or the socket is closed with 4001: an unbounded first read is a connection any stranger holds open for free. The node sends it on connecting. A client must therefore treat that list as candidates rather than a ranking, and try the next node on a `not_hosted` refusal (`MESHBAY_NODE_PROTOCOL.md` §6.3). The node authenticates to the hub with an Ed25519 signature over a domain-separated timestamped message — **no password and no auth key on a node** — and receives a `scope: "node"` token that is refused for group management. The operator manages groups from a client (**NS7**). Signaling is rate-limited, SDP-size bounded, capped per user, and **the caller must share an active group with the target node**. Otherwise any authenticated user could make a third party's machine allocate peer connections on demand (**H6**). The address in a NAT-punch request must match the caller's source address. **A node registered for no group shares one with nobody**, and is refused rather than exempted. Written as "check membership if the node claims any group", the rule skipped itself — membership, group status and the public-group gate together — for precisely the node that AV1 made commonplace: the unconfigured one, hosting nothing, which is also the one least able to absorb the work. No legitimate connection is lost, because such a node refuses the handshake anyway (`group_id` is mandatory, **M1**, and a node holding no group key refuses, **NS8**); the refusal simply stops happening at the operator's expense. `X-Forwarded-For` is honoured **only from a trusted proxy, rightmost hop** (**M7**), through one helper so the behaviour is defined in one place — including for the rate limiter, whose keying otherwise collapses to a single global bucket behind a loopback proxy (third review L10). ### 7.3 Groups A group's **identity is its UUID**, everywhere: the route, the node's configuration, membership. A group **name is unique per owner account**, case-insensitively and trimmed, enforced by a functional unique index; two different owners may each have a `photos`. Names are displayed as `name@owner`, which is a label plus a create-time check and **not an addressing scheme**. The handle is hub-local: the same `name@owner` on two federated hubs are different groups, and a federated row shows its source hub rather than an account. `visibility` and `join_policy` are the two independent axes described in §3.5. `join_policy` is read from the node's own configuration, never from the hub. **Public group creation is quota'd** — ten live public groups per owner account, staff exempt. Public groups are the ones that cost other people something: they appear in the directory and are brokered to strangers. The check is at creation only, which is correct because the update endpoint refuses to change visibility. **Which nodes host a group is answered to its members.** For a public group that is everyone, which is what public means; for a private one it is the membership row and nothing else. Answering any authenticated account — as it did while only the public case was checked — hands whoever knows the group id the identities of the machines hosting it, and an ex-member knows that id for ever. Nothing needs it before joining: an open join writes the membership row first, and an invitation registers the invitee's when the code is created. ### 7.4 Instance policy `hub_settings` is a key/value table an admin edits at runtime. It is **instance policy, not group content**: it says how this hub behaves and holds nothing about any group's files, index, membership or keys, so §1.3 is untouched. The first entry is `allow_public_groups`. Switched off, server-side and read live on every path the hub mediates: creating a public group is refused (staff included — the way back is to re-enable, not to slip past), the public directory returns nothing local **and** federated, open-joining is refused, a non-member is handed no node to connect to, the "this node hosts an open group, admit anyone" signaling fallback is dropped, and the federation export advertises nothing. **It is a directory-and-brokering control, not a remote kill.** Existing members keep their membership and their access. A node whose operator set `join_policy = "open"` still pins and serves whoever reaches it directly over MNP; what the switch removes is the hub-provided ways to find and reach such a node. **What the hub answers without an account is a reviewed list.** `test_unauthenticated_surface.py` walks every route and fails on one that takes no authentication dependency and is not listed there with its reason; routes that authenticate in their own body (a signature, an e-mailed code, an MHP token) are listed with what they check. The hub publishes no API description — no `/docs`, `/redoc` or `/openapi.json` — in the code, not in a proxy rule, so a packaged install behind any proxy publishes none either. The mail bounds (`mail.*`) and the sign-in lockout (`login.max_failures`, default 4, and `login.lockout_minutes`, default 60 — §7.7) live in the same table for the same reason: they are what an operator changes while the hub is serving, from the panel, without a restart. Each value is clamped to published bounds, and `max_failures = 0` turns the lockout off. ### 7.5 Moderation Two verbs on a group, and they are distinct things: | | `suspend` | `revoke` | |---|---|---| | Hub | `status = "suspended"` | `status = "revoked"` | | Node | nothing | signed revocation broadcast → denylist + live sessions dropped, **persisted across a restart** | | Reversible from the panel | yes | no | The client shows the real state, not a blanket one. **Revocation is honoured by nodes** and the denylist survives a restart (**H4**); signaling refuses a group that is not active. **Moderator is not administrator.** The user-patch handler is split by field: a moderator may act on the fields moderation needs and may not write `role`. **Content reporting requires authentication, distinct reporters and a rate limit, and is refused when public groups are off.** An unauthenticated endpoint that blocklists a content hash after two reports is a network-wide censorship and DoS primitive for anyone who learns a public file's id. The exact-hash CSAM check is **structural, not yet functional** — production databases are perceptual — and is stated as such so it is not relied on operationally. ### 7.6 Federation (MHP) > **Federation is closed in the code, and every MHP route refuses with a stated > 503.** `federation.FEDERATION_ENABLED` is the only thing that decides it — a > constant rather than a setting, because a switch in an admin panel invites an > operator to turn on something that has never worked between two machines. > `/v1/hub/info` reports it, since the `mhp_version` beside it would otherwise > be a claim this hub does not honour. > > The reason is not the design below. It is that **nothing has ever run it**: > two hubs have never completed one authenticated request between them > (**AV14** — the issuer signed with a key bound before it was loaded and named > itself after the reference deployment whatever it was called, and the verifier > named no audience for the `aud` the issuer sets). Both were found by reading, > and both stood for a month behind a green suite, because a second > implementation of a peer proves the protocol and nothing about two machines — > the sentence §12 already writes about a second implementation of the client. > It re-opens when a second hub has been stood up and the exchange run both > ways. > > What the closure does not touch: the public directory still reads whatever > `federated_groups` holds, which is nothing, because nothing can arrive. Peer hubs exchange directory rows and revocations. The trust rules, which hold when it re-opens: - a pushed row's **source is bound to the signer**, not taken from the payload; - the **token audience is checked**; - a push is **capped**, and replays are rejected; - **revocation acts on the peer's own directory entries** — it does not reach nodes, and nothing local hosts a federated group. ### 7.7 Account lifecycle A user can delete their own account from Settings behind a **passphrase re-entry** — a live token may be a borrowed laptop, and the bar for something with blast radius is proof of the passphrase. An admin can delete one too. The row is **tombstoned rather than dropped**: username released, email and password hash cleared, node linking key dropped, memberships, notifications, refresh tokens, node registrations, device keys and public-swarm sources removed, active tokens refused at once by a status check rather than left to expire. Device keys go because the desktop client keeps its half: left on the tombstone, the key would refuse that installation to the next account created from it. Two things survive on purpose: - **The IP log**, for its legal retention period, and it stays attributable — 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. - **Everything on a node.** Files, the pinned identity and the keypair bundle live on machines the hub does not command — the same sovereignty that makes admission work. **Deleting a hub account is not an erasure request to the operators who host you**; the operator surface is where that happens, and the docs must say so. **Groups the account owns** decide between the two routes: - **The owner's own deletion is refused** while the account still owns groups, rather than cascading into other people's data — the owner can hand them over first. - **An administrator's deletion deletes them with the account.** It is the route an erasure ordered by an authority takes, and it cannot wait on the person it is about. Everything on the hub that references those groups goes too (`db/purge.py`, which finds the referencing tables from the schema, so none is left to fail a foreign key on PostgreSQL). Then a **signed revocation** for the account and for each group goes to every connected node: an access token already issued stays valid on a node until it expires, and the revocation is what makes the nodes refuse the account and close the groups' sessions now. A node that is offline misses it. Registration is gated by a CAPTCHA whenever one is configured — **unconditionally**, not only when some other field is absent, or the real client's ordinary request skips it. The desktop client renders the widget too. **Sessions end three ways, all admin settings in hours** (`session.*`, §7.4): - **A browser signs itself out** after `browser_idle_hours` with no input and no `