summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md48
-rw-r--r--docs/MESHBAY_DESIGN.md29
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/<hash>/` 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 |