diff options
Diffstat (limited to 'docs/MESHBAY_DESIGN.md')
| -rw-r--r-- | docs/MESHBAY_DESIGN.md | 92 |
1 files changed, 66 insertions, 26 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 8bf5e22..394dfb5 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -16,7 +16,7 @@ > them — it names the invariant that holds today, not the incident that produced > it. §13 is the register of those labels. > -> Wire versions at the time of writing: **MNP 3.4** (oldest peer accepted 3.0), +> Wire versions at the time of writing: **MNP 4.0** (oldest peer accepted 4.0), > **MHP 0.1**, packages **0.16.0**. The normative source for the wire format is > `MESHBAY_NODE_PROTOCOL.md`; this document states the design the protocol > serves, not its byte layout. @@ -404,6 +404,7 @@ Code lifetimes differ because the acts differ: | Code | Default | Setting | |---|---|---| | Member invitation | **7 days** | `[node] invite_ttl_hours` | +| Invitation link | **7 days** | none — fixed, and the hub clamps its ticket to the same | | Operator pairing | 24 h | `[node] pair_ttl_hours` | | Device add request | 1 h | `[node] device_request_ttl_minutes` | @@ -428,7 +429,7 @@ carries **two secrets with two jobs**: | Half | Secret | Checked by | Grants | |---|---|---|---| -| Hub ticket | 128 random bits, stored as `sha256` | the hub, against the redeeming account's **verified address** — the blind index of the address the inviter typed | membership, i.e. the right to *reach* the node (§7.1). Nothing about keys | +| Hub ticket | 128 random bits, stored as `sha256` | the hub: unknown, used by another account, expired or cancelled is refused; otherwise the **first account that redeems it** gets it | membership, i.e. the right to *reach* the node (§7.1). Nothing about keys | | Node code | the ordinary 40-bit code, with `kind = "link"` | the issuing node only | pinning and the group key, as for any code | ``` @@ -461,14 +462,26 @@ The rules the client follows, each of which a test holds: - a link whose origin is not this hub is refused (the desktop client accepts one pasted in), so a ticket is never sent to another hub. -What it holds against, per the convention at the top. **A third party holding the -link** — a messaging service that previews it, a forwarded mail — gets nothing -without the invitee's mailbox. **An active hub** is not stopped: it verifies the -addresses it compares, and could already be anybody. **A hijacked signaling path** -no longer receives the code on a native client; a browser is still **T3**, exactly as -for a typed code. What it costs: the hub learns that an owner invited an address -(as a blind index and a masked form, until the link is used or expires). Addresses -are compared case-insensitively and nothing more, so `+tags` and dots must match. +**Both halves are bearer secrets, and the link is not bound to any address.** It +is meant to be sent by whatever the inviter and the invitee already use — a +messaging application, a text — and an address the invitee must register with +was what made that fail: the person opened the link, registered with the address +they use, and was told it had been sent to another one. The address is therefore +optional and binds nothing: it is where the hub mails the link, if asked, and a +masked label in the owner's list. + +What that gives up, per the convention at the top. **Whoever holds the link first +joins**: a forwarded message, a shared screen, a group chat it was posted in by +mistake. What bounds it is that it works **once** — the person it was meant for +then finds it used, which is noticed — for **seven days**, fixed rather than the +operator's `invite_ttl_hours`; that joining is an explicit click showing the group +and the inviter; that the owner is notified of every redemption; and that +**Cancel** takes an unused one back, and removing the member a used one. A service +that *previews* the link learns nothing: everything is after `#`, which a fetch +never sends. **An active hub** is not stopped, and never was. **A hijacked +signaling path** no longer receives the code on a native client; a browser is +still **T3**, exactly as for a typed code. When an address is given, the hub +learns it was invited (in a masked form, until the link is used or expires). **Mailing the code is the inviter's choice, and it gives up the row above.** The Members tab can ask the hub to mail the code to the invitee's address on file @@ -480,8 +493,8 @@ checked by default, and is **remembered per account** (the `invite_email` preference), so an operator who unticks it once is not asked to again. Unticked, the hub is never called and the table above holds exactly. The CLI mails nothing. -The same box sits under the link form, sharing the same preference. Ticked, the hub -mails the link to the address the ticket is bound to — no second address, and +The same box sits under the link form, sharing the same preference. Ticked, and +with an address typed, the hub mails the link to that address — and **the hub writes the link itself** from its own origin, the ticket it has just minted, and the node key and code it is handed, each checked against its exact shape, so a hub-signed mail cannot be made to carry any other URL. Because that mail @@ -979,12 +992,29 @@ already fails the key proof. Pinning covers the case where an attacker *holds* t group key — an ex-member, a leaked key — and swaps the node underneath, which the proof alone cannot distinguish from the genuine node. +**The token the member presents is a node-audience token, never the hub session +token (E10).** A member hands whatever it presents here to the node operator, +who is in the threat model, so the credential must open nothing at the hub. The +hub signs two audiences with its one key: a session token (`aud` = the hub API) +for `hubFetch` and signaling, and a short-lived **MNP token** (`aud = MNP_AUD`, +from `POST /v1/nodes/mnp-token`) that carries the member's `sub`, `groups` and +`jti` and is the only thing presented in the handshake. The node binds `MNP_AUD` +when it decodes, so a session token is refused here; the hub API binds its own +audience, so an MNP token captured by an operator is refused there. It is +checked once, before the proof, so its short life never interrupts a transfer or +a film already playing — a reconnect fetches a fresh one. `meshbay_common/tokens.py` +holds the two audience strings, shared by the hub that issues and the node that +verifies. + **Authorization rules:** - `group_id` is **mandatory**. Omitting it once skipped the membership check and fell back to the node's first group (**M1**). - `scope == "user"` is enforced by default; node-scoped daemon tokens are refused on the client path (**M9**, **NS7**). +- **The audience must be `MNP_AUD`.** A hub session token, or one with no + audience, is refused — a member's credential to a node is not a credential to + the hub (**E10**). - The denylist is consulted for user, `jti` **and** group. - The node **refuses connections when it holds no group key** — there is no `gek_required: false` bypass (**NS8**). @@ -1216,6 +1246,16 @@ client on reload; the desktop client ships its own UI, which is why connecting and says "this version can no longer connect" rather than showing a handshake refusal nobody can act on. +**MNP 4.0 (2026-09-25) is the worked example of a MAJOR that had to be one.** A +member now presents a node-audience token in the handshake, not its hub session +token (§5.2, **E10**), so a pre-4.0 client — which presents the session token — +cannot authenticate to a 4.0 node, and a 4.0 client cannot authenticate to a +pre-4.0 node, which expected the session token. Leaving a branch that still +accepted the session token would keep the very disclosure the change closes +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. + **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 meet one: an upload is sealed or it is not sent; a transfer has a real lease or it @@ -1676,7 +1716,7 @@ Node page: | Setting | Default | What it controls | |---|---|---| -| `invite_ttl_hours` | 168 | how long a member invitation stays valid | +| `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 | @@ -1826,23 +1866,21 @@ appear in the directory and are brokered to strangers. The check is at creation only, which is correct because the update endpoint refuses to change visibility. **An invitation link's hub half is a ticket, and it grants membership to one -account.** `group_invite_links` holds `sha256(ticket)`, the invitee's address as -the same blind index `users` has, and a masked form for the owner's list — never -the address. Redeeming requires a signed-in account whose verified address has -that index; anyone else holding the ticket is told only that it is not for this -account, and an unknown, used, expired or cancelled ticket, or a group no longer -active, is one uniform refusal. Creating a link says nothing about whether the -address has an account (**M1**). At most twenty outstanding per group, as on the -node; a lifetime clamped to thirty days; a node token may create one for its own -operator's group (the CLI) and may not ask for mail. **The list answered to the +account: the first signed-in account that redeems it.** `group_invite_links` +holds `sha256(ticket)` and, when the inviter gave an address, a masked form of it +for the owner's list — never the address, and nothing a redemption is checked +against. An unknown ticket, one used by another account, an expired or cancelled +one, or a group no longer active, is one uniform refusal. Creating a link says +nothing about whether an address has an account (**M1**). At most twenty +outstanding per group, as on the node; a lifetime clamped to **seven days**, the +node's own fixed lifetime for a link code; a node token may create one for its own +operator's group (the CLI) and may not ask for mail, and mail needs an address. **The list answered to the owner holds the links nobody has used yet**, which are the ones there is still something to do about: a redeemed one has become the member row it produced, and showing both says the same thing twice. The row itself outlives the list by thirty days, so the account that used a link is answered on a reload or in a -second tab rather than refused. What the binding holds -against, per the convention: **third parties** — a messaging service that -previews the link, a forwarded mail — and not this hub, which verifies the -addresses it compares and could already be anybody. +second tab rather than refused. What a leaked link costs is in §3.4: it is a +bearer secret, bounded by being single use, seven days and cancellable. **Which nodes host a group is answered to its members.** For a public group that is everyone, which is what public means; for a private one it is the membership @@ -3199,6 +3237,7 @@ had already been asked. | **E7** | **Several named roots** per group, forming one virtual root (§6.2) | | **E8** | **exFAT/NTFS and Windows are the common case.** Linux ships first; that is build order, not population (§10) | | **E9** | **Group-related server state lives on the node. Always** (§1.3) | +| **E10** | **The credential a member presents to a node is not the one the hub API accepts.** A member hands its handshake token to the node operator, who is in the threat model, so it is a short-lived node-audience token (`aud = MNP_AUD`, §5.2), useless at the hub API; the session token (`aud` = hub API) is never disclosed to a node. Before this the member presented its hub session token, so an operator held a live hub credential for the member — enough to enumerate their other groups and act as them at the hub. Closed by the audience split and the MNP 4.0 flag day (§5.6) | | **O1** | Initial key setup in the pre-proof window — deferred; that window is where C4 and C5b came from | | **O2** | A LAN enrolment door — one endpoint, bounded window, one-time code, closing permanently on success | | **O3** | `device_policy {allow_bundle: false}`, signed by a pinned key — **the mechanism that actually closes C4** (§3.7) | @@ -3240,6 +3279,7 @@ had already been asked. 20. **Private keys never leave the device on native clients.** Qualified: a browser has no durable storage of its own and still needs a bundle on each node, so C4 closes for an *account* only when it opts out of browser use. 21. **Hub minimisation is enforced by an acceptance test, not by policy.** The hub must be *unable* to see keys, content or file listings. 22. **No new code exchanges between people.** Safety numbers are refused for identity verification, permanently. The device-linking code is between a person's own devices and is unaffected. The total user-visible cost of the whole authorship story is **one notice**: *"this account's key changed"*. +23. **A member's credential to a node is audience-separated from its hub credential** (**E10**, §5.2). The handshake carries a short-lived `aud = MNP_AUD` token, never the hub session token; the hub API accepts only its own audience. A node operator therefore holds nothing that acts at the hub. ### 14.2 Client architecture |