aboutsummaryrefslogtreecommitdiffstats
path: root/docs/MESHBAY_DESIGN.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/MESHBAY_DESIGN.md')
-rw-r--r--docs/MESHBAY_DESIGN.md273
1 files changed, 193 insertions, 80 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 59a7686..e9b63b5 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -168,9 +168,9 @@ document uses:
| 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** | — | — |
+| Keypair bundles (**C4**) | ✅ it holds the pepper and no bundle | ⚠️ it can fetch a bundle with a token it mints and holds the pepper: an offline passphrase search, as T3 already concedes for browsers · none to fetch for an account without browser access | ✅ no offline search: the bundle does not open without the hub's pepper — only sign-in attempts, bounded and audited · none on disk for an account without browser access | — | — |
| 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** | ✅ | ✅ |
+| Your identity keys stay yours | ✅ | ⚠️ as the row above | ✅ the bundle they hold does not open without the pepper, and a leaked bundle key opens **that node's** bundle only | ✅ | ✅ |
### 2.3 What the project must not claim
@@ -185,9 +185,12 @@ Three sentences are forbidden, each for a deliberate reason:
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.**
+- **"C4 is closed."** It is closed for an account whose identities the desktop
+ application keeps (browser access off, §3.7): nothing of them is on any node.
+ For an account with browser access the bundle is still on each node, sealed
+ under the passphrase **and** a pepper the hub holds — no operator can search it
+ offline, but an active hub can, and that is T3's adversary already. **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.
@@ -195,11 +198,12 @@ 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).
+*you*. The bundle on their disk does not let them try: it opens only with the
+passphrase and a pepper the hub hands to nobody but a session that proved the
+passphrase or a device key, so the only guesses left to them are sign-ins, which
+the hub counts and locks out. And were a bundle key to leak all the same, it
+opens **the bundle on that node** and no other. Reading what they host is by
+design; reading what *other* operators host is not, and does not follow (§3.2).
---
@@ -215,31 +219,37 @@ values**, both salted by the trimmed username:
| Value | Derivation | Consumer |
|---|---|---|
| `auth_key` | PBKDF2-SHA512, 600 000 iterations, domain `meshbay:auth:v1:<user>` | hub authentication — the hub stores an Argon2id hash of it |
-| `bundle_key` | Argon2id 128 MB / t=3 / p=1, domain `meshbay:bundle:v2:<user>` | AES-GCM key for the per-node identity bundle, held on each node |
+| `A` | Argon2id 128 MB / t=3 / p=1, salt `SHA-256("meshbay:bundle:v2:<user>")[:16]` | the passphrase's half of the bundle key (§3.7); never kept |
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.
+`A` is not a key by itself. The bundle key is `M = HKDF(A ‖ pepper, "meshbay:
+bundle-master:v3|" + account id)`, where the **pepper** is 32 random bytes the hub
+keeps per account, sealed at rest and handed out only to a session that has just
+proved the passphrase or a device key (§3.7). `M` is what a session keeps; every
+key that opens something derives from it — one per node, one for playlists.
+
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).
+can reset it from an email code. **Nobody can reset `M`**: the pepper alone opens
+nothing, and the hub 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.
+A person's identity keypair is created **at first contact with a node** and never
+reused elsewhere. A browser seals it for that node and leaves it there (§3.7); the
+desktop application keeps it, and leaves a sealed copy only if the account has
+browser access.
-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.
+The reason is blast radius. What a stolen identity 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. The bundle keys follow the same line: each node's is
+derived for that node, so one that leaks opens nothing on another.
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
@@ -533,12 +543,17 @@ 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.
+**Changing a known passphrase** re-seals every reachable node's identity bundle
+under the new `M` **before** touching the hub — if the fan-out fails, the account
+is unchanged. The pepper does not change, and the client, which does not keep it,
+asks for it with the open session (`GET /v1/users/me/bundle-pepper`). Only then is
+`POST /v1/users/password` called with the old and new `auth_key`. In a browser,
+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. The desktop
+application needs no fallback: it holds its identities, keeps the new `M` aside
+until the hub has accepted the change, and re-seals a node's bundle the next time
+it connects to it — it records which key sealed each one. Every refresh-token
+family is revoked.
**Recovering a lost passphrase** splits into what each key can reach:
@@ -551,9 +566,12 @@ to fix each one. Every refresh-token family is revoked.
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: shown once
+and there is nothing to brute-force, and no pepper for the same reason. When a
+browser writes an identity bundle to a node and holds the recovery key, a **second
+copy** is written beside it sealed under `recovery_key` (`bundle_enc_recovery`,
+additive on the wire), in the same format and bound to the same account and node.
+The desktop application writes one when the recovery key is entered on the
+Profile page, for an account with browser access. `R` is a pass-through: shown once
on screen at registration and mailed with the verification code only if the person
ticks the box for it (unticked by default), never written to any database, never
logged.
@@ -580,49 +598,117 @@ 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.
+A bundle carries **one node's** identity keys, sealed for that node, stored on it.
+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.
+
+**The bundle key has two halves, and a node holds neither.**
+
+```
+A = Argon2id(passphrase, salt = SHA-256("meshbay:bundle:v2:" + username)[:16])
+M = HKDF-SHA256(ikm = A ‖ pepper, salt = "", info = "meshbay:bundle-master:v3|" + account id)
+K_node = HKDF-SHA256(M, info = "meshbay:bundle:v3|node|" + node public key) AES-256-GCM
+K_pl = HKDF-SHA256(M, info = "meshbay:playlists:v2") AES-256-GCM
+```
+
+The **pepper** is 32 random bytes per account, created by the hub on first use,
+sealed at rest with the key that seals e-mail addresses and bound to the account
+(`auth.seal_pepper`). The hub hands it out only where the caller has just proved
+the passphrase or a device key: in the response of `POST /v1/users/login` and
+`POST /v1/users/auth`, and from `GET /v1/users/me/bundle-pepper` for a session
+that did (a stored session from before, a passphrase change). **Never** on a token
+refresh — that proves possession of a refresh token and nothing else — never to a
+node token, never inside a token, never in a log. Erasing the account clears it;
+a passphrase reset keeps it.
+
+What that buys is the reason for all of it: **the tag of a bundle on a node's disk
+is no longer an oracle for the passphrase.** An operator who copies it cannot
+test a single guess without the pepper; the guesses left to them are sign-ins,
+which the hub counts and locks out (§7.7). The same holds for playlist blobs,
+which were a second oracle when their key came from the passphrase alone.
+
+**The format is `MBK3`, bound to its account and its node:**
+
+```
+"MBK3" ‖ pepper version (1 byte) ‖ nonce (12) ‖ AES-GCM(K_node, {skEd, skX},
+ aad = "meshbay:bundle:v3|" + account id + "|" + node key)
+```
+
+A bundle copied to another node, or served for another account, does not open.
+The recovery copy (§3.6) has the same format under the recovery key, with pepper
+version 0. **Nothing else is read**: a bundle in an earlier format — sealed under
+the passphrase alone — is refused by name, never opened and never replaced by a
+new identity behind the member's back, which would leave the node pinning a key
+nobody holds. The client says so, and the way out is the operator's: `member
+unpin` (which drops the bundle) and a fresh code. That was the flag day of 0.17.0
+(§5.6).
-**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.
+**What a session keeps is `M`**, non-extractable, in IndexedDB until sign-out.
+Not the pepper and not `A`. So the hub is asked for the pepper once per sign-in,
+inside the sign-in response, and never again while the session lasts: reloads,
+reconnections and new nodes derive nothing.
-**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 desktop application keeps `M` and every node identity in its main process**
+(`meshbay-client/src/keyring.js`, stored in the OS key storage beside the device
+key). The page is told public keys and asks for signatures and X25519 agreements;
+it is never handed a private key or `M` (§8.2). The one key that crosses is the
+playlist key, which opens nothing but playlists the page shows anyway. Where the
+OS has no key storage, the application keeps its keys the way a browser does:
+identities kept in the main process and lost at the next start would leave every
+node pinning a key nobody holds.
-**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.
+**Browser access** is the account's choice of whether a browser may open its
+groups, and it is made **in the desktop application, never in a browser**:
+
+| | |
+|---|---|
+| An account created in the desktop application | **off** until the person turns it on |
+| An account created in a browser, or created before this existed | on |
+| Turning it on | the Profile page of the application, confirmed in a dialog its main process draws (§8.2) |
+| With it off | the application keeps each identity and leaves nothing on the node: no bundle is stored, and one left there before is withdrawn (`keypair_bundle_delete`) the next time the group opens |
+| With it on | the identity is sealed as above and left on the node, and sealed again when `M` has changed since |
+
+The hub keeps a **mirror** of the setting as a preference (`browser_access`),
+written by the application at each start and change. It grants nothing — no node
+reads it — and exists so a browser can say why a group will not open and name the
+way in: turn it on from the application, or approve this browser from it (device
+linking, §3.3), which gives the browser identities of its own. Were a browser able
+to turn it on, somebody holding only the passphrase could sign in on the web, flip
+it, and have the owner's own application upload the identities at its next start
+— under the pepper that same sign-in hands out.
+
+**Why Argon2id for `A`.** 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. With the pepper this matters against one
+adversary only: an active hub, which holds the pepper and can fetch a bundle with a
+token it mints. For that one, the passphrase still decides — at ~2k guesses/s a
+dictionary-and-rules run of 10⁹ candidates takes about six days on one card, and
+four random words (~52 bits) about seventy thousand years.
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
+ provenance — in the page, and in the desktop application's main process too,
+ because Electron's Node is built on BoringSSL, whose `crypto.argon2` exists and
+ refuses. 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.
+- **Never change these parameters or strings in one place.** `keyderive.js`, the
+ desktop keyring and a reference written in Python from this section are held
+ together, byte for byte and down to opening an `MBK3` bundle, by
+ `test_bundle_kdf_parity.py` and `test_desktop_keyring.py`. A mismatch does not
+ look like an error — it looks like an account nobody can open.
+- Cost is paid **once per sign-in** (≈650 ms for `A`, ≈239 ms for `auth_key`).
- 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). Withdrawing a bundle already exists on the wire (`keypair_bundle_delete`) and
-is not offered in the interface: it belongs with that decision, not before it.
+**Where C4 stands.** Closed for an account whose identities the desktop
+application keeps: nothing of them is on any node. For an account with browser
+access, bundles still sit on disks their owner does not control, and an operator
+can no longer attack them; an active hub can, which T3 already concedes for every
+browser. The signed `device_policy {allow_bundle: false}` of O3 — nodes refusing
+to store a bundle for the account — is not needed for this and stays open:
+storing one already requires a session that proved the group key.
---
@@ -635,6 +721,7 @@ User identity key Ed25519 signing, authentication — per node (Â
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)
+Bundle master key M HKDF per account, per session — passphrase ‖ hub pepper (§3.7)
Session keys X25519/HKDF per-connection, from DTLS/TLS
```
@@ -842,7 +929,9 @@ the index.
|---|---|
| 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 **64 MiB**, t=3, lanes=4 (`pw_version` 4), over the client-derived `auth_key` — RFC 9106's second recommended setting. An older hash is verified at its own version's parameters and rewritten at the current ones on the next sign-in |
-| Browser bundle key | Argon2id **128 MB**, t=3, p=1 |
+| Bundle key, passphrase half (`A`) | Argon2id **128 MB**, t=3, p=1 — the same WebAssembly build in the page and in the desktop main process |
+| Bundle pepper | **32 random bytes** per account, held by the hub, sealed at rest (§3.7) |
+| Bundle keys | `M` = HKDF-SHA256(`A` ‖ pepper, account id); one key per node and one for playlists by HKDF from `M` |
| Browser `auth_key` | PBKDF2-SHA512, **600 000** iterations |
**Why the hub verifier is 64 MiB and not more.** What it protects against is an
@@ -1302,6 +1391,18 @@ reachable on every node, which is exactly what "no compatibility switch" forbids
So the floor moved to 4.0, `client.minimum` moved to the release that carries the
new client, and the hub, node and SPA deploy together.
+**0.17.0 is a flag day with no protocol change at all**, and it follows the same
+rule. The keypair bundle is opaque to the node, so MNP did not move; what changed
+is what a client writes into it and reads out of it (`MBK3`, §3.7). A client that
+still read the older format would keep it reachable, and an older client would
+still write it — the passphrase-only seal that the pepper exists to replace. So
+the client refuses the old format by name, and `client.minimum` moved to 0.17.0
+so that no desktop client can go on writing it; the browser takes the new client
+from the hub on reload. What it costs is stated rather than migrated: each
+identity sealed in the old format is re-created on its node after `member unpin`
+and a fresh code, and a playlist the node alone held is lost — any copy a browser
+or the application still has is sealed again over it.
+
**Every *requirement* is true of every peer the client can reach.** The floor moves
with each MAJOR, so `check_version` refuses at the handshake any peer that cannot
meet one: an upload is sealed or it is not sent; a transfer has a real lease or it
@@ -2128,7 +2229,7 @@ A user can delete their own account from Settings behind a **passphrase re-entry
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,
+hash cleared, bundle pepper cleared, node linking key dropped, memberships, notifications, refresh tokens,
node registrations and device keys 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
@@ -2225,8 +2326,9 @@ the docs never claim end-to-end *integrity* for that path.
**Several browsers, one identity per node.** A browser keeps nothing durable the
user controls, so the identity it creates for a node is left with that node,
-encrypted under the passphrase. Any other browser recovers it there with the
-passphrase alone: same identity, same pin, no second code. Joining a *different*
+sealed under the passphrase and the hub's pepper (§3.7). Any other browser
+recovers it there with the passphrase, which is also what gets it the pepper: same
+identity, same pin, no second code. Joining a *different*
node creates a different key and needs that operator's code — the first contact it
has always needed. This is what makes the product behave the way people expect, and
it is also **C4**, with a blast radius of one node.
@@ -2279,6 +2381,13 @@ What running it establishes, and what each fact costs:
language and nothing else. An in-page confirmation is one a script in the page
can answer for itself. **Every channel answers only the packaged page's top-level
document.**
+- **The account's keys live in the main process too** (`keyring.js`, §3.7): the
+ bundle master key `M` and the identity on every node. The transport in the page
+ sees an identity as two public keys, a signature and an X25519 agreement
+ (`transport.js`, `_identityFromKeys` in a browser, `_nativeIdentityHandle`
+ here), so nothing above it knows where the keys are — and no code in the page
+ reads a private key outside that object (`test_identity_seam.py`). Whether a
+ node keeps a sealed copy is the account's browser access, decided here.
- **OS-backed secret storage is real on a desktop and honest without one.** With a
keyring it is keyring-backed; headless, the same code reports unavailable and
**refuses to store rather than downgrading silently**.
@@ -2910,10 +3019,13 @@ to prevent.
**The key is the one thing that must not be got wrong.** Identity keys are per node
(§3.2), so a blob encrypted under one is unreadable from every other node — the
precise opposite of the requirement. The only secret an account holds *everywhere*
-is the bundle key, so `playlist_key = HKDF(bundle_key, info =
-"meshbay:playlists:v1")`: one derivation at sign-in, two handles, no second
-Argon2 run, and a purpose-separated subkey rather than the bundle key reused with a
-different AAD (§4.4's rule). The nonce is 96 random bits and never a counter, for
+is the bundle master key, so `playlist_key = HKDF(M, info = "meshbay:playlists:v2")`
+(§3.7): no second Argon2 run, and a purpose-separated subkey rather than a bundle
+key reused with a different AAD (§4.4's rule). Being under `M`, a blob is no
+oracle for the passphrase either — `v1` came from the passphrase alone and was
+exactly that. A blob that does not open is not an older copy of anything: the
+local copy is sealed again over it, even at an equal revision, which is what
+carried every playlist across the key change. The nonce is 96 random bits and never a counter, for
exactly the reason chat's is (§4.5): two devices of one account derive the *same*
key, which is the point. The AAD names the blob's *kind*, so one playlist's body
cannot be served in place of another's.
@@ -3263,7 +3375,7 @@ be understood, not so the incident can be retold.
| **C1** | **All content travels over the authenticated protocol.** The node exposes no unauthenticated HTTP surface; the per-group file API that served private indexes and plaintext files on all interfaces was deleted rather than repaired, because it duplicated MNP without any of its controls |
| **C2** | A node's signaling identity is **resolved against the database and derived from it**, never taken from the client's first message (§7.2) |
| **C3** | **Authentication is mutual**: the node proves key possession over the client's nonce and signs the transcript, and the client verifies both and pins the key (§5.2) |
-| **C4** | **Keypair bundles are per node, Argon2id-protected, and closed for native devices** — and **open for any account that also uses a browser** (§3.7) |
+| **C4** | **Keypair bundles are per node and sealed under the passphrase and a hub-held pepper**, so an operator cannot search one offline; an account whose identities the desktop application keeps leaves none at all (§3.7) |
| **C5** | The two halves below, cited together where a comment means "a member must not be able to write what the node then trusts" |
| **C5a** | Uploads cannot overwrite, are allowlisted, ordered and capped, and ownership is signed by the uploader (§6.4) |
| **C5b** | **No key material arrives from outside.** The member-supplied bundle message does not exist; the node generates every copy of a group key itself (§4.2) |
@@ -3459,7 +3571,7 @@ had already been asked.
| **E10** | **The credential a member presents to a node opens nothing else.** A member hands its handshake token to the node operator (in the threat model), so it is a short-lived node-audience token (`aud = MNP_AUD`, §5.2): the session token that opens the hub API is never disclosed to a node, and the MNP token names the one node it is for (`node` claim) so it cannot be presented to another node the member uses. Established by the MNP 4.0 flag day (§5.6) |
| **O1** | Initial key setup in the pre-proof window — deferred; that window is where C4 and C5b came from |
| **O2** | A LAN enrolment door — one endpoint, bounded window, one-time code, closing permanently on success |
-| **O3** | `device_policy {allow_bundle: false}`, signed by a pinned key — **the mechanism that actually closes C4** (§3.7) |
+| **O3** | `device_policy {allow_bundle: false}`, signed by a pinned key — nodes refusing a bundle for the account. Not needed to close C4 for an account without browser access, which already leaves none (§3.7); open |
| **O4** | Isolating the node-admin panel from the process holding user keys. **Narrowed**: the panel reaches the node through named operations, and what widens the node is confirmed natively (§8.2); it still runs in the renderer that parses node content |
| **O5** | An unlock key in the environment, for the **node** |
| **O6** | The engine version floor, verified rather than assumed |
@@ -3495,8 +3607,8 @@ had already been asked.
17. **`punch_nat()` is a direct-connection helper, not a traversal stack** (§5.1).
18. **The desktop shell is Electron.** What is unchanged and non-negotiable: **UI assets ship inside the package and load from disk** (§8.2).
19. **A second device is admitted by device linking, not by an operator code** (§3.3).
-20. **Private keys never leave the device on native clients.** Qualified: a browser has no durable storage of its own and still needs a bundle on each node, so C4 closes for an *account* only when it opts out of browser use.
-21. **Hub minimisation is enforced by an acceptance test, not by policy.** The hub must be *unable* to see keys, content or file listings.
+20. **Private keys never leave the device on native clients**, and never reach their page: the desktop main process holds them (§3.7, §8.2). Qualified: a browser has no durable storage of its own and still needs a bundle on each node, so C4 closes for an *account* when its browser access is off, which is the default for an account created in the application.
+21. **Hub minimisation is enforced by an acceptance test, not by policy.** The hub must be *unable* to see keys, content or file listings. The one secret it holds per account, the bundle pepper, is half a key and opens nothing alone: the other half is the passphrase, and what it would open sits on the nodes (§3.7).
22. **No new code exchanges between people.** Safety numbers are refused for identity verification, permanently. The device-linking code is between a person's own devices and is unaffected. The total user-visible cost of the whole authorship story is **one notice**: *"this account's key changed"*.
23. **A member's credential to a node is separate from its hub credential and bound to that node** (**E10**, §5.2). The handshake carries a short-lived `aud = MNP_AUD` token that names the node it is for, never the hub session token; the hub API accepts only its own audience, and each node accepts only a token that names it. A node operator therefore holds nothing that acts at the hub or at another node.
@@ -3576,7 +3688,8 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows.
| Item | Status |
|---|---|
-| **C4** for browser-using accounts | Open until the signed bundle opt-out ships (O3) |
+| **C4** for accounts with browser access | Closed against operators by the pepper; **open against an active hub**, which holds the pepper and can fetch a bundle with a token it mints — the adversary T3 already concedes for browsers (§3.7) |
+| **A desktop that never held an identity cannot open its recovery copy** | The application opens a node's passphrase copy, not the recovery copy: after a reset, an identity it never held is recovered from a browser (§3.6). And an identity the application mints gets a recovery copy only when the recovery key is entered on its Profile page with browser access on |
| **T3** for browser users | **Accepted permanently.** Removed for native clients, and that removal's value depends on reproducible builds |
| **Hub identity pinning** (O13) | Nothing pins the hub's key. Bounded, because a substituted hub can neither read content nor ship code to a native client |
| **Aggregate upload quota** | Per-file caps exist and the operator sets theirs (§6.4); a per-user or per-group total does not |