aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/MESHBAY_DESIGN.md381
-rw-r--r--docs/MESHBAY_NODE_PROTOCOL.md90
-rw-r--r--docs/USERGUIDE.md72
-rw-r--r--docs/playlists.md7
4 files changed, 419 insertions, 131 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 06ba2b5..e9b63b5 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -17,7 +17,7 @@
> it. §13 is the register of those labels.
>
> Wire versions at the time of writing: **MNP 5.0** (oldest peer accepted 4.0),
-> **MHP 0.1**, packages **0.16.0**. The normative source for the wire format is
+> **MHP 0.1**, packages **0.17.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.
@@ -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
@@ -361,7 +371,8 @@ 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 accepts the invitation on the hub (§7.3), then 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
@@ -378,7 +389,9 @@ Four properties, each load-bearing:
account in one group — except an invitation link's, which names its account
when it is redeemed (below) — 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.
+ connection and by wrong-code limits per account and node-wide — consulted only
+ when a code is tried, never for a device the node already pinned — 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`.
@@ -530,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:
@@ -548,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.
@@ -577,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)
+```
-**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.
+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).
-**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.
+**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 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.
+**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.
+
+**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.
---
@@ -632,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
```
@@ -839,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
@@ -997,13 +1089,21 @@ already fails the key proof. Pinning covers the case where an attacker *holds* t
group key — an ex-member, a leaked key — and swaps the node underneath, which the
proof alone cannot distinguish from the genuine node.
+What pinning does not cover is a **second node** holding the key: the pin is per
+node, and a node with its own identity is a first sight. A member's node is exactly
+that — every member holds the group key — so what keeps it from being offered to
+clients as the group's host is the hub's registration rule (§7.2, **AV32**), not the
+pin.
+
**The token the member presents is a node-audience token, never the hub session
token (E10).** A member hands whatever it presents here to the node operator,
who is in the threat model, so the credential must open nothing at the hub. The
hub signs two audiences with its one key: a session token (`aud` = the hub API)
for `hubFetch` and signaling, and a short-lived **MNP token** (`aud = MNP_AUD`,
-from `POST /v1/nodes/mnp-token`) that carries the member's `sub`, `groups` and
-`jti` and is the only thing presented in the handshake. The node binds `MNP_AUD`
+from `POST /v1/nodes/mnp-token`) that carries the member's `sub`, `jti` and **the
+one group the connection is for** — never the member's other groups, which the
+operator it is handed to has no business learning — and is the only thing
+presented in the handshake. The node binds `MNP_AUD`
when it decodes, so a session token is refused here; the hub API binds its own
audience and requires `exp`, `sub` and a known `scope`, so an MNP token captured
by an operator is refused there, and so is any other hub-signed token that is not a
@@ -1036,6 +1136,14 @@ requester and it therefore grants nothing across accounts.
- 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**).
+- **The roster must admit the account for the group** before a session opens,
+ after the proof and whatever the token says (`not_authorized_for_group`). The
+ key proves possession and the token the hub's view of membership; neither is
+ the node's own answer. A member revoked or unpinned here but still a member on
+ the hub, holding the key, is therefore refused a session — and so is not
+ handed the chat epoch their removal opens. A removal, from any door (MNP, the
+ node page, the CLI), opens a new chat epoch in each group the person could read
+ and closes every connection they hold.
**Refusals carry a code**, not only a sentence, because a client can act on a code.
`not_a_member` means the hub did not count the account a member when it minted the
@@ -1283,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
@@ -1622,8 +1742,13 @@ blake3-keyed store as any other thumbnail. **The new surface is SSRF**, because
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.
+and the address the connection landed on re-checked before the body is read (the
+request itself has been sent by then, so this refuses the answer, not the
+request). The body is read as a stream and stops at its cap — 512 KiB of page,
+2 MiB of image — counted after decompression, so a small compressed response is
+bounded like any other; an image declared larger than its cap is not read, and
+the whole fetch has a 15-second deadline. Previews are rate-limited per
+connection and node-wide. 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.
@@ -1813,7 +1938,13 @@ account has and when each last signed in.
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
+live registration (**C2**). The ceiling is **the groups its account owns, plus those
+whose owner approved that node** (`group_hosts`) — not the groups its account belongs
+to. Every member holds the group key, so a member's node passes the handshake exactly
+as the real host would, and clients keep the first registered node that completes it:
+membership as the ceiling would let any member stand in for the host. A node that
+claims a group it may not host is recorded as a request, and the owner is notified
+once and approves or refuses it from the group's settings (**AV32**). 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
@@ -1922,7 +2053,18 @@ row and nothing else. Answering any authenticated account — as it did while on
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.
+invitation is accepted before the invitee's client asks for a node.
+
+**Being added to a group is an invitation, not a membership** (**AV33**). An owner
+adding a username — from the Members tab or `meshbay-node member invite` — writes a
+`group_invitations` row; the account becomes a member when it accepts on its home
+page. Until then the group is not in its sidebar or Search, is named in none of its
+tokens, and its nodes refuse it signaling like any non-member's. The step exists
+because a membership is what makes a client dial a group's nodes: written by
+someone else, it would let any account point any other account's client at a node
+of its choosing — which learns that client's address and receives its first-join
+identity bundle. Redeeming an invitation link, joining an open group and creating a
+group are the account's own acts and write the membership directly.
### 7.4 Instance policy
@@ -1984,6 +2126,17 @@ they received, so the hub and the nodes then disagree until each operator clears
**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`.
+**The configured administrators are accounts, not names.** `hub.toml`'s
+`admin_usernames` names the accounts to make administrators, and each name is
+pinned (`admin_pins`) to the first active account seen holding it — at start-up,
+or at that account's first request if it registers while the hub runs. The pin
+is what grants the role. A username is not an identity: deleting an account
+releases its name, and a list of names used to hand the administrator role to
+whoever registered a freed one next. A pinned name that changes hands now grants
+nothing, across restarts too. Removing a name from the list drops its pin at the
+next start, so handing a listed name to a new account is: remove it, restart,
+list it again, restart.
+
**A file is reported by a member of the public group it was seen in, and an
administrator decides.** An unauthenticated endpoint that blocklists a content hash
after two reports is a network-wide censorship and DoS primitive for anyone who
@@ -2076,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
@@ -2173,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.
@@ -2209,7 +2363,31 @@ What running it establishes, and what each fact costs:
- **The device's hub key lives in the main process, never in the renderer.**
Generated, stored and used there; the interface asks for a signature and is never
handed a key. Same rule as the save dialog, and for the same reason: the renderer
- parses decrypted content from nodes, which is attacker-controlled input.
+ parses decrypted content from nodes, which is attacker-controlled input. The
+ secret store that holds it is not reachable from the page either: a generic
+ read and write by name was a way to take the key and to replace it, and nothing
+ in the interface used it.
+- **The page administers the local node by operation, never by route.** It names
+ one of the operations the interface performs (`NODE_OPS` in `main.js`); the main
+ process checks the arguments — ids are ids, names are encoded — builds the
+ request and adds the node's token. `node:start` writes the hub this application
+ is signed in to and a username the hub would register, never a value the page
+ supplies verbatim. **What widens what the node shares or admits, or replaces its
+ group key, is confirmed by a dialog the main process draws**: hosting a group,
+ sharing a folder not chosen in the native folder picker (one chosen there is its
+ own confirmation, so the ordinary path asks nothing twice), rotating the key,
+ clearing the denylist, and pointing the node at another account. The words come
+ from the interface's catalogues, read by the main process; the page sets the
+ 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**.
@@ -2295,6 +2473,14 @@ Specific rules the download path is built on:
the frame source policy, the frame-ancestors policy and the legacy frame option.
Same-origin framing is what a streamed download needs; refusing every foreign
origin is unaffected.
+- **A downloaded file is opened in a tab only under a type that runs nothing.**
+ A tab on a `blob:` URL is a document of the hub's origin, with its session and
+ its keys; measured in Chrome and Firefox, HTML typed `text/html` runs there and
+ the same bytes typed `text/plain` do not. So "Open" is offered for PDFs, raster
+ images, audio, video and plain text, under a type chosen from the name — never
+ the one the browser would guess from the bytes — and not at all otherwise
+ (`downloads.openInTab`). Like resumability, whether a row can be opened is
+ declared by its target.
**Pause and resume, and where resumability actually lives.** A pause button that
quietly restarts a download from zero is worse than no pause button, so the
@@ -2833,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.
@@ -3186,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) |
@@ -3197,7 +3386,7 @@ be understood, not so the incident can be retold.
| Label | The rule it names |
|---|---|
| **H1** | **Per-group isolation on a multi-group node**: the chat store, the peer registry and the broadcast set are per group |
-| **H2** | Every value that originates outside the node — filenames chosen by members, usernames originating at the hub — is escaped where it is rendered. A CSP contains exfiltration but cannot prevent injected inline script, so escaping is the actual fix |
+| **H2** | Every value that originates outside the node — filenames chosen by members, usernames originating at the hub — is escaped where it is rendered. A CSP contains exfiltration — `connect-src` names the page's own origin and reCAPTCHA's, nothing else — but cannot prevent injected inline script, so escaping is the actual fix. The reCAPTCHA hosts in `script-src` remain a known gadget risk: a nonce policy would remove it, and the application's page, served from disk, cannot carry one |
| **H3** | **No public key is ever fetched from a directory to wrap a group key for.** The node wraps for a key the recipient proved possession of, bound to an account by a code the hub never sees (§3.4) |
| **H4** | Revocation reaches nodes, drops live sessions, and **persists across a restart** (§7.5) |
| **H5** | An admin challenge is a **structured, domain-separated transcript naming the operation and subject**, and the client refuses to sign anything that is not what the user asked for (§5.4) |
@@ -3310,7 +3499,7 @@ had already been asked.
| **AV3** | **A node speaks only for the groups it is registered for.** `chat_notify` names a group and is checked against that node's set before a notification is written for anyone, and it is rate-limited per node — the fan-out is one write per member |
| **AV4** | **Nobody names a third party's address.** Where a peer is comes from its node record, stamped with the address its announce arrived from |
| **AV5** | **An answer is accepted only from the node the offer was sent to.** A `peer_id` is bound to its node, so no connected node can resolve another's pending offer |
-| **AV6** | **A relay proves possession of its approved key.** A public key is not a password, and the register call is unauthenticated by design — it is not a user — so the proof is the only thing standing between a stranger and where nodes send relayed traffic |
+| **AV6** | **A write that decides where other people's traffic goes proves possession of a key, never presents one.** A public key is not a password. The relay registry this was written for has been removed — nothing called it, and no TURN relay is needed (§11.1) — and the rule stands for whatever replaces it |
| **AV7** | **A node bounds how many peers it holds and how long an unproven one lasts.** The hub's cap is per calling account, which is a limit on each member and not on the machine, so without this an operator's exposure grew with the size of their groups |
| **AV8** | **One account cannot make the hub mail another at will.** The invitation email's subject comes from the group row, never from the request, and the endpoint is metered |
| **AV9** | **No mail is sent from the event loop.** `smtplib` is synchronous and waits up to ten seconds; called from an async handler that wait is the whole instance's, not one request's. Every send goes through `mail.send_off_loop`. **Argon2 is held to the same rule**: every derivation runs on one dedicated worker thread (`auth.*_off_loop`), never on the loop and never two at a time, because two concurrent `lanes=4` derivations deadlock in OpenSSL. **So is the node's disk**: every filesystem call on a group's content — the stat as much as the read, since a stat is what wakes a sleeping disk — goes through `roots.off_disk`, onto one worker thread per root set. A spun-down or network-mounted root answers its first syscall in seconds, and on the loop that is every group, every stream and the hub socket waiting for a platter. **ffmpeg's own output too**, through `asyncio.to_thread` rather than that per-root thread: a temp file is not a group root and has no platter to serialise against, but a whole transcode read inline is still tens of megabytes of blocking read |
@@ -3335,6 +3524,8 @@ had already been asked.
| **AV29** | **An invitation link is bounded on both halves and its mail on the sender** (§3.4, §7.3). Twenty outstanding per group on the node (bearer codes) and on the hub (tickets); and because a link mail reaches an address the hub has no relationship with, at the request of anyone who owns a group, it is counted **per sending account per day** (`mail.invite_link_daily_cap`, 10), under the recipient and instance bounds and outside the recovery reserve (`invite_link` is not a recovery purpose) |
| **AV28** | **How many node keys one account may announce is bounded** (§7.2). Each is a row plus an IP-log row under a one-year retention, so an account in a loop writes a year of storage on the operator's disk having paid only for signatures. Proof of possession (**M8**) settles whose key it is and not how many. Counted only where a row is added: re-announcing a key already held keeps working at the ceiling, or a node that reached it could never refresh its address again |
| **AV30** | **What one member's offers cost a node is bounded per account and per node, and the bound admits the heaviest ordinary account** (§7.2). Each offer makes the node allocate a peer connection. A budget of 120 per node refilled at two a second bounds a member there without touching their other nodes, and it is counted by account because a mobile carrier shares one IPv4 address among many subscribers. Pending offers are capped at 32 per account. Both refusals carry `Retry-After` and the client retries them, because a refused offer otherwise reads as a node that is down |
+| **AV32** | **A node hosts a group because its owner said so, not because its account belongs to it** (§7.2). Every member holds the key, so a member's node passes the handshake like the real host and could be the one a client keeps. A node may claim the groups its account owns and those whose owner approved it; any other claim is a pending request the owner sees |
+| **AV33** | **Nobody is made a member without saying yes** (§7.3). A membership makes the account's client list the group, name it in its tokens and dial its nodes, so an owner's addition is an invitation until the invitee accepts it. The MNP token names only the group it is minted for |
| **AV31** | **What waits for a signature is bounded** (§5.4). Any authenticated member can ask for an admin challenge, since the signature is checked afterwards, and a pending challenge kept its whole request until answered — measured, 200 requests of 1 MiB held 400 MiB for the life of one connection. At most eight pending per connection, 64 KiB each, expired ones dropped |
### 13.6 Chat design findings
@@ -3380,8 +3571,8 @@ 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) |
-| **O4** | Isolating the node-admin panel from the process holding user keys |
+| **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 |
| **O8** | A minimum client version in the hub version endpoint — **done** (§5.6) |
@@ -3416,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.
@@ -3497,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 |
@@ -3505,12 +3697,11 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows.
| Forward secrecy in group chat | **Given up deliberately and on the record** (§4.5). If it becomes a requirement it belongs in 1:1 DM |
| Metadata at the hub | Membership, and who posted in which group and when. A known leak, not a solved problem (§7.1) |
| **QUIC** | Off by default, and **not at parity**: it serves the index and file chunks with no transfer lease, no leaseless ceiling, no root-availability check and no content blocklist, does its file I/O on the event loop, and returns exception text to the peer (**L3**). No client speaks it. Either it comes to parity or it goes; until then §5.1's "chat is the only gap" is the one sentence here that overstates the code |
-| **The relay registry** | **Closed in the code**: `relay.RELAYS_ENABLED` is False and every `/v1/relays` route answers 503, as federation does. Nothing in the tree calls them, node or client, and §11.1 measured two ISPs with no TURN relay needed. Kept code that nothing calls is what **L7** says not to keep; it stays only as the proof-of-possession design (**AV6**) until a node needs a relay or it is deleted |
| **A very high bitrate wedges the player against a small buffer ceiling** | Where even the *floor* read-ahead does not fit — ninety seconds plus the minute kept behind, at the file's bitrate, above what the engine will hold — the film stalls: measured on the harness at 9.3 Mbit/s against a 100 MB ceiling, 100.8 s of film played in 900 s of wall clock. **Predates the byte budget and is unchanged by it**, to the tenth of a second; what the budget did change there is the refusal count, 1560 → 2. The fix is not a bound at all, it is a second stage of buffer outside the SourceBuffer, which means gating the append path — the riskiest change in this area and not one to make alongside another |
| **The reconnect backoff only wakes on `visibilitychange`** | So a tab that stays visible through an outage — which is what a screen wake lock guarantees while a film is playing — waits out the full backoff, up to 30 s, after the network is already back. Nothing listens for `online` |
| **Per-device revocation has no CLI** | A device is revoked over MNP (`roster.revoke_device`), from a device the node has already pinned. On a headless node the operator's only lever is `member unpin`, which removes **every** device of that account — so the per-device control the roster is built around is reachable from an interface and from nowhere else. §6.7 listed a `meshbay-node member device list\|revoke` verb that was never written, and that listing is how this was found: `USERGUIDE.md` was the first document written by reading the CLI rather than this specification, and the verb it copied out did not run |
| **Migrations run on SQLite only** | The chain reaches head and agrees with the models there (§12), which is not where it ships. **The exposure is one revision deep, not the whole chain**: every revision behind the first packaged release was development that no installation ever ran, so nothing replays them on PostgreSQL. What is unguarded is the *next* migration — a default, an index type or a constraint PostgreSQL refuses reaches a deploy without the suite saying so |
-| **The loopback path removes access without writing an audit entry** | `ops.revoke_member` and `ops.unpin_member` log to the daemon's log and nothing to `audit.db`; the MNP admin handlers doing the same work audit `member_revoke` and `member_unpin`. So a removal made from the node page or the CLI — the two doors an operator sitting at their own machine actually uses — leaves the journal showing an admission and then, whenever that person next connects, a `join_refused` with nothing in between to explain it. §5.4's signed transcript is not what is missing: a loopback caller is authorized by being on localhost with the run token and signs nothing, so the gap is the record, not the authority. Found by reading a node's audit log for a refusal whose cause was six hours earlier and unrecorded |
+| **The loopback path removes access without writing an audit entry** | `ops.revoke_member` and `ops.unpin_member` log to the daemon's log and nothing to `audit.db`; the MNP admin handlers doing the same work audit `member_revoke` and `member_unpin`. So a removal made from the node page or the CLI — the two doors an operator sitting at their own machine actually uses — leaves the journal showing an admission and then, whenever that person next connects, an `auth_failed` ("not admitted by the roster") with nothing in between to explain it. §5.4's signed transcript is not what is missing: a loopback caller is authorized by being on localhost with the run token and signs nothing, so the gap is the record, not the authority. Found by reading a node's audit log for a refusal whose cause was six hours earlier and unrecorded |
| **The Create group wizard calls two hooks after an early return** | `CreateGroupWizard` (`create-group-page.js`) returns during node detection, before its `useRef`/`useEffect` for provisioning, so the hook count changes between renders. Preact tolerates a list that grows, and nothing is known to break; `test_hook_ordering.py` checks declaration order, not this. Found while tracing the frozen-fields report, which had another cause (`ask.js`) |
| **A node key is read from the terminal or the desktop client, never a browser** | **Accepted.** `meshbay-node status` on the node's own machine and Node → Overview in the desktop client are the two places the key can be read; the Node page is Electron-only, because `platform.node` resolves to "not available" without the bridge, and no hub route exposes the key. The create-group wizard links it automatically over that same bridge, so the manual paste in **Profile → Link Node** exists for the operator who runs the node from a terminal and the hub from a browser — who has a terminal by definition. Anyone linking a node is already at a shell prompt, so a browser-reachable copy would buy nothing and widen what the hub knows about the node |
| **Listing a group's folders walks every root on the event loop** | `index_sync_message` (`transport/wire.py`) builds its `dirs` field with `list_dirs`, an `rglob("*")` over every root, and nothing sends it off the loop: the WebRTC `index_sync` handler, the daemon's index push and QUIC all call it inline. So each index request from any member is a directory walk of the whole library that every other peer on the node waits behind. `test_disk_io_off_loop.py` never saw it, because it reads the transport's own modules and the walk is one call away in `wire.py`. Found by widening what that test reads, not by a symptom |
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md
index ce99a40..9efef08 100644
--- a/docs/MESHBAY_NODE_PROTOCOL.md
+++ b/docs/MESHBAY_NODE_PROTOCOL.md
@@ -413,7 +413,8 @@ fails if a transport skips a step.
|-------------------------------------------------------------->|
| 8. rebuild binding; |
| refuse if empty; |
- | compare_digest(proof) |
+ | compare_digest(proof); |
+ | roster admits the user |
| 9. session authenticated: |
| frame limit -> 64 MiB, |
| peer registry, audit |
@@ -480,6 +481,7 @@ absent. `verify_proof` compares with `hmac.compare_digest`.
| not on the denylist for `user_id`, `jti` **or** `group_id` | `Token revoked` | all three targets, and persisted to disk: a revocation that a restart forgets is not one |
| `group_id ∈ token.groups` | `Not a member of this group`, code `not_a_member` | the membership check itself — a token is proof of an account, never of a group |
| `group_id ∈ node.hosted_groups` | `Group not hosted on this node`, code `not_hosted` | the hub may hand a client several nodes for one group, and only some of them host it |
+| *(after the proof)* the roster admits `sub` for `group_id` — an active member row, or the node-wide operator row | `This node has not admitted you to this group`, code `not_authorized_for_group` | the key proves possession and the token the hub's view; the node's own answer is the roster. Someone revoked here but still a hub member, holding the key, is refused a session |
`AuthorizedPeer` carries `user_id`, `group_id`, `username`, `jti` — and deliberately
**no user public key**. `username` is read from a `username` claim that neither the MNP
@@ -489,13 +491,17 @@ would let whoever issues tokens decide it instead. Identity keys are pinned by t
node's roster. The hub certifies accounts, not keys.
`not_a_member` means the hub did not count this account a member of the group when it
-minted the token. The MNP token is minted for each connection, from the membership the
-hub holds at that moment, so a stale `groups` claim is no longer the usual cause; the
+minted the token. The MNP token is minted for each connection and names **only** that
+connection's group (`POST /v1/nodes/mnp-token {node_pk, group_id}`; `groups` is empty
+for a non-member), so the operator it is handed to learns nothing of the member's
+other groups. A stale `groups` claim is therefore not the usual cause; the
client still refreshes its session once and retries on that code before telling someone
who was just invited that they are not a member.
`not_hosted` is the client's signal to try the **next** node the hub offered for the
-group rather than to report a failure. `/v1/groups/{id}/nodes` returns every node
+group rather than to report a failure. The hub offers only nodes whose account owns
+the group or that the group's owner approved as hosts — a member's node holds the
+group key and would pass this handshake like the real host. `/v1/groups/{id}/nodes` returns every node
registered for the group, in hub registration order, and that order is not a ranking:
a node listed first is not necessarily one that holds the group's files. Refusing
without a code made this indistinguishable from a refusal the reader has to act on,
@@ -620,7 +626,7 @@ immediately after these three, so the table below is the window, exhaustively.
|---|---|---|
| `keypair_bundle_fetch` | the client's own identity keys for this node live in an encrypted bundle stored on it | counts against `MAX_PRE_PROOF_FETCHES` = 4; audited |
| `gek_bundle_fetch` | the wrapped group key is what the proof is computed with | same counter |
-| `join_request` | a first-time member holds no group key at all. Accepted after the proof as well — an operator pairing a browser is already connected — because its authority comes from the pairing code and the signature, never from the session state | 5 attempts per connection, 20 failures per 600 s node-wide |
+| `join_request` | a first-time member holds no group key at all. Accepted after the proof as well — an operator pairing a browser is already connected — because its authority comes from the pairing code and the signature, never from the session state | 5 attempts per connection; wrong codes: 5 per account and 20 node-wide per 600 s, consulted only when a code is tried |
Device linking (§9) is **not** in this window. `device_add_request` and every message
after it are answered only on an authenticated session, and the device budget of 5
@@ -630,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
@@ -643,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
@@ -653,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
@@ -822,7 +856,6 @@ Evaluated in order (`_do_join_request`):
| Condition | Outcome |
|---|---|
| `join_attempts >= 5` on this connection | `error: Too many attempts` |
-| `>= 20` node-wide failures in 600 s | `error: Pairing temporarily locked`, audited `join_throttled` |
| key not 32 raw bytes, or bad base64 | `join_result{ok:false, reason:"invalid_keys"}` |
| `\|ts - now\| > 120` | `stale_request` |
| `group_id` non-empty and != session group | `group_mismatch` |
@@ -831,6 +864,7 @@ Evaluated in order (`_do_join_request`):
| account has devices here, this key is not one | `unknown_device` — the way in is a device-add (§9), not a new invite |
| device known, no member row, group policy `open` | member row created (`approved_by: "open-join"`) |
| device known, a **pending invite** exists for this user | code required even for a known device; `code_required` / `code_invalid` on failure |
+| a code is about to be tried and this account has `>= 5`, or the node `>= 20`, wrong codes in 600 s | `error: Pairing temporarily locked`, audited `join_throttled`. Only `code_invalid` counts, and nothing else is gated by it: every member reconnecting gets the key through this message, so a lock applied before recognition would let one member refuse it to everybody |
| device known, not an active member of the session group, a code offered | redeemed like any code — an invitation **link** reaches here from someone pinned through another group, or removed and invited back; `code_invalid` on failure |
| device known, member row resolved | `join_result{ok, recognised:true, role}` + wrapped GEK |
| unknown device, no code, policy `open` | pin TOFU, admit, wrap (`via: "tofu"`, audited) |
@@ -2210,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
@@ -2292,8 +2331,9 @@ walks through the gate meant to stop it.
filename and no path anywhere in them (§11.1a).
* **Rotation is the only thing that removes access.** Revoking a member stops the node
serving the next key; the current key and anything already downloaded stay readable.
- A chat epoch is opened at the same time, which stops them reading what is said next —
- not what was said before, which they could already read.
+ A chat epoch is opened at the same time, and their connections are closed and refused
+ from then on (the handshake consults the roster), which stops them reading what is
+ said next — not what was said before, which they could already read.
---
@@ -2366,7 +2406,7 @@ LP(x) = uint32be(len(x)) || x every field, no exceptions
| Code lifetimes (default, settable) | invitation 7 d, operator pairing 24 h, device request 1 h | `roster.py` |
| `MAX_PRE_PROOF_FETCHES` | 4 per connection | `webrtc/dispatch.py` |
| `MAX_JOIN_ATTEMPTS` | 5 per connection | `webrtc/admission.py` |
-| `MAX_JOIN_FAILURES_WINDOW` / `JOIN_FAILURE_WINDOW` | 20 / 600 s, node-wide | ” |
+| `MAX_JOIN_FAILURES_PER_ACCOUNT` / `MAX_JOIN_FAILURES_WINDOW` / `JOIN_FAILURE_WINDOW` | 5 per account / 20 node-wide / 600 s, wrong codes only | ” |
| Device attempts | 5 per connection | ” |
| `MAX_DEVICES_PER_USER` | 5 | `roster.py` |
| `MAX_LINK_INVITES_PER_GROUP` | 20 unredeemed invitation links | `roster.py` |
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
index fc9cf40..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,6 +207,23 @@ 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, 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.
+
---
## 4. Joining a group
@@ -212,7 +233,9 @@ Someone who runs a node invites you. You need an account on the same hub first.
1. **They send you a code** — eight characters like `K7P2-9WQX`, by message,
mail, or read out loud. It is good for 7 days by default, works once, and
only for your account in that one group.
-2. **You sign in**, and the group is already in your sidebar.
+2. **You sign in**, and the group is waiting under **Invitations** on your
+ home page. **Accept** it — until you do, nothing connects to it — and it
+ joins your sidebar. **Decline** if you did not expect it.
3. **You open it.** It says *"This node needs to recognise you"*. Paste the
code.
4. Done — the machine hosting the group recognises you from now on, and the
@@ -361,6 +384,11 @@ resume.
- **Browsing is never queued.** Posters, thumbnails, opening a photo or a
document to look at it — none of it takes a transfer slot. A busy group
browses exactly like an idle one.
+- **In a browser, Open is offered for PDFs, pictures, music, video and plain
+ text.** A web page, an SVG image or anything else that could run code is
+ not opened in a tab: the page would run as MeshBay, with your session. The
+ file is saved all the same — open it from your downloads folder. The
+ desktop application opens every file with your system's own program.
### Casting to a TV
@@ -566,10 +594,19 @@ meshbay-node member invite alice_dupont
Or the group's **Members** tab, from a paired browser.
-They need an account on the same hub first. The invitation registers their
-membership on the hub and produces a code that never goes near it. Send the
-code out of band; they enter it the first time they open the group. You do not
-need to be online then.
+They need an account on the same hub first. The invitation appears on their
+home page, where they accept it, and produces a code that never goes near the
+hub. Send the code out of band; they enter it the first time they open the
+group. You do not need to be online then.
+
+### Other nodes hosting your group
+
+Your own nodes serve your groups without asking. Any other node — a member's,
+say, offering a second copy — serves one of your groups only after you approve
+it: it appears in the group's settings under **Hosts**, and you are notified
+the first time it asks. **Approve** or **Refuse**; either can be changed later.
+A member's node holds the same group key as everyone, so a node you did not
+approve could otherwise present itself to your members as the group's host.
**Inviting someone who has no account yet** is a link:
@@ -722,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
@@ -769,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