aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/MESHBAY_DESIGN.md273
-rw-r--r--docs/MESHBAY_NODE_PROTOCOL.md65
-rw-r--r--docs/USERGUIDE.md43
-rw-r--r--docs/playlists.md7
4 files changed, 284 insertions, 104 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 |
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md
index b760354..9efef08 100644
--- a/docs/MESHBAY_NODE_PROTOCOL.md
+++ b/docs/MESHBAY_NODE_PROTOCOL.md
@@ -636,10 +636,12 @@ Exceeding the fetch budget is audited as `pre-proof fetch flood` and answered
`Too many requests`. Every fetch in this window is written to the audit log with the
message type, because this is a disclosure surface a hub that forges a JWT can reach:
the hub mints the tokens, so it can present one for any account, and what it can then
-ask for is that account's *encrypted* keypair bundle. The bundle is useless without the
-account passphrase, which is why the window is bounded and audited rather than closed
-— and it closes for good when clients stop storing keypair bundles on other people's
-nodes.
+ask for is that account's *sealed* keypair bundle. The bundle opens only with the
+account passphrase and the account's pepper (`MESHBAY_DESIGN.md` §3.7) — and the hub
+holds the pepper, so this is the one place where a hub can still search a passphrase
+offline, which is why the window is bounded and audited rather than closed. It is
+empty for an account whose browser access is off: the desktop application keeps its
+identities and leaves no bundle on any node.
### 7.1 Identity bundles
@@ -649,7 +651,7 @@ nodes.
|<- keypair_bundle_resp {v, found, |
| [bundle_enc], [bundle_enc_recovery]} ----------|
| |
- | decrypt bundle_enc with the passphrase-derived bundle key,
+ | open bundle_enc with this node's bundle key (K_node),
| or bundle_enc_recovery with the recovery key
| |
|-- keypair_bundle_store {v, bundle_enc, | after minting or re-wrapping
@@ -659,26 +661,52 @@ nodes.
|-- keypair_bundle_delete {v} ---------------------->| withdraw the backup
```
-* The bundle is opaque to the node: it is encrypted client-side under a key derived
- from the account passphrase (`keyderive.js`), and optionally a second copy under the
- account recovery key. The node stores bytes and serves them back to the same
- `user_id`.
+The node stores both fields as it receives them. What the client puts in them is:
+
+```
+bundle_enc = base64( "MBK3" ‖ pepper version (1 byte) ‖ nonce (12) ‖
+ AES-256-GCM(K_node, JSON {skEd, skX}, aad) )
+aad = "meshbay:bundle:v3|" + account id + "|" + node public key (base64)
+K_node = HKDF-SHA256(M, info = "meshbay:bundle:v3|node|" + node public key)
+M = HKDF-SHA256(A ‖ pepper, info = "meshbay:bundle-master:v3|" + account id)
+```
+
+`bundle_enc_recovery` has the same form under the recovery key, with pepper version
+0. `skEd` and `skX` are PKCS#8, base64. The node public key is the one the node
+proved in its signed challenge (§5), so a bundle is sealed for — and opens only on —
+the node that proved it, for the account that stored it.
+
+* The bundle is opaque to the node: it is sealed client-side as above
+ (`keyderive.js` in a browser, `keyring.js` in the desktop application's main
+ process), and optionally a second copy under the account recovery key. The node
+ stores bytes and serves them back to the same `user_id`.
+* **A client reads `MBK3` and nothing else.** A bundle in an earlier format was
+ sealed under the passphrase alone; the client refuses it by name
+ (`bundle_format_retired`) and does not mint a replacement identity, which would
+ leave the node pinning a key nobody holds. `member unpin` drops the bundle, and
+ the next join is a first contact.
* `keypair_bundle_store` is accepted **after** authentication (it is not in the
pre-proof list); the fetch is what happens before.
* A `store` omitting `bundle_enc_recovery` leaves any existing recovery copy in place.
-* Identity keys are **per node**. There is nothing to carry between nodes, and an
- operator who cracks the copy on their own disk gets a key that opens nothing
+* Identity keys are **per node**, and so are bundle keys. There is nothing to carry
+ between nodes, and an identity or a `K_node` taken from one node opens nothing
anywhere else.
-* `keypair_bundle_delete` is **reserved for `device_policy`** (`MESHBAY_DESIGN.md`
- §3.7, open item O3): the node honours it, and no interface sends it yet. Withdrawing
- the bundle is only safe once the account has chosen not to need it from a browser —
- a lone button would strand the next browser that signs in.
+* `keypair_bundle_delete` is sent by the desktop application for an account whose
+ browser access is off (`MESHBAY_DESIGN.md` §3.7): after connecting, it withdraws any
+ bundle a node still holds for the account, and stores none. With browser access on,
+ it stores the identity it holds, sealed as above, and seals it again when the
+ account's `M` has changed since — which is how a passphrase change reaches a node
+ that was offline when it happened. Browser access is decided in the application,
+ never on the wire: nodes do not read it.
### 7.1a Per-account blobs (MNP 3.1)
The same shape as a keypair bundle with a different payload — playlists today
(`docs/playlists.md` §8). The node stores bytes it cannot read for an account it
-already holds a bundle for, so this adds **no new trust boundary**.
+already admits, so this adds **no new trust boundary**. The client seals them under
+`K_pl = HKDF-SHA256(M, info = "meshbay:playlists:v2")` — the one key every node of
+the account shares, since a playlist is read from any of them — and a blob that does
+not open under it is overwritten with the client's own copy, never treated as newer.
```
C N
@@ -2216,6 +2244,11 @@ version can no longer connect" instead. An unreachable hub is deliberately *not*
as too old: a captive portal or a closed laptop must not make starting the application
impossible.
+`client.minimum` also moves for a change the protocol does not see. The keypair bundle
+is opaque to the node, so the `MBK3` format (§7.1) moved no protocol version — but a
+desktop client older than 0.17.0 still writes the format it replaced, so 0.17.0 is the
+minimum.
+
**The version a peer announces is only as good as the number it ships with.** Every
package in the tree carries one version, and a test fails if two disagree — a client
announcing a number from a different scheme sorts wherever that scheme puts it, and
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
index 7eeafbf..eb9452e 100644
--- a/docs/USERGUIDE.md
+++ b/docs/USERGUIDE.md
@@ -185,6 +185,10 @@ identity: it asks for your passphrase, unlocks the copy of your keys kept
there, and you are in. No second code, nobody to ask — which is another reason
the passphrase is worth choosing well.
+That copy exists only if your account allows browsers. An account created in the
+desktop application does not until you say so (below): a browser then cannot
+open your groups with the passphrase alone, and tells you why.
+
### The desktop application
Worth installing if you use MeshBay more than occasionally:
@@ -193,7 +197,7 @@ Worth installing if you use MeshBay more than occasionally:
|---|---|---|
| Install | none | a package |
| Interface comes from | the hub, on every visit | inside the package, from disk |
-| Keys | in the browser, plus a copy on each node | in the OS keyring, never bundled anywhere |
+| Keys | in the browser, plus a copy on each node | kept by the application in the OS keyring; a copy on each node only if you allow browsers |
| Downloads | to disk where the browser allows it | native, streamed, no size limit |
| Casting to a TV | — | yes |
@@ -203,10 +207,20 @@ any. So the application is the one to prefer for anything you care about. The
browser stays, and is a perfectly reasonable way to use MeshBay — being able to
open a group on someone else's laptop with nothing installed is worth having.
+**Browser access** (Profile → Browser access, in the application) decides whether
+a browser can open your groups with your passphrase. With it off — the default for
+an account created in the application — your keys stay on this computer and
+nothing of them is left on anybody's node. With it on, the application leaves a
+locked copy on each node, as a browser would. The change reaches each group the
+next time it opens. A browser can also be approved from the application for
+itself, group by group: the browser shows a linking code (*Get a linking code*),
+and you enter it in the application under *Your devices on this node*. That gives
+the browser keys of its own without turning browser access on.
+
The application asks in a small window of its own, not in the page, before it
hosts a group on this computer, shares a folder you did not pick in its folder
-dialog, replaces a group's key, clears the denylist, or points the node at
-another account. A folder you pick in the folder dialog is not asked about
+dialog, replaces a group's key, clears the denylist, turns browser access on, or
+points the node at another account. A folder you pick in the folder dialog is not asked about
twice. That window belongs to the application, so nothing displayed in the page
— which shows content from other people's nodes — can answer it for you.
@@ -745,9 +759,12 @@ deciding what belongs in a group and what is better kept elsewhere.
browser downloads its code from the hub every time you open it; the
application carries its own and never asks the hub for any. For anything you
would rather not stake on the hub behaving, prefer the application.
-- **Your passphrase is what guards your keys.** A copy of them, locked with it,
- sits on each machine you have joined. A long one puts that out of reach; a
- guessable one does not. This is the single thing most worth getting right.
+- **Your passphrase is what guards your keys.** When your account allows
+ browsers, a copy of them sits on each machine you have joined, locked with your
+ passphrase and a second secret your hub keeps, so the people running those
+ machines cannot sit and guess at it. Whoever runs the hub holds that second
+ secret, though, and for them a guessable passphrase is still a weak one. A long
+ one puts it out of reach. This is the single thing most worth getting right.
- **An open group is open.** Anybody can walk into one, which is what open
means. Invite-only is the default, and is what you want for anything personal.
- **The hub sees who is in which group, and when.** Never what was said or
@@ -792,8 +809,18 @@ namespace, so a volume mounted after it started is invisible to it.
The browser is not paired. `meshbay-node operator pair`, then enter the code in
the Members tab.
-**A member changed their passphrase while the node was down.**
-`meshbay-node member unpin <user>`, then a fresh invitation code.
+**A member changed their passphrase in a browser while the node was down.**
+`meshbay-node member unpin <user>`, then a fresh invitation code. The desktop
+application catches up by itself the next time it reaches the node.
+
+**"This node holds your identity in a format this version no longer reads."**
+The member's keys on that node were locked the way versions before 0.17 did it,
+which this version refuses. `meshbay-node member unpin <user>` on the node, then a
+fresh invitation code.
+
+**"Browser access is off for this account."**
+The account keeps its keys in the desktop application. Turn browser access on
+there (Profile), or approve this browser from it.
**Video says the codec is not supported.**
The source codec has no decoder in that browser and transcoding is off, or
diff --git a/docs/playlists.md b/docs/playlists.md
index c41c71f..f1f6b67 100644
--- a/docs/playlists.md
+++ b/docs/playlists.md
@@ -220,6 +220,13 @@ bundles. So:
playlist_key = HKDF-SHA256(bundle_key_v2, info = "meshbay:playlists:v1")
```
+> **Superseded (0.17.0).** A key from the passphrase alone made every sealed
+> blob an offline oracle for it on every node. The key is now
+> `HKDF-SHA256(M, info = "meshbay:playlists:v2")`, where `M` folds in a pepper
+> the hub holds, and a blob that does not open under it is overwritten with the
+> local copy — `MESHBAY_DESIGN.md` §3.7 and §9.10. The rest of this section is
+> the reasoning as it stood, and still holds for `M`.
+
Three consequences, each of which is a line of code somewhere:
- **`deriveEncryptionKey` must return an HKDF handle as well as the AES-GCM