aboutsummaryrefslogtreecommitdiffstats
path: root/docs/MESHBAY_DESIGN.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/MESHBAY_DESIGN.md')
-rw-r--r--docs/MESHBAY_DESIGN.md208
1 files changed, 149 insertions, 59 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index e9b63b5..2419916 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -160,7 +160,7 @@ document uses:
| File content is unreadable | ✅ | ❌ **T3** (browser) · ✅ native | ❌ by design — the operator hosts the files | ❌ members share the group key | ✅ |
| The file index is unreadable | ✅ | ❌ T3 · ✅ native | ❌ | ❌ | ✅ |
| Chat content is unreadable | ✅ | ❌ T3 · ✅ native | ❌ — the operator is a member | ❌ | ✅ |
-| Chat is unreadable **off a stolen disk** | ✅ | ✅ | ✅ without the keystore passphrase | ✅ | ✅ |
+| Chat is unreadable **from a copy of the node's storage that lacks its unlock key** — not from a whole disk by default (§4.5) | ✅ | ✅ | — the operator holds the unlock key | ✅ | ✅ |
| Content cannot be modified | ✅ | ✅ | ❌ by design | ✅ | ✅ |
| The node cannot be impersonated | ✅ | ✅ | — | ✅ | ✅ |
| Client code integrity | ❌ **T3, accepted** (browser) · ✅ ships in the package (native) | ❌ T3 · ⚠️ native: **detectable, not prevented** | ✅ | ✅ | ✅ |
@@ -213,7 +213,10 @@ design; reading what *other* operators host is not, and does not follow (§3.2).
An account lives on the hub: a username, an encrypted email address, a status and
a role. A username is 8 to 64 characters, checked at registration only — accounts
-created under the older 3-character floor keep signing in. The passphrase never leaves the client. It derives **two independent
+created under the older 3-character floor keep signing in — and **unique whatever
+its case**: invitations and member management name people by username, and
+"Alice" beside "alice" is one person to whoever reads the list. Sign-in takes the
+name as stored. The passphrase never leaves the client. It derives **two independent
values**, both salted by the trimmed username:
| Value | Derivation | Consumer |
@@ -286,7 +289,11 @@ The binding is a **one-time code the new device generates and displays**, hashed
together with the new keys: `code_hash = sha256(code ‖ new_pk_ed25519 ‖
new_pk_x25519)`. The approving device asks the node for this account's pending
requests **with their stored hashes** and recomputes the hash for each until one
-matches.
+matches. **An approval answers a pending request**: it names the request's hash,
+and the node admits the keys only if that request was filed by those same keys. A
+countersignature alone — however it was obtained — admits nothing. Retiring a
+device is signed under a prefix of its own, never the admission transcript: a
+signature given to retire a key would otherwise admit it.
> **The code never reaches the node.** That is what makes a substituted key
> impossible rather than merely detectable: a node offering fabricated keys would
@@ -540,13 +547,19 @@ admission. Only the second decides whether a code is required: a public group wi
**`join_policy` is read from `node.toml`, never from the hub.** A hub able to
declare a group open would be handed its key. An unknown group reads as `invite`.
+It is written there when the node starts hosting the group, from the operator's
+own request — the creation form in the desktop application, `group add --open` on
+the command line — and is `invite` unless that request says `open`; the hub's
+record of the group is looked up for its id and name only. Every string written
+into `node.toml` is escaped as a TOML string (`ops.node_toml.toml_string`): a group
+or folder name is someone else's text.
### 3.6 Passphrase change and recovery
**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
+asks for it with the old passphrase (`POST /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
@@ -616,10 +629,14 @@ 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;
+`POST /v1/users/auth`, and from `POST /v1/users/me/bundle-pepper` with the
+passphrase (`auth_key`) for a session that lacks the key derived from it (a
+restored session, a passphrase change). **Never to a token alone** — a refreshed
+one proves possession of a refresh token and nothing else, and one lifted from a
+page would put the pepper beside the bundles an operator keeps — never to a node
+token, never inside a token, never in a log. For the same reason **registering a
+device's hub key takes the passphrase too**: a registered key signs in without
+it, and every such sign-in carries the pepper. 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
@@ -637,15 +654,27 @@ which were a second oracle when their key came from the passphrase alone.
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).
+version 0. **Nothing else is written.** One earlier format is still read, once,
+to be replaced (*transitional*): `MBK2` — `"MBK2" ‖ nonce ‖ AES-GCM(A, {skEd, skX})`,
+no associated data — sealed under the passphrase's Argon2 key alone. The Argon2
+run that makes `M` makes `A` anyway, so a session keeps `A` too, as a
+decrypt-only key (in IndexedDB beside `M`; in the desktop application beside `M`
+in its key storage, until sign-out). A client that meets an `MBK2` bundle opens
+it with that key — or its recovery copy with the recovery key — and stores the
+same identity as `MBK3` once the connection is made; the desktop application
+stores it, or withdraws it, as the account's browser access says. Keeping `A`
+for a session adds nothing a node does not already hold: the `MBK2` bundle on its
+disk is the same oracle, until it is replaced. A session that has no `A` — one
+opened before it was kept, a device sign-in — asks for the passphrase once. A
+legacy key that does not open it means a bundle sealed under an older passphrase,
+treated like a current bundle that does not open. Anything older than `MBK2` 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 way
+out is the operator's, `member unpin` and a fresh code. The `MBK2` reader goes
+once no node holds one.
**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,
+Not the pepper, and `A` only as the transitional decrypt-only key above. 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.
@@ -905,9 +934,15 @@ where it stands on its own instead of pointing at a file to compare against.
> requirement rather than from a module somebody left behind.
**What chat encryption protects against, in the words the user-facing docs should
-use:** someone who obtains the node's storage **without the keystore passphrase** —
-a hosting provider imaging the machine, a leaked backup, a seizure where the
-passphrase is not surrendered. It does **not** protect chat from the operator or
+use:** someone who obtains the node's stored chat **without the key that unlocks
+its keystore** — a backup of the data directory, a copy of the chat database. **By
+default that key is not elsewhere:** setup writes `unlock.key` into the same
+configuration directory as `keystore.enc`, so the whole disk, an image of the
+machine or a backup of the home directory carries both, and opens. Against those
+the protection is the disk's own encryption — BitLocker or Windows device
+encryption, LUKS — or an unlock key kept off that disk (`[keystore] unlock_file`
+on other storage, or `MESHBAY_UNLOCK_KEY` supplied from outside it; `node.env`
+is in the same directory and is not outside it). It does **not** protect chat from the operator or
any current member (they hold the group key, and the chat key is delivered under
it); from anyone holding any one device of any member; from a former member, for
messages sent before the epoch changed; from the hub as regards *metadata*; or
@@ -1391,17 +1426,16 @@ 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.
+**0.17.0 is a flag day**, and it follows the same rule. What a client writes into
+the keypair bundle is `MBK3` (§3.7); an older client would go on writing the
+passphrase-only seal the pepper exists to replace, so `client.minimum` is 0.17.0
+and the browser takes the new client from the hub on reload. An `MBK2` bundle is
+read once and replaced (§3.7), so no identity has to be re-created; a playlist the
+node alone held under the old key is lost — any copy a browser or the application
+still has is sealed again over it. And retiring a device is signed under a
+transcript of its own (`meshbay:device_revoke:v1`, §3.3), so a 0.17 client and a
+0.16 node, or the reverse, cannot retire devices with each other: hub, nodes and
+clients move together.
**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
@@ -1607,10 +1641,21 @@ reason to show progress.
Five protections, and they are the substance:
-- a **filename allowlist**;
+- a **filename allowlist**, and **no file Windows Explorer acts on by itself** —
+ `desktop.ini`, `.lnk`, `.url`, `.scf`, `.library-ms`, `.searchConnector-ms`. Shown in
+ a folder the operator browses, any of them can make Explorer contact a server
+ of the uploader's choosing with the operator's Windows credentials; refused on
+ every node, since a Linux node's folder may be shared to Windows;
- **no overwrite** — a colliding name gets a free one. The check is `Path.exists()`,
and `stat()` is itself case-insensitive on NTFS and exFAT, so this already holds
- there;
+ there. A name an upload in flight will take counts as taken, since its file
+ is not on disk yet; each upload writes a `.part` of its own (`name.<tag>.part`);
+ and the finished file is published by a hard link, which refuses an existing
+ target, so a file that appeared while the upload ran — the operator's, or
+ another group's upload into a shared folder — is never replaced: the upload
+ takes the next free name and its last acknowledgement says which. Where the
+ filesystem has no hard links (FAT, exFAT, some network shares) the existence
+ check and the rename are as close as it gets;
- **strict chunk ordering**;
- a **size cap** — 8 GB per file by default, and **the operator's to set**
(`max_upload_gb` in node.toml, on the Node page, or `meshbay-node transfers
@@ -1741,10 +1786,14 @@ card text lives in a bounded in-memory TTL cache; the image rides the same
blake3-keyed store as any other thumbnail. **The new surface is SSRF**, because the
URL is a member's choice and it triggers an outbound request from the operator's
machine: http(s) only, no credentials, a port allowlist, every resolved address
-must be globally routable, redirects followed by hand so each hop is re-checked,
-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,
+must be globally routable, and redirects followed by hand so each hop is
+re-checked. **The socket is opened to the address that was checked**: the name is
+resolved once, off the event loop, every answer is checked, and the connection
+goes to that IP literal while TLS verifies the certificate for the name. Checking
+one resolution and letting the HTTP client make another would let a name answer
+clean and then with a LAN address, and the request would leave before anything
+looked; resolving on the event loop would stall every group on a slow name. No
+proxy from the environment is used, since a proxy resolves the name itself. 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
@@ -1873,7 +1922,7 @@ Node page:
| `invite_ttl_hours` | 168 | how long a member invitation stays valid. Not an invitation link, which is fixed at seven days (§3.4) |
| `pair_ttl_hours` | 24 | how long an operator pairing code stays valid |
| `device_request_ttl_minutes` | 60 | how long a device request waits for approval. Comfort, not security: the code is bound to the keys by its hash |
-| `max_concurrent_streams` | 8 | simultaneous video streams. One process per viewer, ~50 MB each; a slot is held for the length of a film, so this counts viewers |
+| `max_concurrent_streams` | 8 | simultaneous video streams. One process per viewer, ~50 MB each; a slot is held for the length of a film, so this counts viewers. One account plays at most half of them, rounded up — three screens in one home fit, and no member alone takes every slot — and runs at most two subtitle extractions at once, which take the same slots |
| `max_concurrent_downloads` | 8 | node-wide download leases (§5.5) |
| `max_concurrent_uploads` | 8 | node-wide upload leases. A separate pool from downloads, because the two cost different things and one queue for both makes each cap meaningless |
| `max_upload_gb` | 8 | the largest single upload, §6.4. Fractions are allowed, and it is read per chunk so a change reaches an upload already running |
@@ -1995,8 +2044,13 @@ refused offer is indistinguishable, to the reader, from a node that is down:
connected above all, fails at once, so a dead node costs no time.
The node bounds its own total separately (`MAX_PEER_SESSIONS` in
-`webrtc_server.py`), which is the limit that protects the machine whatever the
-number of members.
+`webrtc_server.py`, 128), which is the limit that protects the machine whatever
+the number of members, and **one account holds at most half of it** (64, from
+the account the hub names with each offer). The heaviest real member — twenty
+groups on one node, three devices and a spare tab — holds up to 52: each device
+keeps up to twelve connections for search and music, plus the open group page.
+The node's own account is not counted; it is the operator's machine. An idle
+connected session costs about 0.15 MiB and one file descriptor.
**A node registered for no group shares one with nobody**, and is refused rather
than exempted. Written as "check membership if the node claims any group", the
@@ -2110,8 +2164,9 @@ Two verbs on a group, and they are distinct things:
| Reversible from the panel | yes | no |
The client shows the real state, not a blanket one. **Revocation is honoured by
-nodes** and the denylist survives a restart (**H4**); signaling refuses a group that
-is not active.
+nodes** and the denylist survives a restart (**H4**): an account's or a group's
+live sessions are closed when the revocation arrives, not left to run until they
+happen to end. Signaling refuses a group that is not active.
**Revocation has one door, and it broadcasts.** Only an administrator revokes, and
only through `POST /v1/admin/revoke`, which signs the revocation and pushes it to every
@@ -2294,15 +2349,29 @@ The rules that make this safe:
CONFLICT DO UPDATE … WHERE … RETURNING` — so a concurrent burst gets no more
attempts than the limit. A request that checked no passphrase gives its attempt
back.
-- **Every path that checks the passphrase counts on the same row**: sign-in,
- passphrase change, changing the e-mail address on file and account deletion. A
- right passphrase clears it; failures older than the window age out.
+- **A browser the account signed in from has a row of its own.** A successful
+ sign-in from a browser that presented no token is answered with one (`known_browser`,
+ a random value the hub keeps only hashed, at most twenty per account, the least
+ recently used going first); a later sign-in presenting it is counted on its own
+ row, which nobody else can spend. The username's row, which anyone can spend,
+ then locks only browsers the account has never used. The token is not a
+ credential — the passphrase is still checked, at the same rate — and a token
+ for another account counts on the name's row like no token at all (**M1**). It
+ survives sign-out by design, and a passphrase reset or the account's erasure
+ forgets every one.
+- **A passphrase re-checked inside an open session counts on the session's
+ account row** — passphrase change, changing the e-mail address on file, account
+ deletion, registering a device, asking for the bundle pepper. A stranger
+ failing at sign-in does not stop the owner doing any of them, and failures there
+ lock nothing at sign-in. A right passphrase clears the row it was checked on;
+ failures older than the window age out.
- **A lockout refuses passphrase sign-in and nothing else.** Open sessions, token
- renewal and device sign-in continue, and a reset code sent to the address on
- file clears it — so a stranger who locks a public username costs its owner at
- most a new sign-in (**AV26**). A session learns its own lockout from
- `/v1/users/me`, because a passphrase change re-wraps every node's bundle before
- the hub accepts the new passphrase and must not start when the hub would refuse.
+ renewal and device sign-in continue, a known browser signs in on its own row,
+ and a reset code sent to the address on file clears it — so a stranger who
+ locks a public username costs its owner at most a sign-in from a new browser
+ (**AV26**). A session learns its own row's lockout from `/v1/users/me`, because
+ a passphrase change re-wraps every node's bundle before the hub accepts the new
+ passphrase and must not start when the hub would refuse.
---
@@ -2372,11 +2441,11 @@ What running it establishes, and what each fact costs:
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
+ supplies verbatim. **What widens what the node shares or admits is
+ confirmed by a dialog the main process draws**: hosting a group or sharing a
+ folder with a folder not chosen in the native folder picker (one chosen there is
+ its own confirmation, so the ordinary path asks nothing), 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
@@ -2387,10 +2456,27 @@ What running it establishes, and what each fact costs:
(`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.
+ node keeps a sealed copy is the account's browser access, decided here, and the
+ keyring refuses to seal one while it is off, whatever the page asks.
+- **An identity signs a kind, never bytes.** The page names what it is signing —
+ a join, a device's hello, a device request, an approval, a retirement, a chat
+ line, an admin operation — and gives the fields; the main process builds the
+ transcript itself (`transcripts.js`, byte for byte `meshbay_common` and the
+ page's `transcriptFor`), with the identity's own public keys wherever one is
+ named, refuses a field naming another node or another account or a moment far
+ from now, and signs no other kind. A page able to submit bytes would obtain a
+ signature over anything, reusable outside the operation it claimed to be for.
+ What a page can still obtain is a signature of a named kind within the session
+ it runs in — the same reach as the person using the window, which is the scope
+ accepted for the renderer.
- **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**.
+- **A downloaded file carries the Mark-of-the-Web**, as a browser's download
+ does: on Windows the application writes `Zone.Identifier` (ZoneId 3) beside each
+ file it saves, so SmartScreen and Protected View apply when it is opened. The
+ application writes its files itself, so nothing else would mark them; FAT and
+ exFAT have no such stream.
- **Installation places files, never secrets.** No key generation in a package's
post-install step or an installer custom action — a golden image would give every
machine the same key.
@@ -2512,7 +2598,9 @@ Asking on the lease message instead would have put the operator's filenames on a
unsealed message — precisely what sealing the write path bought. The partial state
is keyed by member, directory and filename in the **group** context rather than the
session, so a reconnect finds it, and an orphaned partial with no live lease is
-reaped on a timer and at startup. The four upload protections (§6.4) are untouched
+reaped on a timer and at startup — only a `.part` the node wrote, which carries
+its tag (`name.<8 hex>.part`); any other `.part` in a shared folder is somebody
+else's download or copy in progress and is never touched. The four upload protections (§6.4) are untouched
by any of this.
**Video streaming** is fragmented-MP4 remux (or transcode where the codec has no
@@ -3176,7 +3264,9 @@ requirements, not compatibility notes.
the name its disk gave the file and never rewrites it — that is the string that
opens it — so a single file, a zip's entries and the zip's own name are passed
through `portable-name.js` at the moment of saving (reserved characters become
-`_`, a trailing dot or space goes, a reserved stem gains `_`), and the transfer's
+`_` — the bidirectional controls among them, since `invoice\u202efdp.exe` displays
+as `invoiceexe.pdf` — a trailing dot or space goes, a reserved stem gains `_`), and
+the transfer's
row names the original when it changed. The rule is `paths.sanitize_for_download`,
and the two are held byte-identical by a parity test. Two different names can
still become one — a zip keeps both entries under it.
@@ -3430,7 +3520,7 @@ be understood, not so the incident can be retold.
| **M2a** | `sender_id` comes from the authenticated session (**NS6**). Now guaranteed by there being **one** chat implementation: QUIC does not carry chat at all (§5.1) |
| **M2b** | Chat broadcast is per group (**H1**), on the one transport that carries chat |
| **M2c** | No transport runs a synchronous media process on the event loop, and every one is capped |
-| **M3** *(third review)* | Link-preview SSRF is gated: rate limit, port allowlist, globally-routable check, per-hop re-check, connect-address re-check, size guard (§6.5) |
+| **M3** *(third review)* | Link-preview SSRF is gated: rate limit, port allowlist, globally-routable check, per-hop re-check, connection pinned to the checked address, size guard (§6.5) |
| **M4** *(third review)* | Federation binds a pushed row's source to the signer, checks the token audience, caps the push, rejects replays, and scopes revocation to the peer's own entries (§7.6) |
| **M5** *(third review)* | A CSP and security headers apply to the hub-served application, verified against the running app — a mis-tuned CSP shows as a blank page |
| **M6** *(third review)* | **Withdrawn.** It misread the node registering a hub membership during the CLI invite flow — which is deliberate — as authorization drift |
@@ -3519,11 +3609,11 @@ had already been asked.
| **AV23** | **An upload's owner is recorded when the upload ends and applied when the entry is created**, which are different moments (§5.4). Written against the index at the end of the upload it matched nothing, every time, and left every uploaded file owned by nobody — so no member could delete what they had sent |
| **AV24** | **A node registered for no group is refused signaling, not exempted from it** (§7.2). The membership check was written as "if the node claims any group", so it skipped itself — membership, group status and the public-group gate together — for the node AV1 made commonplace: the unconfigured one, which is also the one least able to absorb the work |
| **AV25** | **Which nodes host a group is answered to its members** (§7.3). Only the public case checked, so a private group told any authenticated account that knew its id which machines hosted it — and an ex-member knows that id for ever |
-| **AV26** | **A sign-in lockout refuses passphrase sign-in and nothing else** (§7.7). It is keyed by username, usernames are public, and so anyone can spend somebody else's attempts. Open sessions, renewal and device sign-in are untouched and a reset code ends it, which bounds what a stranger buys to one forced sign-in. The lockout is a DoS primitive by construction; this is the ceiling on it |
+| **AV26** | **A sign-in lockout refuses passphrase sign-in and nothing else** (§7.7). Its username row is public, so anyone can spend it; a browser the account has signed in from counts on a row of its own, and passphrase checks inside a session on the account's. Open sessions, renewal and device sign-in are untouched and a reset code ends it, which bounds what a stranger buys to a sign-in from a browser the account never used |
| **AV27** | **A free-text third-party search is bounded per member and per node** (§6.5). `tmdb_search_req` spends the *operator's* credential, which TMDB rates and the whole group's automatic matching depends on, so one member holding a search box degrades the library for everyone. Per member and not per connection — three tabs is one person — and kept in the group context so a reconnect does not reset it. The refusal is an error, because an empty result list is what "no such film" looks like |
| **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 |
+| **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. An offer carries at most 64 ICE candidates (32 KiB), and its IP-log row — kept a year — is written only once it goes to a node, so naming nodes that do not exist costs the hub nothing. A node's `update_groups`, a database read each, is budgeted like `chat_notify` (ten a minute) and claims at most 1000 groups |
| **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 |