diff options
Diffstat (limited to 'docs/MESHBAY_NODE_PROTOCOL.md')
| -rw-r--r-- | docs/MESHBAY_NODE_PROTOCOL.md | 41 |
1 files changed, 36 insertions, 5 deletions
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md index ed06edd..62a76ef 100644 --- a/docs/MESHBAY_NODE_PROTOCOL.md +++ b/docs/MESHBAY_NODE_PROTOCOL.md @@ -811,11 +811,12 @@ Evaluated in order (`_do_join_request`): | account has devices here, this key is not one | `unknown_device` — the way in is a device-add (§9), not a new invite | | device known, no member row, group policy `open` | member row created (`approved_by: "open-join"`) | | device known, a **pending invite** exists for this user | code required even for a known device; `code_required` / `code_invalid` on failure | +| device known, not an active member of the session group, a code offered | redeemed like any code — an invitation **link** reaches here from someone pinned through another group, or removed and invited back; `code_invalid` on failure | | device known, member row resolved | `join_result{ok, recognised:true, role}` + wrapped GEK | | unknown device, no code, policy `open` | pin TOFU, admit, wrap (`via: "tofu"`, audited) | | unknown device, no code, policy `invite` | `code_required` | | unknown device, code invalid or spent | `code_invalid` | -| unknown device, code valid | pin identity, set member row from the invite, wrap | +| unknown device, code valid | pin identity, set member row from the invite, wrap (`via: "code"`, or `"link"`) | The member row is resolved as: this group's row, then the row for the join message's `group_id`, then the node-wide (`""`) row — which is where an operator opening any @@ -845,7 +846,7 @@ membership alone must not produce a key. The longer window costs little: a code is single use, bound to one account, never seen by the hub, and 40 bits do not fall to guessing in a week against the node-wide lockout below. -* Valid for exactly one `user_id` in one group. +* Valid for exactly one `user_id` in one group — **except an invitation link** (§8.6). * Guessing is bounded per connection and node-wide, and every failure is an audit event rather than a silent grind. * `join_policy` is read from the **node's own configuration**, never from the hub: a @@ -862,6 +863,29 @@ There is **one** source of operator authority and it is the roster: a node.toml an `admin_pk_ed25519` is warned about at startup and never obeyed, because a second source of authority is a second thing to get wrong. +### 8.6 Invitation links (`kind = "link"`) + +A code for someone who may have no account yet, so it names none: it is bound to the +first account that redeems it (`invite_link_create`, §10.4). What stops a stranger +holding it is not the node but the hub, which lets only the account whose verified +address the inviter named reach the node at all (`docs/invite-links.md` §3.5). At the +node it is therefore a **bearer code**, and everything else about it is fixed: + +* role `member`, never `operator`; a row that says otherwise is refused, not honoured; +* one named group, never node-wide, and only redeemed on a connection authenticated to + that group — the account codes keep redeeming anywhere, as before; +* not by someone already an **active** member of that group, who would otherwise spend + it for the person it was meant for; a member row that is revoked does not count; +* single use: redemption sets `used_at` **and** `user_id` in one guarded `UPDATE`; +* at most **20 unredeemed per group** (`MAX_LINK_INVITES_PER_GROUP`); +* cancellable before use by `invite_cancel`, by an `invite_id` handle unrelated to the + code; a redeemed one stays as the record of the join. + +`kind` is a column, not an empty `user_id`: every query that matches invitations by +account filters on `kind = "account"`, so a row bound to nobody can never be read as +bound to anybody (AV1). A roster from before this gains `kind` and `invite_id` by +`ALTER TABLE`, and its codes stay account codes. + --- ## 9. Device linking @@ -1059,6 +1083,8 @@ broadcast, every connected peer in the group learns the change without reconnect | `file_delete` | `file_id` | operator **or** the file's recorded `uploader_pk` | `file_delete_ack{file_id}` | no | | `dir_delete` | path relative to the root | operator | `dir_delete_ack{dir}` | no | | `invite_create` | invitee `user_id` | operator only (delegation designed, deferred) | `invite_result{code, expires_at, user_id, username}` | no — the code is shown once | +| `invite_link_create` | `link:<group_id>`, the session's group | operator only | `invite_link_result{code, invite_id, expires_at, group_id}` | no — the code is shown once | +| `invite_cancel` | `invite_id` (32 hex) | operator only | `ack{detail: "invite_cancelled", invite_id}` | no | | `member_revoke` | `user_id` | operator | `member_revoke_ack` | no | | `member_unpin` | `user_id` | operator | `member_unpin_ack{user_id}` | no | | `gek_rotate` | `group_id` | operator | `gek_rotate_ack{group_id, authorized_members, note}` | no | @@ -1940,6 +1966,8 @@ it back (§3.5). | `gek_bundle_fetch` / `_resp` | C→N / N→C | pre | the caller's wrapped GEK | | `join_request` / `join_result` | C→N / N→C | pre | pin or recognise an identity; wrap the GEK | | `invite_create` / `invite_result` | C→N / N→C | signed | issue a one-time pairing code | +| `invite_link_create` / `invite_link_result` | C→N / N→C | signed | issue a code bound to no account, for an invitation link (§8.6) | +| `invite_cancel` | C→N | signed | take back an unredeemed invitation link | | `device_add_request` / `_ack` | C→N / N→C | auth | file a new device as pending | | `device_lookup` / `device_lookup_result` | C→N / N→C | auth | candidates for the approver to hash-match | | `device_add` / `device_add_ack` | C→N / N→C | auth | admit a device, countersigned | @@ -2042,7 +2070,9 @@ message: MNP versions independently of the package version. Current: **`3.4`**; oldest peer accepted: **`3.0`** — 3.1, 3.2, 3.3 and 3.4 are all additive, so the floor does not move -with them. 3.4 adds the challenge signature (section 6.5). +with them. 3.4 adds the challenge signature (section 6.5) and invitation links +(section 8.6): `invite_link_create`, `invite_link_result`, `invite_cancel`, which an older +node answers as unknown messages. The two numbers are separate on purpose. `MNP_VERSION` says what this build speaks; `MNP_MIN_SUPPORTED` says what it will talk to, and moving the second is a decision about @@ -2172,8 +2202,8 @@ walks through the gate meant to stop it. rest), which carry the same folder names the sealed index carries; the media-metadata replies (`media_meta_resp`, `music_meta_resp`, `link_preview_resp`), which carry titles, artists and synopses; `node_status_ack`, which carries absolute paths on the operator's disk to an operator session; the - identity replies (`roster_read_ack`, `device_list_result`); or `invite_result`, which - carries a pairing code. All are inside DTLS/TLS and none reaches the hub, but none is + identity replies (`roster_read_ack`, `device_list_result`); or `invite_result` and + `invite_link_result`, which carry a pairing code. All are inside DTLS/TLS and none reaches the hub, but none is behind the group key. * **Transfer messages are in clear on purpose**, and that is a deliberate line rather than an omission: `tr` is opaque, `bytes` and `chunks` are numbers, and there is no @@ -2255,6 +2285,7 @@ LP(x) = uint32be(len(x)) || x every field, no exceptions | `MAX_JOIN_FAILURES_WINDOW` / `JOIN_FAILURE_WINDOW` | 20 / 600 s, node-wide | ” | | Device attempts | 5 per connection | ” | | `MAX_DEVICES_PER_USER` | 5 | `roster.py` | +| `MAX_LINK_INVITES_PER_GROUP` | 20 unredeemed invitation links | `roster.py` | | `PRE_HANDSHAKE_MAX_MSG` / `MAX_MSG` | 64 KiB / 64 MiB | `webrtc_server.py` | | `CHUNK_SIZE` | 1 MiB | ” | | `DOWNLOAD_BUFFER_HIGH` | 2 MiB | ” | |