From 4cce50f09a73739387d5058a6f8183ebac65ae2c Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 12 Sep 2026 09:47:07 +0200 Subject: fix: an empty group claim is a claim on nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node that hosts no groups sends no `group_ids` on its hub socket, and the hub resolved the claim with `set(claimed_groups or authorized)` — so "I host nothing" arrived as "I host every group this account belongs to", other members' included. Such a node can serve none of them: it holds no GEK, and its own handshake refuses them with "Group not hosted on this node". `/v1/groups/{id}/nodes` answers in registration order and `_node_groups` is in-memory, so which node a client was sent to depended on who reconnected first after a hub restart. GroupPage took `nodes[0]` with no fallback. On 2026-09-11 a hub deploy at 20:14 reshuffled the registry, a second member's unconfigured node won the race, and a group stopped opening for everyone in it with its only real host online throughout. Any member could take one of their groups down, by accident, by leaving an empty node running. Four changes, because no one of them is sufficient: - the hub never widens an absent claim, and `update_groups` goes through the same ceiling as registration — it assigned its list verbatim, so the bound that makes C2 hold at authentication was one message wide - the node states the empty set rather than omitting the field - the refusal carries `not_hosted`, so a client can tell "try the next node" from "you, here, must do something first" - GroupPage walks the list instead of indexing into it The three lines involved date from 13, 20 and 23 August and each is defensible alone. The defect is in the seam, which is where the last two also were: a falsy empty collection must never mean "unspecified". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT --- docs/MESHBAY_DESIGN.md | 12 +++++++++++- docs/MESHBAY_NODE_PROTOCOL.md | 10 +++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) (limited to 'docs') diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 39a24da..f125825 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -1519,7 +1519,17 @@ 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**). +live registration (**C2**). 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 +registration order, one such node reaching the hub first made a group unopenable for +everyone in it (2026-09-11). The ceiling applies to **every** message that changes the +set, not only to the registration: a node that may narrow on connecting and widen on +reload has no ceiling. + +A client must therefore treat that list as candidates rather than a ranking, and try +the next node on a `not_hosted` refusal (`MESHBAY_NODE_PROTOCOL.md` §6.3). The node authenticates to the hub with an Ed25519 signature over a domain-separated timestamped message — **no password and no auth key on a node** — and receives a diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md index f7e8c48..81605c6 100644 --- a/docs/MESHBAY_NODE_PROTOCOL.md +++ b/docs/MESHBAY_NODE_PROTOCOL.md @@ -466,7 +466,7 @@ absent. `verify_proof` compares with `hmac.compare_digest`. | `group_id` non-empty | `group_id is required` | an absent group means no membership check to make; there is no default group, and a node's first group is not one | | 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` | | +| `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 | `AuthorizedPeer` carries `user_id`, `group_id`, `username`, `jti` — and deliberately **no user public key**. A key arriving in a token would be a key the hub chose, and the @@ -478,6 +478,14 @@ node's roster. The hub certifies accounts, not keys. group (`groups` is baked in at login and the hub pushes no updates), so the client refreshes once and retries on that code rather than telling a member they are not one. +`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 +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, +and a client that stopped at the first node let one wrongly registered peer make a +group unopenable for all of its members (2026-09-11). + ### 6.4 Channel binding | Transport | Anchor | Construction | -- cgit v1.2.3