From 7c3a1d6fd765ef0421d0f3d85e512e10ea2f6f87 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 12 Sep 2026 09:47:46 +0200 Subject: docs: availability between members is a finding category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first three reviews asked who can read what, who can impersonate whom, and what a hostile node can forge. None asked what a legitimate but misconfigured or careless member costs everyone else — which is the question a group platform lives on, because every member was invited by someone who trusted them and none of them is an attacker. C2 had asked "can a node claim a group its owner is not in?" and the answer was correctly no. Nobody had asked what happens when a node claims one its owner *is* in but does not host, which is how a group went dark for all of its members with its real host online throughout. §13.5b is the register, AV1 to AV8. The lens, for anything reviewed from here: a participant supplies input; if anyone other than the sender bears the cost, there is a ceiling to write, and it goes on every path that writes the state. CLAUDE.md gets the working rule and the incident as a lesson, and the stale path to sync-ui.js corrected — it lives under scripts/, not build/. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT --- CLAUDE.md | 48 ++++++++++++++++++++++++++++++++++++++++++++---- docs/MESHBAY_DESIGN.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f2a3363..b6fbd4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,7 +121,8 @@ that produced it. | Looking for | Read | |---|---| -| What a label means (`C1`, `H3`, `NS6`, `T3`, `C5b`, `W2`, `E9`, `F1`, …) | `docs/MESHBAY_DESIGN.md` §13 | +| What a label means (`C1`, `H3`, `NS6`, `T3`, `C5b`, `W2`, `E9`, `F1`, `AV4`, …) | `docs/MESHBAY_DESIGN.md` §13 | +| What one member can cost the others (`AV1`–`AV8`) | §13.5b — the newest category, and the one the first three reviews had no question for | | Trust model, and what the project may and may not claim | §2 | | Identity, devices, admission, recovery, the keypair bundle | §3 | | Cryptography, key hierarchy, the group and chat envelopes | §4 | @@ -144,9 +145,16 @@ These are about working on the tree rather than about the design: `/a//` against `meshbay_hub.api.webapp.ASSET_V` before concluding a fix is missing. `site/` and the Caddy config have never been deployed - **One UI source.** `packages/meshbay-hub/src/meshbay_hub/static/` is the - interface, for the web and the app alike; `packages/meshbay-client/build/ - sync-ui.js` copies it and CI fails if the copy drifts — **never edit - `packages/meshbay-client/ui/` by hand** + interface, for the web and the app alike; `packages/meshbay-client/scripts/ + sync-ui.js` copies it (`npm run sync-ui`) and CI fails if the copy drifts — + **never edit `packages/meshbay-client/ui/` by hand** +- **Before adding an endpoint or a message, ask who pays.** A participant + supplies input; if anyone other than the sender bears the cost, there is a + ceiling to write, and it goes on **every** path that writes the state — the + gap between `_authorize_node_ws` and `update_groups` was one message wide. + `docs/MESHBAY_DESIGN.md` §13.5b is the register; `test_availability_between_ + members.py` is where a new case goes, and every test in it is two accounts, + because a one-member test proves a one-member property - **Never change the KDF parameters in one place.** `keyderive.js`, `keyderive.py`, the QE harness and `test_bundle_kdf_parity.py` are held byte-identical by that test, and a mismatch does not look like an error — it @@ -590,6 +598,38 @@ do. Read them before writing anything that touches the same mechanism. needs a person with a large file. Confirmed by the operator on 2026-08-15: Firefox, 180 MB, written to disk. Nothing multi-gigabyte has been measured +- **An empty claim read as a claim on everything, and a list taken as a + ranking.** Two defaults, in two codebases, a month old and harmless + separately. The hub resolved a node's group claim with `set(claimed_groups or + authorized)`, and the node omitted `group_ids` entirely when it hosted + nothing — so "I host no groups" arrived as "I host all of this account's + groups", other members' included. Such a node can serve none of them: it has + no GEK, and its own handshake refuses them. But `/v1/groups/{id}/nodes` + answers in **registration order**, `_node_groups` is in-memory, and + GroupPage took `nodes[0]` with no fallback — so the group opened or did not + depending on who reconnected first after a hub restart. On 2026-09-11 a hub + deploy at 20:14 did exactly that: this node needed 16 s to come back (502 + while the hub restarts, then its access token had expired during the outage, + so a refusal and two 5 s backoffs), a second member's unconfigured node won + the race, and a group went dark for everyone with its only real host online + the whole time. **A member could take any of their groups down, by accident, + by leaving an empty node running.** + Four things worth keeping. The node's own logs and audit database were the + evidence that cleared it — every handshake OK, no refusal recorded since + August — which is what said the refusal came from somewhere else entirely; + believing the error message names the machine you are standing on is how an + hour goes. `git log -S` on the three suspect lines dated them all to August, + which is what turned "what did I just break" into "what changed around it": + the answer was a restart, not a commit. The read-only probe that settled it + is four lines — the node's own keystore, a node-scoped token, `/v1/groups/mine` + and `/v1/groups/{id}/nodes` — and node-scoped tokens are accepted by + `get_current_user`, so a node can ask the hub what the hub thinks of it. + And the rule under all of it: **a falsy empty collection must never mean + "unspecified"** — `if gids:` and `or authorized` are the same mistake written + twice, and the ceiling that made C2 hold on connection was stepped over one + message later by `update_groups`, which assigned its list verbatim. A limit + enforced on one path is not enforced + **Corrections that used to live here** — `punch_nat()` is not a traversal stack, the node keystore's Argon2id parameters, what group chat actually uses, and what is sealed on the wire — are now design statements in `docs/MESHBAY_DESIGN.md` diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index f125825..bda33f1 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -2471,6 +2471,35 @@ Two structural recommendations from that review stand as rules: | **T2** | The hub was the key directory. **Closed** by admission redesign, not by safety numbers: the invite path reads no directory at all (§3.4). Reclassified as **H3** | | **T3** | **The hub serves the SPA. Accepted permanently for browser users.** It is the only remaining way an active hub reads content, it is an artifact-level attack rather than a silent lie, and it does not exist for a native client — whose value is realised by reproducible builds, not by packaging (§2.3, §8.2) | +### 13.5b Availability between members (`AV`) + +**Added 2026-09-12, after the finding that produced it.** The first three +reviews asked who can read what, who can impersonate whom, and what a hostile +node can forge. None asked **what a legitimate but misconfigured or careless +member costs everyone else** — and that is the question a group platform lives +on, because every member was invited by someone who trusted them and none of +them is an attacker. `C2` had asked "can a node claim a group its owner is not +in?" and the answer was correctly no; nobody had asked what happens when a node +claims one its owner *is* in but which it does not host, which is how a group +went dark for all of its members on 2026-09-11 with its real host online +throughout. + +The lens, for anything reviewed from here: **a participant supplies input; who +else bears the cost?** Where the answer is "someone other than the sender", +there must be a ceiling, and it must apply on every path that writes the state +— not only the one where the ceiling was first thought of. + +| Label | The rule it names | +|---|---| +| **AV1** | **An empty claim is a claim on nothing.** A node's group set is `authorized ∩ claimed`, and an absent or empty `group_ids` registers it for no group rather than all of its owner's — on registration and on `update_groups` alike (§7.2) | +| **AV2** | **A client treats the hub's node list as candidates, not a ranking**, and tries the next one on a `not_hosted` refusal (`MESHBAY_NODE_PROTOCOL.md` §6.3) | +| **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.** A swarm source publishes a transport and a port, never a host; where a peer is comes from its node record, stamped with the address its announce arrived from. The number of hashes one account may claim is bounded | +| **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 | +| **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 | + ### 13.6 Chat design findings | Label | The rule it names | -- cgit v1.2.3