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.md43
1 files changed, 25 insertions, 18 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index d2bec10..e7b29d0 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -361,7 +361,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
@@ -1048,12 +1049,12 @@ requester and it therefore grants nothing across accounts.
`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, the token proves the hub's view of membership, and
- neither is the node's own answer: without this, a member revoked or unpinned
- here but still on the hub kept a full session with the key they held, and was
- handed the chat epoch their removal had just opened. A removal, from any door
- (MNP, the node page, the CLI), also opens a new chat epoch in each group the
- person could read and closes every connection they hold.
+ 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
@@ -1640,8 +1641,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.
@@ -1835,7 +1841,7 @@ live registration (**C2**). The ceiling is **the groups its account owns, plus t
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:
-with membership as the ceiling, any member could stand in for the host. A node that
+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
@@ -1952,11 +1958,12 @@ invitation is accepted before the invitee's client asks for a node.
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. Without that step
-any account could make any other account's client dial a node of its choosing — and
-learn its address, and receive from it a first-join identity bundle. Redeeming an
-invitation link, joining an open group and creating a group are the account's own
-acts and still write the membership directly.
+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
@@ -3369,8 +3376,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). The claim ceiling was membership, and every member holds the key, so any member's node could register as a host and be the first one a client kept. Now: the owner's own nodes, plus nodes the owner approved; anything else is a pending request the owner sees |
-| **AV33** | **Nobody is made a member without saying yes** (§7.3). An owner could add any username, and the account's client then listed the group, named it in its tokens and dialled its nodes. An addition is an invitation until accepted, and the MNP token names only the group it is minted for |
+| **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
@@ -3545,7 +3552,7 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows.
| **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 |