summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md11
-rw-r--r--docs/MESHBAY_NODE_PROTOCOL.md41
-rw-r--r--docs/invite-links.md361
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py5
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py7
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py3
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py6
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py37
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py134
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py116
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py8
-rw-r--r--packages/meshbay-node/tests/test_link_invites.py270
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py149
13 files changed, 1118 insertions, 30 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 4b99fb4..371163e 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -420,6 +420,15 @@ being bob on his first connection:
| Safety-number comparison | Nothing — but it needs two humans reading digits at the worst moment | correct, unusable as a default |
| **One-time pairing code** | **Nothing: the code never reaches the hub** | **adopted** |
+**An invitation link is the one code that names no account**, because it goes to
+someone who may not have one yet. At the node it is a bearer code — a member of one
+group, once, never an operator, never for someone already in the group, at most
+twenty outstanding per group, cancellable (`MESHBAY_NODE_PROTOCOL.md` §8.6). Who may
+bring it is decided before the node is reached: the hub admits only the account whose
+verified address the inviter named, and the client sends the code only to the node
+the link names, once that node has signed its challenge (§5.2). The node half is
+built; the hub ticket and the client are not yet (§15.2, `docs/invite-links.md`).
+
**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
(`invite-notify`); that request carries the code, so an active hub holds it and
@@ -973,6 +982,7 @@ from anything in the response.
| `file_delete` | the operator, or **any non-revoked device of the uploading account** |
| `dir_delete` | the operator alone, and only on an empty directory |
| `invite_create` | the operator (or a delegate, when delegation ships) |
+| `invite_link_create`, `invite_cancel` | the operator |
| `gek_rotate` | operator-signed; the node generates the key itself |
| initial `gek-init` | **local admin API or CLI only** |
| root add/remove/update/eject/plug, `apps_enabled`, app directories, transfer limits | operator-signed |
@@ -3190,6 +3200,7 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows.
| — | DLNA/UPnP casting (§11.4) |
| — | **Bitmap subtitles** (PGS, VOBSUB — about a fifth of the embedded streams). No WebVTT without OCR; they are not listed rather than listed and blank. Burn-in covers them and costs `-c:v copy`, which is what the eight-slot sizing assumes never happens |
| — | Delegation (§3.4) |
+| — | **Invitation links** (§3.4). The node half is built — the unbound code, its signed ops and the join path; the hub ticket bound to an address and the client that reads the link are not (`docs/invite-links.md`) |
| — | Tier 3 roster attestation (§3.3) |
| — | Android client |
| — | **Federation between two hubs.** The protocol is written and switched off in the code (§7.6); what is not built is one run between two machines |
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 | ” |
diff --git a/docs/invite-links.md b/docs/invite-links.md
new file mode 100644
index 0000000..fc8cca8
--- /dev/null
+++ b/docs/invite-links.md
@@ -0,0 +1,361 @@
+# Invitation links — plan
+
+> **Status: plan, partly built.** Written 2026-09-23 against MNP 3.3 / packages 0.15.0.
+> Built so far (§7): the remembered e-mail choice (step 1), the signed challenge
+> (step 2, MNP 3.4) and the node half of links (step 3). The rest is not true of
+> the code yet. When it lands, the design statements move
+> into `MESHBAY_DESIGN.md` (§3.4, §7.3, §13) and `MESHBAY_NODE_PROTOCOL.md` (§6, §8,
+> §10.4), and this file keeps only what a synthesis cannot carry, as `transfers-v1.md`
+> does.
+
+## 1. What is asked
+
+An operator sends somebody **one link**. Whoever receives it can open it before
+having an account, register, and land in the group with the invitation code entered
+for them. It must work across several situations (§6), without regressing anything
+and without weakening the admission model of §3.4.
+
+Decisions taken with the project owner before writing this:
+
+| Question | Decision |
+|---|---|
+| Binding | **Bearer code at the node, bound to a verified e-mail address at the hub** |
+| Mail sent by the hub | **Offered on both invitation boxes**, as a checkbox under the text field, **checked by default and remembered per account** (§3.8). Checked, the hub sees the code or the link, which is a documented trade-off rather than a defect (§5) |
+| Mail quota for links | **10 link mails per inviting account per day**, on top of the existing bounds. Ordinary invitations keep their current limits |
+| CLI | **Yes** — `meshbay-node member invite --link`, alongside the interface |
+| Desktop client | **Paste the link into the application**; no OS protocol handler |
+
+## 2. Why it cannot just be "put the code in a URL"
+
+Three facts about today's model decide the shape:
+
+1. **A code is bound to a `user_id` when it is created** (`roster.create_invite`,
+ `consume_invite` checks `invite["user_id"] != user_id`). Somebody with no account
+ has no `user_id`, so no code can exist for them.
+2. **Reaching the node needs hub membership.** Signaling is refused unless caller and
+ node share an active group (H6, AV24), and `/v1/groups/{id}/nodes` answers members
+ only (AV25). Today the invitation registers the invitee's membership when the code
+ is created — again, by account.
+3. **The code is sent in the pre-proof window, to a node that has not proved who it
+ is** (`MESHBAY_NODE_PROTOCOL.md` §6.5). `node_pk` in the challenge is unverified
+ until the ack, and `join_request` goes before the ack. Whoever answers signaling
+ receives the code in the clear (inside DTLS).
+ Today that costs little, because the code only works for one account. **A bearer code
+ changes that**, and so does a group with two hosting nodes: the code typed today
+ goes to whichever node answers first, which may be **another operator's machine**.
+
+So a link needs two secrets with two jobs, and the node has to authenticate itself
+before the code goes out.
+
+## 3. Design
+
+### 3.1 Two halves, as everywhere else
+
+| Half | Secret | Who checks it | What it grants |
+|---|---|---|---|
+| **Hub ticket** | 128-bit random `t`, stored as `sha256(t)` | the hub, **plus** the redeeming account's verified e-mail blind index must equal the one the inviter named | hub membership = the right to *reach* the node. Nothing about keys (§7.1) |
+| **Node code** | the existing 40-bit code, stored as `sha256(code)` | the issuing node only | pinning + the group key, exactly as today |
+
+The hub never receives the code, and the node never learns the e-mail address. A hub
+membership obtained without the code gets `not_authorized_for_group`, as it does today.
+
+### 3.2 The link
+
+```
+https://<hub>/#/invite?v=1&g=<group_id>&t=<ticket>&n=<node_pk_b64url>&c=<CODE>
+```
+
+- **Everything after `#` is never sent in an HTTP request**. It does not reach Caddy
+ logs, hub logs, `Referer`, or the service worker. The SPA reads it, stores it
+ (§3.6), and **immediately rewrites the address to `#/invite`** with
+ `history.replaceState`.
+- `g` lets the client check that the group the hub answers is the group the inviter
+ meant.
+- `n` is **the issuing node's public key**. The code exists on that node only, so the
+ client sends it to that node and to no other (fixes fact 3, and the multi-host leak).
+- No e-mail address in the link.
+
+### 3.3 The node authenticates before the code leaves (MNP 3.4, additive)
+
+`handshake_challenge` gains `sig = Ed25519(sk_node, C)` where
+
+```
+C = "meshbay:mnp:challenge:v1" || LP(group_id) || LP(nonce_c) || LP(nonce_s) || LP(binding)
+```
+
+`binding` is the same channel binding as §6.4, already known when the challenge is
+sent (both DTLS fingerprints are in the SDP). `nonce_c` makes it fresh, and `binding`
+means it cannot be relayed.
+
+Client rule, **by the peer's answer and not by version** (§5.6): when the client holds
+a link code for this group, it sends `join_request` only if the challenge has a `sig`
+that verifies under `n` from the link. Otherwise it refuses with a stated reason:
+"this invitation was issued by another machine" or "this node is too old for
+invitation links". Nothing changes for code typed by hand or for recognised
+devices. An old node cannot have issued a link code, so refusing it loses nothing.
+
+The code goes in `meshbay_common/handshake.py` (one implementation, both transports:
+**QUIC must stay at parity**) and in `transport.js`. A parity test covers the transcript
+in Python and JS, the same way `test_bundle_kdf_parity.py` covers the KDF.
+
+**What it buys, per the §2 convention:** for a link join, an active hub that hijacks
+signaling no longer receives the code on a **native** client. **Browser: unchanged**
+— T3, the hub serves the page that reads the fragment. That is the same exposure as
+today's typed code, and no worse.
+
+### 3.4 Node: link invitations in the roster
+
+- `invites` gains `kind TEXT NOT NULL DEFAULT 'account'` (values `account` | `link`)
+ and `invite_id TEXT` (random, non-secret handle for cancelling). The migration is an
+ `ALTER TABLE ADD COLUMN`, as `username` was.
+ **`user_id = ''` never means "anyone" on its own** (the AV1 lesson: an empty value
+ must not mean "unspecified"). Only `kind = 'link'` makes a row bearer, and every
+ query that matches by `user_id` also filters on `kind`: `drop_invites`,
+ `consume_invite`, the pending-invite check in `_do_join_request`, and `member list`.
+- `consume_invite` for a `link` row requires all of:
+ `role == member`, `group_id != ''`, `group_id == session group`, not used, not
+ expired, **caller has no member row in that group**. An existing member cannot spend
+ someone else's link. The call then sets `used_at` **and** `user_id` in one `UPDATE
+ … WHERE used_at IS NULL`, so single use still holds under a race.
+ A link row can **never** carry `role = operator`, and `pair_operator` is untouched.
+- `_do_join_request`: the "device known" branch also accepts a link code. The case is
+ a person already pinned on this node through another group, which is common. Today
+ that branch only looks for bound invites.
+- New signed op **`invite_link_create`** (`adminop.py`), subject `link:<group_id>`,
+ so the transcript names the outcome (H5). A new op instead of overloading
+ `invite_create`, whose subject is an invitee id. **`invite_cancel`** takes subject
+ `<invite_id>`. Both are operator-only, like `invite_create`, and both are audited.
+- `ops.create_link_invite` / `ops.cancel_invite`: one implementation behind MNP,
+ loopback and CLI (§5.4).
+- Ceiling (AV lens): **20 unredeemed link invitations per group**. The refusal says
+ so.
+- Audit: `invite_link_create`, `invite_cancel`, and `join_pinned … via=link`.
+
+### 3.5 Hub: tickets
+
+New table `group_invite_links`: `id`, `group_id` → groups, `created_by` → users,
+`ticket_hash` (unique), `email_hash` (the existing HMAC blind index), `email_masked`
+(`al***@ex***.com`, for the owner's list), `node_invite_id`, `created_at`,
+`expires_at`, `redeemed_by`, `redeemed_at`. Alembic migration with `sa.func.now()`.
+`test_migrations_reach_head` has to pass. `purge.py` finds it from the schema; a test
+confirms it on group deletion **and** on account deletion.
+
+| Route | Auth | Rule |
+|---|---|---|
+| `POST /v1/groups/{id}/invite-links` `{email, expires_at, node_invite_id}` | `get_current_user` + `group.admin_id == current_user.id` (the node token for the CLI, same guard as `add_group_member`) | group active, not `open`; ≤ 20 outstanding per group; rate-limited; expiry clamped to ≤ 30 days; **same answer whether or not an account holds that address** (M1). Returns `t` once |
+| `GET /v1/groups/{id}/invite-links` | owner | outstanding and redeemed links, masked e-mail |
+| `DELETE /v1/groups/{id}/invite-links/{link_id}` | owner | deletes the ticket |
+| `POST /v1/invite-links/preview` `{ticket}` | `require_user_scope` | if valid **and** `caller.email_hash == row.email_hash` **and** caller active → `{group_id, group_name, inviter}`. Wrong account → `403 invite_other_account`, which names no address. Otherwise one uniform `404` |
+| `POST /v1/invite-links/redeem` `{ticket}` | `require_user_scope` | same checks → `GroupMember` row + `redeemed_by`. **Idempotent for the same account** (a second tab, or a reload). Refused for anyone else once redeemed. One notification to the owner: "X joined through your link" |
+
+No route is added to the unauthenticated surface. `POST …/invite-links` takes
+`send_email: bool`; mail is §3.8.
+
+Binding is checked **at redemption**, against the account's current verified address.
+An address change already requires verifying the new address (`users.py`), so it
+cannot be spoofed.
+
+### 3.6 Client
+
+- **`#/invite` page** (`invite-page.js`). It parses the fragment, validates the
+ shape of each field, stores `{g, t, n, c, exp}` in **`sessionStorage`**, then
+ calls `replaceState`. Why `sessionStorage`: it survives a reload in the middle of
+ registration and dies with the tab. Why not `localStorage`: that is shared across
+ tabs and outlives them. The page never renders the code.
+- **Not signed in** → "You have been invited to a group on this hub", then *Create an
+ account* / *Sign in*. It shows no group name, since that would be information for an
+ unauthenticated reader.
+- **After registration** (form → recovery key → e-mail code → done → sign in) and
+ **after any sign-in**: if an invitation is pending, `LoginPage` goes to `#/invite`
+ instead of `/`.
+- **Signed in** → `preview`, then an **explicit confirmation**: "*inviter* invites you
+ to *group* — Join / Ignore". The code is entered for them; the click stays theirs,
+ because otherwise a link becomes a way to add anyone to any group without asking.
+ Then `redeem`, then check `group_id == g`, then `#/group/<g>`.
+- **`GroupPage`**: a pending invitation for this group:
+ (1) narrows the candidate nodes to the one whose key is `n`;
+ (2) passes the code to `connect` as `pendingJoinCode` does today;
+ (3) is cleared on `ok`, `code_invalid`, expiry, sign-out and "Ignore". It is **kept**
+ on a network failure, node offline, `unknown_device` (device linking comes first)
+ or a passphrase prompt.
+- **Members tab** (paired operator). A second box, **"Invite by link"**, goes directly
+ under the existing **"Invite member"** box, with the same layout: an e-mail field and
+ a *Create link* button. Under the field sits the same checkbox as in the box above
+ (§3.8). The result shows the link with *Copy* (and `navigator.share` where it
+ exists).
+ ```
+ INVITE MEMBER
+ [ username ] [Invite]
+ ☑ Send the invitation by e-mail (may land in spam)
+
+ INVITE BY LINK
+ [ e-mail address ] [Create link]
+ ☑ Send the invitation by e-mail (may land in spam)
+ → link + [Copy]
+ Pending links: al***@ex***.com · expires 30/09 · [Cancel]
+ ``` It also lists outstanding links, with a cancel action that does
+ both halves, **node first, then hub, and reports a refusal without skipping the
+ other half** (the member-removal lesson).
+- **Desktop client / paste**: a "Join with an invitation link" field on the home page
+ (browser and application). It accepts the whole link and **refuses a link whose
+ origin is not `platform.hubBase()`**, so a ticket is never sent to another hub.
+- `test_hub_address_seam.py`, `test_no_native_dialogs_in_the_spa.py`,
+ `test_hook_ordering.py`, `test_spa_ordering.py` and `test_locales.py` all apply.
+ New strings go in **all ten** catalogues.
+
+### 3.7 CLI
+
+```
+meshbay-node member invite --link --email alice@example.org --group "Family Photos"
+```
+
+This runs `ops.create_link_invite` and then the hub ticket through the node token
+(hub first, as `create_invite` does, so a failure leaves no orphaned code). It prints
+the link and writes it to `invite-link` beside `invite-code` (`write_code_file`).
+`member list` shows unredeemed links as such, not as a truncated empty `user_id`.
+`test_cli_dispatch.py` covers the new flag.
+
+### 3.8 Mail sent by the hub
+
+**The checkbox.** It sits under the text field in both boxes, reads *"Send the invitation
+by e-mail (may land in spam)"*, and is **checked by default**. It is **remembered per
+account** through `/v1/users/me/preferences`, under one key, `invite_email`, which
+both boxes share. That key is added to `ALLOWED_PREF_KEYS`, a closed namespace (AV11).
+It is remembered server-side rather than in `localStorage` so that it follows the
+account from browser to desktop client. It is read once when the Members tab opens,
+and written when it is toggled.
+
+**Invite member (existing).** Unchecked → `invite-notify` is not called, so the hub
+never sees the code. Checked → today's behaviour, **unchanged**: same endpoint, same
+20/hour per IP limit, same `invite` purpose.
+
+**Invite by link (new).** Checked → the hub mails the link to the address the inviter
+typed, which is the address the ticket is bound to. There is therefore no second
+address, and none that the inviter could choose separately. The mail's content is
+fixed: the subject comes from the group row and the inviter's username, and nothing
+in the text comes from the request (AV8). The link is built by the client, then
+**validated by the hub before it is sent**: it must have the hub's own origin, the
+exact shape of §3.2, and the same `g`/`t` as the ticket just created. A hub-signed
+mail must not become a way to send an arbitrary URL.
+
+**Bounds**, all enforced through `mail.reserve`, with none of the existing ones
+changed:
+
+| Bound | Value | Why |
+|---|---|---|
+| Per inviting account | **10 link mails / day** (`mail_quota` row `invite_link:<user_id>`, one-day window), clamped setting `mail.invite_link_daily_cap` beside the others | the recipient has **no account**: this is the only mail door besides registration that reaches an address the hub has no relationship with, and registration is open. A per-IP bound limits a caller, not the number of strangers mailed (AV10) |
+| Per recipient | existing: 10/day, 120 s cooldown | unchanged |
+| Instance | existing: hourly budget | the new purpose **`invite_link`** is added to the closed list (AV13) and **is not** a recovery purpose, so it cannot spend the share reserved for passphrase resets |
+| Creating a ticket | existing ≤ 20 outstanding per group | a ticket with no mail costs nothing to anyone else |
+
+A refused mail does **not** cancel the link: the answer says `email_status: refused`
+and the interface says "share the link yourself". The link is already shown.
+
+## 4. What this changes in the security model — to write in `MESHBAY_DESIGN.md`
+
+| Claim | Before | With a link |
+|---|---|---|
+| The code never reaches the hub | ✅ if the box is **unchecked**. Checked: the hub receives it (`invite-notify`) | ✅ if the box is **unchecked** (fragment only; browser: T3). Checked: the hub writes the mail and so holds the whole link |
+| A leaked code is useless to another account | ✅ bound to the account | ⚠️ **the node code is bearer**. What restricts *who reaches the node* is the hub ticket bound to a verified address. Leaking the link to a messaging provider is therefore not enough: you also need the mailbox |
+| Active hub | can already be the invitee (forged token + code if it sees it) | **unchanged**. It can create an account with the address it verifies itself, redeem, forge. Honest wording: the e-mail binding holds against **third parties**, not against the hub |
+| Signaling hijack at first contact | receives the code | **closed for a link on a native client** (§3.3). Browser: T3 |
+| Multi-host group | the code may go to another operator's node | **closed for a link** (`n`) |
+| Hub metadata | who is a member of what | **+ "owner X invited address Y into group Z"** (blind index + masked form) until expiry/deletion. The creation request carries the address in the clear over TLS to the hub |
+
+Residual risks, stated rather than hidden:
+
+- Addresses are compared **case-insensitively and nothing more**. `+alias` and dots
+ are not folded, so the person has to register with exactly the address that was
+ given. The error message says so.
+- An account created before e-mail verification existed may hold an address nobody
+ verified. An account whose `status` is not `active` is refused.
+- The browser's local history may have recorded the full URL before `replaceState`.
+ That exposes it to a local attacker only, and the code is single-use and short-lived.
+- A **group member** who obtains the link cannot spend it (§3.4, "caller has no
+ member row"). An account that received hub membership some other way and has not
+ been pinned could. That is narrow, audited, and visible to the owner, who sees who
+ redeemed.
+
+New labels for §13 (numbered at write time), e.g. `AV29`: "A link invitation is
+capped per group, on the node **and** on the hub". And an admission rule: "A bearer
+code is sent only to a node that has proved the key the link names".
+
+## 5. Mail sent by the hub: a stated trade-off, no longer a silent one
+
+**`invite-notify` hands the hub the code in the clear**, and the link mail does the
+same with the link. It is kept, **as an option the inviter sees**, because a mail that
+arrives without anyone copying anything is worth more to most groups than the property
+it costs. Written honestly:
+
+- Checked, **an active hub can join in the invitee's place**. It holds the code, and
+ for a link the ticket as well. It still has to go through the address binding, which
+ it controls, because it is the one that verifies addresses.
+- Unchecked, the property of §3.4 holds exactly as written.
+- `MESHBAY_DESIGN.md` §3.4 (property 2 and the "adopted" line) and `USERGUIDE.md`
+ §4 ("The code never passes through the hub") gain the same qualification: **"unless
+ you ask the hub to send it by e-mail"**. The USERGUIDE gets one sentence next to the
+ checkbox: for a group where it matters, uncheck it and send the link yourself.
+
+This replaces the §15.3 entry planned earlier: a choice stated in the interface and in
+the documentation is not an open defect.
+
+## 6. Scenarios to hold (each becomes a test or a manual check)
+
+| # | Situation | Expected |
+|---|---|---|
+| S1 | No account, opens the link | invitation page → register → recovery → e-mail code → sign in → confirmation → group open, files visible |
+| S2 | Has an account, signed out | sign in → confirmation → group |
+| S3 | Already signed in, right account | confirmation → group |
+| S4 | Signed in as **another** account | `invite_other_account`, suggests signing out; invitation kept |
+| S5 | Registers with another address | same, and the message mentions aliases |
+| S6 | Link expired / cancelled / already used by someone else | "invitation no longer valid, ask for a new one"; storage cleared |
+| S7 | Reload or tab closed between steps | reload: resumes (`sessionStorage`). Tab closed: reopening the link works (idempotent redeem) |
+| S8 | Node offline after redeem | group in the sidebar, "offline"; code kept; joins when it comes back |
+| S9 | Already pinned on this node through **another group** | "device known" branch → the link is accepted |
+| S10 | Account known on the node, **new browser** | `unknown_device` → device linking, then the link |
+| S11 | Browser without `bundleKey` (device sign-in) | passphrase prompt, code kept |
+| S12 | Already a member of the group | nothing spent, the group opens |
+| S13 | Group hosted by two nodes | the code goes to `n` only; the other node never sees it |
+| S14 | Old node (no challenge `sig`) | stated refusal, code not sent |
+| S15 | Desktop, pasted link | same flow; link from another hub refused |
+| S16 | Owner cancels before use | node + hub; the link answers S6 |
+| S17 | Owner removes the member after joining | existing removal flow, unchanged |
+| S18 | Open group (`join_policy = open`) | "invite by link" is not offered, as with the code today |
+| S19 | Node with no paired operator | same message as today for invitations |
+| S20 | Sign-out with an invitation pending | storage cleared |
+| S21 | Box unchecked, then the page is reopened (or another browser, or the desktop) | still unchecked, in both boxes |
+| S22 | 11th link mail of the day | link created and shown, `email_status: refused`, "share it yourself"; ordinary invitations unaffected |
+| S23 | Box unchecked | no call to `invite-notify` / no mail; a test checks that the hub never received the code |
+| S24 | Link mail with a forged URL (other origin, other ticket) | refused by the hub, no mail sent |
+
+## 7. Order of work — one commit per step, suite green between each
+
+0. **Baseline**: suite counts per package (hub, node, common) before touching
+ anything, with the known pre-existing browser failures noted.
+1. `feat(hub):` the remembered **"Send by e-mail"** checkbox on the existing *Invite
+ member* box (`invite_email` preference, i18n ×10), plus the §3.4 / USERGUIDE wording
+ of §5. Standalone, and it changes nothing when the box stays checked.
+2. `feat(common,node):` challenge signature, MNP **3.4** (MINOR, floor unchanged),
+ both transports, py↔js parity, client verification when `sig` is present. Worth
+ having without links: it is the precondition for everything else.
+3. `feat(node):` `kind`/`invite_id`, `consume_invite`, join branches,
+ `invite_link_create`/`invite_cancel`, ops, loopback, ceiling, audit, `member list`.
+ Tests: node roster + join + races + regressions (bound invite, operator pairing,
+ `drop_invites` on an empty `user_id`).
+4. `feat(hub):` table + migration + routes + purge + notification + **link mail**
+ (`invite_link` purpose, per-account daily cap, URL validation). Tests: two accounts
+ (`test_availability_between_members.py`), uniform responses, clamps, idempotency,
+ the 11th mail refused while the other purposes still send, `invite_link` unable to
+ spend the recovery reserve.
+5. `feat(hub):` SPA — `invite-page.js`, login/register routing, `GroupPage`,
+ Members tab, paste field, i18n ×10. Source-reading tests: **no `hubFetch` carries
+ `c`**, `replaceState` called before any `await`, storage cleared on sign-out. A
+ **harness probe driving the real flow** in a browser against a local hub and node
+ (S1, S4, S13): only launching it proves the ordering.
+6. `feat(node):` CLI `--link`.
+7. `docs:` `MESHBAY_DESIGN.md` §3.4 / §5.6 / §7.3 / §13 / §15, `MESHBAY_NODE_PROTOCOL.md`
+ §6.5 / §8 / §10.4 / §13 / appendices, `USERGUIDE.md` §4 and §7, and this file
+ reduced to what the synthesis does not carry.
+
+Deployment: node before SPA. A node on 3.4 signs, and nothing yet requires it; an SPA
+that offers the link without a 3.4 node gets the stated refusal from step 2.
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py
index 1271c4e..77e6fac 100644
--- a/packages/meshbay-common/src/meshbay_common/__init__.py
+++ b/packages/meshbay-common/src/meshbay_common/__init__.py
@@ -236,5 +236,10 @@ __version__ = "0.15.0"
# *wrong* one outright and treats an absent one as "this node cannot prove
# itself early", which it discovers from the node's answer and never from the
# version number. `MNP_MIN_SUPPORTED` does not move.
+#
+# The same version adds invitation links: `invite_link_create` /
+# `invite_link_result` and `invite_cancel`, a code bound to no account until it
+# is redeemed. Additive in the same way — a 3.3 node answers `unknown message
+# type`, and no client can hold a link code for a node that could not issue one.
MNP_VERSION = "3.4"
MHP_VERSION = "0.1"
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py
index 20b5080..23040be 100644
--- a/packages/meshbay-common/src/meshbay_common/adminop.py
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -36,6 +36,13 @@ ADMIN_TRANSCRIPT_PREFIX = b"meshbay:admin:v1"
OP_FILE_DELETE = "file_delete"
OP_DIR_DELETE = "dir_delete"
OP_INVITE_CREATE = "invite_create"
+# An invitation bound to no account (docs/invite-links.md). Its own op rather
+# than `invite_create` with an empty subject: that subject is the invitee, and
+# what the operator is shown before signing has to name the outcome (H5) — here
+# "a link into this group", `link:<group_id>`.
+OP_INVITE_LINK_CREATE = "invite_link_create"
+# Taking back an unredeemed link, by the handle it was issued with.
+OP_INVITE_CANCEL = "invite_cancel"
OP_MEMBER_REVOKE = "member_revoke"
# Rotating the group key is what actually takes it away from a revoked member:
# revocation stops the node serving the *next* key, and they still hold the
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index 5374742..4df06bf 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -153,6 +153,8 @@ class MNP:
JOIN_REQUEST = "join_request" # client → node: pair/recognise this identity
JOIN_RESULT = "join_result" # node → client: outcome + wrapped GEK
INVITE_CREATE = "invite_create" # operator → node: issue a pairing code
+ INVITE_LINK_CREATE = "invite_link_create" # operator → node: a code bound to no account
+ INVITE_CANCEL = "invite_cancel" # operator → node: take back an unredeemed link
MEMBER_REVOKE = "member_revoke" # operator → node: stop serving the key
MEMBER_REVOKE_ACK = "member_revoke_ack"
MEMBER_UNPIN = "member_unpin" # operator → node: forget an identity
@@ -222,6 +224,7 @@ class MNP:
GEK_ROTATE = "gek_rotate" # operator → node: new group key
GEK_ROTATE_ACK = "gek_rotate_ack"
INVITE_RESULT = "invite_result" # node → operator: the code, once
+ INVITE_LINK_RESULT = "invite_link_result" # node → operator: link code + handle, once
NODE_STATUS = "node_status" # operator → node: list all groups + roots
NODE_STATUS_ACK = "node_status_ack" # node → operator: full status
ROOT_ADD = "root_add" # operator → node: add a directory to a group
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index c16e49f..34d186a 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -2350,7 +2350,11 @@ def main() -> None:
if invites:
print()
for i in invites:
- print(f"pending invite user {i['user_id'][:12]} "
+ # A link names nobody until it is used, so its handle is what
+ # identifies it — and what `cancel` takes.
+ who = (f"link {i['invite_id']}" if i.get("kind") == "link"
+ else f"user {i['user_id'][:12]}")
+ print(f"pending invite {who} "
f"group {(i['group_id'] or 'node-wide')[:8]} "
f"expires {i['expires_at']}")
return
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index b680d43..e6b2d1f 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -41,7 +41,7 @@ from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR
from meshbay_node.config import DEFAULT_CONFIG_PATH
from meshbay_node.roots import RootError, RootSet, off_disk
-from meshbay_node.roster import Roster
+from meshbay_node.roster import LinkInviteLimit, Roster
log = logging.getLogger(__name__)
@@ -236,6 +236,41 @@ async def create_invite(state: dict, group_id: str, username: str, *,
"username": username, "user_id": user_id}
+async def create_link_invite(state: dict, group_id: str, *,
+ created_by: str = "local-cli") -> dict:
+ """
+ Issue a code bound to no account, for an invitation link.
+
+ Nothing is registered on the hub here, unlike `create_invite`: there is no
+ account to register yet. The hub half is a ticket the inviter's client asks
+ the hub for, bound to the invitee's address (docs/invite-links.md §3.5).
+ """
+ roster = _roster(state)
+ _group_ctx(state, group_id)
+ config = state.get("config")
+ ttl = (config.node.invite_ttl_hours if config else 168) * 3600
+ try:
+ code, invite_id, expires = await roster.create_link_invite(
+ group_id, created_by, ttl=ttl)
+ except LinkInviteLimit as e:
+ raise OpError(str(e), status=429) from e
+ log.info("Invitation link issued: group=%s invite=%s", group_id[:8], invite_id[:8])
+ return {"code": code, "invite_id": invite_id, "expires_at": expires,
+ "group_id": group_id}
+
+
+async def cancel_invite(state: dict, group_id: str, invite_id: str) -> dict:
+ """Take back an unredeemed invitation link. Unknown or spent is a refusal,
+ so a mistyped handle does not read as success."""
+ roster = _roster(state)
+ _group_ctx(state, group_id)
+ if not await roster.cancel_invite(group_id, invite_id):
+ raise OpError("No unredeemed invitation link with that id in this group",
+ status=404)
+ log.info("Invitation link cancelled: group=%s invite=%s", group_id[:8], invite_id[:8])
+ return {"cancelled": True, "invite_id": invite_id, "group_id": group_id}
+
+
async def revoke_member(state: dict, user_id: str, group_id: str) -> dict:
"""
Stop serving the group key to someone.
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index 9533f25..8360df3 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -29,6 +29,7 @@ from datetime import UTC, datetime, timedelta
from pathlib import Path
import aiosqlite
+from meshbay_common.join import ROLE_MEMBER
from meshbay_common.paths import fold
log = logging.getLogger(__name__)
@@ -58,6 +59,21 @@ DEFAULT_PAIR_TTL = 24 * 3600 # seconds — operator pairing
# keys by its hash, so a longer window widens nothing an attacker can use.
DEFAULT_DEVICE_REQUEST_TTL = 3600
+# What an invitation is bound to. An `account` code names its user_id when it is
+# issued; a `link` code names nobody until it is redeemed, because a link goes to
+# someone who may not have an account yet (docs/invite-links.md). The kind is a
+# column rather than an empty user_id so that "bound to nobody" can never be
+# read as "bound to anybody" — the mistake AV1 records, once per codebase.
+KIND_ACCOUNT = "account"
+KIND_LINK = "link"
+# Unredeemed links one group may hold. A link is a bearer code at this node, so
+# the number outstanding is the number of strangers who could walk in with one.
+MAX_LINK_INVITES_PER_GROUP = 20
+
+
+class LinkInviteLimit(Exception):
+ """The group already holds as many unredeemed links as it may."""
+
_SCHEMA = """\
-- One row per DEVICE, not per person. A browser and a desktop client are two
-- keys belonging to one account, and `user_id` alone as the key made the second
@@ -146,7 +162,9 @@ CREATE TABLE IF NOT EXISTS invites (
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
- used_at TEXT
+ used_at TEXT,
+ kind TEXT NOT NULL DEFAULT 'account',
+ invite_id TEXT NOT NULL DEFAULT ''
);
"""
@@ -221,6 +239,14 @@ class Roster:
if "username" not in columns:
await self._db.execute(
"ALTER TABLE invites ADD COLUMN username TEXT NOT NULL DEFAULT ''")
+ # The same, for invitation links. Every row that predates them was
+ # issued to an account, which is what the default says.
+ if "kind" not in columns:
+ await self._db.execute(
+ "ALTER TABLE invites ADD COLUMN kind TEXT NOT NULL DEFAULT 'account'")
+ if "invite_id" not in columns:
+ await self._db.execute(
+ "ALTER TABLE invites ADD COLUMN invite_id TEXT NOT NULL DEFAULT ''")
await self._migrate_identities_to_devices()
await self._db.commit()
@@ -1067,20 +1093,72 @@ class Roster:
"""
assert self._db
await self._db.execute(
- "DELETE FROM invites WHERE group_id = ? AND user_id = ? AND used_at IS NULL",
- (group_id, user_id),
+ "DELETE FROM invites WHERE group_id = ? AND user_id = ? AND used_at IS NULL "
+ "AND kind = ?",
+ (group_id, user_id, KIND_ACCOUNT),
)
code = generate_code()
expires = datetime.now(UTC) + timedelta(seconds=ttl)
await self._db.execute(
"INSERT INTO invites (code_hash, group_id, user_id, username, role, "
- "created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ "created_by, created_at, expires_at, kind) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(hash_code(code), group_id, user_id, username, role, created_by, _now(),
- expires.isoformat(timespec="seconds")),
+ expires.isoformat(timespec="seconds"), KIND_ACCOUNT),
)
await self._db.commit()
return code
+ async def create_link_invite(
+ self, group_id: str, created_by: str, ttl: int = DEFAULT_INVITE_TTL,
+ ) -> tuple[str, str, str]:
+ """
+ Issue a code bound to no account: `(code, invite_id, expires_at)`.
+
+ Always a member of one named group — never node-wide, never an operator,
+ because whoever holds it is admitted without being named. `invite_id`
+ is a handle for cancelling it, random and unrelated to the code, so it
+ can be shown and sent about where the code must not be.
+ """
+ assert self._db
+ if not group_id:
+ raise ValueError("an invitation link names a group")
+ async with self._db.execute(
+ "SELECT COUNT(*) FROM invites WHERE group_id = ? AND kind = ? "
+ "AND used_at IS NULL AND expires_at > ?",
+ (group_id, KIND_LINK, _now()),
+ ) as cur:
+ (outstanding,) = await cur.fetchone()
+ if outstanding >= MAX_LINK_INVITES_PER_GROUP:
+ raise LinkInviteLimit(
+ f"this group already has {outstanding} unredeemed invitation links; "
+ "cancel one or wait for one to be used or to expire")
+ code = generate_code()
+ invite_id = secrets.token_hex(16)
+ expires = (datetime.now(UTC) + timedelta(seconds=ttl)).isoformat(timespec="seconds")
+ await self._db.execute(
+ "INSERT INTO invites (code_hash, group_id, user_id, username, role, "
+ "created_by, created_at, expires_at, kind, invite_id) "
+ "VALUES (?, ?, '', '', ?, ?, ?, ?, ?, ?)",
+ (hash_code(code), group_id, ROLE_MEMBER, created_by, _now(), expires,
+ KIND_LINK, invite_id),
+ )
+ await self._db.commit()
+ return code, invite_id, expires
+
+ async def cancel_invite(self, group_id: str, invite_id: str) -> bool:
+ """Take back an unredeemed link of this group. A redeemed one stays: it
+ is the record that the join happened."""
+ assert self._db
+ if not invite_id:
+ return False
+ cur = await self._db.execute(
+ "DELETE FROM invites WHERE kind = ? AND invite_id = ? AND group_id = ? "
+ "AND used_at IS NULL",
+ (KIND_LINK, invite_id, group_id),
+ )
+ await self._db.commit()
+ return cur.rowcount > 0
+
async def drop_invites(self, group_id: str, user_id: str) -> int:
"""
Cancel any code this person has not redeemed yet for this group.
@@ -1090,16 +1168,22 @@ class Roster:
"""
assert self._db
cur = await self._db.execute(
- "DELETE FROM invites WHERE group_id = ? AND user_id = ? AND used_at IS NULL",
- (group_id, user_id),
+ "DELETE FROM invites WHERE group_id = ? AND user_id = ? AND used_at IS NULL "
+ "AND kind = ?",
+ (group_id, user_id, KIND_ACCOUNT),
)
await self._db.commit()
return cur.rowcount
- async def consume_invite(self, code: str, user_id: str) -> dict | None:
+ async def consume_invite(self, code: str, user_id: str,
+ group_id: str = "") -> dict | None:
"""
Redeem a code for `user_id`, or return None.
+ `group_id` is the group the redeeming connection authenticated to. An
+ account code ignores it, as it always has; a link requires it, and it
+ must be the link's own group.
+
Single use is enforced by the UPDATE's WHERE clause: two connections racing
the same code cannot both see `used_at IS NULL`, so exactly one wins.
"""
@@ -1113,19 +1197,35 @@ class Roster:
return None
invite = dict(row)
- if invite["used_at"] is not None:
- return None
- # A code is valid for exactly one account, so a leaked code cannot be
- # redeemed by whoever finds it first.
- if invite["user_id"] != user_id:
+ if invite["used_at"] is not None or not user_id:
return None
if datetime.fromisoformat(invite["expires_at"]) < datetime.now(UTC):
return None
- cur = await self._db.execute(
- "UPDATE invites SET used_at = ? WHERE code_hash = ? AND used_at IS NULL",
- (_now(), code_hash),
- )
+ if invite["kind"] == KIND_ACCOUNT:
+ # A code is valid for exactly one account, so a leaked code cannot be
+ # redeemed by whoever finds it first.
+ if invite["user_id"] != user_id:
+ return None
+ update = ("UPDATE invites SET used_at = ? WHERE code_hash = ? "
+ "AND used_at IS NULL", (_now(), code_hash))
+ elif invite["kind"] == KIND_LINK:
+ # Whoever holds it — so everything else about it is fixed: a member,
+ # of the group it names, reached through that group, by somebody not
+ # already in it. A row that says anything else is not honoured.
+ if (invite["role"] != ROLE_MEMBER or not invite["group_id"]
+ or invite["group_id"] != group_id):
+ return None
+ member = await self.get_member(group_id, user_id)
+ if member and member["status"] == "active":
+ return None
+ update = ("UPDATE invites SET used_at = ?, user_id = ? WHERE code_hash = ? "
+ "AND used_at IS NULL", (_now(), user_id, code_hash))
+ invite["user_id"] = user_id
+ else:
+ return None
+
+ cur = await self._db.execute(*update)
await self._db.commit()
if cur.rowcount == 0:
return None
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 809c5c5..95b628f 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -55,7 +55,9 @@ from meshbay_common.adminop import (
OP_GEK_ROTATE,
OP_GROUP_ATTACH,
OP_GROUP_DETACH,
+ OP_INVITE_CANCEL,
OP_INVITE_CREATE,
+ OP_INVITE_LINK_CREATE,
OP_MEMBER_REVOKE,
OP_MEMBER_UNPIN,
OP_MUSICBRAINZ_ENABLED,
@@ -147,6 +149,7 @@ from meshbay_node.roots import (
off_disk,
safe_subdir,
)
+from meshbay_node.roster import KIND_ACCOUNT, KIND_LINK
from meshbay_node.transfers import TransferSlots
from meshbay_node.transport.wire import index_sync_message
@@ -181,6 +184,9 @@ USER_BLOB_ACCOUNT_MAX = 8 * 1024 * 1024
_USER_BLOB_KIND_RE = re.compile(
r"^(playlists|playlist:[A-Za-z0-9_-]{1,64})$")
+# An invitation link's handle, as `roster.create_link_invite` mints it.
+_INVITE_ID_RE = re.compile(r"[0-9a-f]{32}")
+
# Chat link-preview results, kept in memory only (docs/MESHBAY_DESIGN.md §6.5:
# the node
# produces enrichment on demand and keeps nothing durable — the asking device
@@ -704,6 +710,10 @@ class WebRTCPeerSession:
self._do_admin_response(msg)
elif mtype == MNP.INVITE_CREATE:
self._do_invite_create(msg)
+ elif mtype == MNP.INVITE_LINK_CREATE:
+ self._do_invite_link_create(msg)
+ elif mtype == MNP.INVITE_CANCEL:
+ self._do_invite_cancel(msg)
elif mtype == MNP.MEMBER_REVOKE:
self._do_member_revoke(msg)
elif mtype == MNP.DEVICE_REQUEST:
@@ -1228,6 +1238,44 @@ class WebRTCPeerSession:
"username": str(msg.get("username", ""))[:64],
})
+ def _do_invite_link_create(self, msg: dict) -> None:
+ """
+ Issue a code bound to no account, for an invitation link — into the
+ group this connection authenticated to, and no other: a link names its
+ group, so the operator signs for exactly that one (docs/invite-links.md).
+ """
+ group_id = self._group_id or ""
+ if not group_id:
+ self._send({"type": "error", "detail": "No group on this connection"})
+ return
+ if msg.get("group_id") and msg["group_id"] != group_id:
+ self._send({"type": "error", "detail": "Wrong group for this session"})
+ return
+ if not self._has_admin_authority():
+ self._send({
+ "type": "error",
+ "detail": "No operator paired — run `meshbay-node operator pair`",
+ })
+ return
+ self._issue_admin_challenge(
+ OP_INVITE_LINK_CREATE, f"link:{group_id}", {"group_id": group_id})
+
+ def _do_invite_cancel(self, msg: dict) -> None:
+ """Take back an unredeemed link of this group, by its handle."""
+ group_id = self._group_id or ""
+ invite_id = str(msg.get("invite_id", ""))
+ if not group_id:
+ self._send({"type": "error", "detail": "No group on this connection"})
+ return
+ if not _INVITE_ID_RE.fullmatch(invite_id):
+ self._send({"type": "error", "detail": "Not an invitation id"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ self._issue_admin_challenge(
+ OP_INVITE_CANCEL, invite_id, {"group_id": group_id, "invite_id": invite_id})
+
async def _do_keypair_bundle_fetch(self) -> None:
"""Serve the caller's encrypted keypair bundle during the handshake window."""
bundle_store = self._ctx.get("bundle_store")
@@ -1561,14 +1609,21 @@ class WebRTCPeerSession:
# re-invited). Without this gate a stale roster row lets them
# back in without proving they received the new code.
pending_invite = any(
- i["user_id"] == user_id
+ i["kind"] == KIND_ACCOUNT
+ and i["user_id"] == user_id
and i["group_id"] in (session_group, "")
for i in await roster.list_invites())
- if pending_invite:
+ # Or they bring a link for this group: somebody already pinned here
+ # through another group, which is the ordinary case for a link, or
+ # somebody removed from it and invited back. Only when they are not
+ # an active member — a member opening the group leaves the link for
+ # whoever it was meant for.
+ active = bool(member) and member.get("status") == "active"
+ if pending_invite or (code and not active):
if not code:
self._join_refuse("code_required")
return
- invite = await roster.consume_invite(code, user_id)
+ invite = await roster.consume_invite(code, user_id, session_group)
if not invite:
self._join_refuse("code_invalid")
return
@@ -1580,7 +1635,8 @@ class WebRTCPeerSession:
self._audit_join(
"join_pinned",
f"group={invite['group_id'][:8]} role={invite['role']} "
- "via=code (device already known)")
+ f"via={'link' if invite['kind'] == KIND_LINK else 'code'} "
+ "(device already known)")
member = (await roster.get_member(session_group, user_id)
or await roster.get_member(invite["group_id"], user_id))
@@ -1609,7 +1665,7 @@ class WebRTCPeerSession:
self._join_refuse("code_required")
return
- invite = await roster.consume_invite(code, user_id)
+ invite = await roster.consume_invite(code, user_id, session_group)
if not invite:
self._join_refuse("code_invalid")
return
@@ -1620,7 +1676,8 @@ class WebRTCPeerSession:
# left the roster nameless and `member revoke <name>` unable to match.
roster, user_id, invite["username"] or username, pk_ed_b64, pk_x_b64,
group_id=invite["group_id"], role=invite["role"],
- approved_by=invite["created_by"], via="code")
+ approved_by=invite["created_by"],
+ via="link" if invite["kind"] == KIND_LINK else "code")
# The roster row comes from the invitation; the key comes from the
# connection. An operator pairing is node-wide (empty group), but they
# redeemed the code while opening a group and expect to read it — and
@@ -5969,6 +6026,12 @@ class WebRTCPeerSession:
elif pending["op"] == OP_INVITE_CREATE:
self._spawn(
self._admin_exec_invite_create(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_INVITE_LINK_CREATE:
+ self._spawn(
+ self._admin_exec_invite_link_create(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_INVITE_CANCEL:
+ self._spawn(
+ self._admin_exec_invite_cancel(pending, transcript, sig_bytes))
elif pending["op"] == OP_GEK_ROTATE:
self._spawn(
self._admin_exec_gek_rotate(pending, transcript, sig_bytes))
@@ -6106,6 +6169,47 @@ class WebRTCPeerSession:
"username": result.get("username", ""),
})
+ async def _admin_exec_invite_link_create(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", "invite_link_create")
+ return
+ try:
+ result = await self._run_op(
+ ops.create_link_invite, pending["payload"]["group_id"],
+ created_by=self._user_id or "")
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("invite_link_create", f"invite={result['invite_id'][:8]}")
+ self._send({
+ "type": MNP.INVITE_LINK_RESULT,
+ "v": MNP_VERSION,
+ "code": result["code"],
+ "invite_id": result["invite_id"],
+ "expires_at": result["expires_at"],
+ "group_id": result["group_id"],
+ })
+
+ async def _admin_exec_invite_cancel(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"invite_cancel:{pending['subject'][:8]}")
+ return
+ payload = pending["payload"]
+ try:
+ await self._run_op(ops.cancel_invite, payload["group_id"], payload["invite_id"])
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("invite_cancel", f"invite={payload['invite_id'][:8]}")
+ self._send({"type": "ack", "v": MNP_VERSION, "detail": "invite_cancelled",
+ "invite_id": payload["invite_id"]})
+
async def _exec_file_delete(self, ctx: dict, file_id: str, entry) -> None:
file_path, refusal = await off_disk(ctx["roots"], _locate, ctx["roots"], entry)
if refusal == ROOT_NOT_SERVED:
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index 888487c..f6b93ea 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -322,6 +322,14 @@ def create_ui_app(state: dict) -> FastAPI:
async def create_invite(group_id: str, username: str):
return await _op(lambda: ops.create_invite(state, group_id, username))
+ @app.post("/api/groups/{group_id}/invite-links")
+ async def create_link_invite(group_id: str):
+ return await _op(lambda: ops.create_link_invite(state, group_id))
+
+ @app.delete("/api/groups/{group_id}/invite-links/{invite_id}")
+ async def cancel_invite(group_id: str, invite_id: str):
+ return await _op(lambda: ops.cancel_invite(state, group_id, invite_id))
+
@app.get("/api/resolve")
async def resolve_user(username: str):
return await _op(lambda: ops.resolve_user(state, username))
diff --git a/packages/meshbay-node/tests/test_link_invites.py b/packages/meshbay-node/tests/test_link_invites.py
new file mode 100644
index 0000000..f5081d2
--- /dev/null
+++ b/packages/meshbay-node/tests/test_link_invites.py
@@ -0,0 +1,270 @@
+"""
+Invitation links: a code bound to no account until somebody redeems it.
+
+A link is sent to someone who may not have an account yet, so its code cannot
+name one. That makes it a bearer code at the node — the hub's ticket, bound to
+a verified address, is what decides who can reach the node at all
+(docs/invite-links.md §3). Everything here is a way a bearer code could be made
+to mean more than "one new member of this group, once", or a way an unbound row
+could leak into the code paths written for bound ones. The second family is the
+one to watch: `user_id = ''` must never read as "anyone" (AV1).
+"""
+
+import sqlite3
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from meshbay_common.crypto import generate_gek, unwrap_gek_aes
+from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR
+from meshbay_node.roster import (
+ KIND_ACCOUNT,
+ KIND_LINK,
+ MAX_LINK_INVITES_PER_GROUP,
+ LinkInviteLimit,
+ Roster,
+ hash_code,
+)
+from test_roster_pairing import _join_msg, _keypair, _keypair_full, _last, _session, _x_raw
+
+GROUP_A = "a" * 32
+GROUP_B = "b" * 32
+
+
+@pytest.fixture
+async def roster(tmp_path):
+ r = Roster(db_path=tmp_path / "roster.db")
+ await r.open()
+ yield r
+ await r.close()
+
+
+async def _link(roster, group_id=GROUP_B):
+ code, invite_id, _expires = await roster.create_link_invite(group_id, "cbesson")
+ return code, invite_id
+
+
+# ── The roster ───────────────────────────────────────────────────────────────
+
+async def test_a_link_is_redeemed_once_and_then_names_its_redeemer(roster):
+ code, _ = await _link(roster)
+ invite = await roster.consume_invite(code, "alice", group_id=GROUP_B)
+ assert invite and invite["role"] == ROLE_MEMBER and invite["group_id"] == GROUP_B
+ assert invite["user_id"] == "alice"
+
+ assert await roster.consume_invite(code, "mallory", group_id=GROUP_B) is None
+ used = [i for i in await roster.list_invites(include_used=True)
+ if i["code_hash"] == hash_code(code)]
+ assert used[0]["user_id"] == "alice" and used[0]["used_at"]
+
+
+async def test_a_link_is_good_for_its_own_group_only(roster):
+ code, _ = await _link(roster, GROUP_B)
+ for other in (GROUP_A, ""):
+ assert await roster.consume_invite(code, "alice", group_id=other) is None
+ # Not spent by the refusals.
+ assert await roster.consume_invite(code, "alice", group_id=GROUP_B)
+
+
+async def test_an_active_member_cannot_spend_somebody_elses_link(roster):
+ await roster.set_member(GROUP_B, "bob", ROLE_MEMBER, "active", "cbesson")
+ code, _ = await _link(roster)
+ assert await roster.consume_invite(code, "bob", group_id=GROUP_B) is None
+ assert await roster.consume_invite(code, "alice", group_id=GROUP_B)
+
+
+async def test_a_link_never_carries_operator_authority(roster, tmp_path):
+ code, _ = await _link(roster)
+ rows = [i for i in await roster.list_invites() if i["kind"] == KIND_LINK]
+ assert rows and all(r["role"] == ROLE_MEMBER for r in rows)
+
+ # Even a row edited on disk to say otherwise is refused, not honoured.
+ con = sqlite3.connect(tmp_path / "roster.db")
+ con.execute("UPDATE invites SET role = ? WHERE code_hash = ?",
+ (ROLE_OPERATOR, hash_code(code)))
+ con.commit()
+ con.close()
+ assert await roster.consume_invite(code, "alice", group_id=GROUP_B) is None
+
+
+async def test_an_empty_account_on_a_bound_invite_is_nobody(roster, tmp_path):
+ """
+ AV1's shape: an account invitation whose `user_id` is empty must match no
+ one — not whoever turns up. Only `kind = 'link'` makes a row a bearer code.
+ """
+ code = await roster.create_invite(GROUP_B, "", ROLE_MEMBER, "cbesson")
+ for who in ("alice", ""):
+ assert await roster.consume_invite(code, who, group_id=GROUP_B) is None
+
+
+async def test_bound_invitations_and_links_do_not_cancel_each_other(roster):
+ code, invite_id = await _link(roster)
+ # Re-inviting an account supersedes that account's earlier code — and must
+ # not take the group's unredeemed links with it.
+ await roster.create_invite(GROUP_B, "carol", ROLE_MEMBER, "cbesson")
+ await roster.create_invite(GROUP_B, "carol", ROLE_MEMBER, "cbesson")
+ # Cancelling "nobody's" invitations is not cancelling the links.
+ assert await roster.drop_invites(GROUP_B, "") == 0
+ assert invite_id in {i["invite_id"] for i in await roster.list_invites()}
+ assert await roster.consume_invite(code, "alice", group_id=GROUP_B)
+
+
+async def test_links_are_capped_per_group(roster):
+ codes = [(await _link(roster, GROUP_B))[0] for _ in range(MAX_LINK_INVITES_PER_GROUP)]
+ with pytest.raises(LinkInviteLimit):
+ await _link(roster, GROUP_B)
+ # Another group has its own allowance.
+ await _link(roster, GROUP_A)
+ # A redeemed link is no longer outstanding, so it frees a place.
+ assert await roster.consume_invite(codes[0], "alice", group_id=GROUP_B)
+ await _link(roster, GROUP_B)
+
+
+async def test_a_link_needs_a_group(roster):
+ with pytest.raises(ValueError):
+ await roster.create_link_invite("", "cbesson")
+
+
+async def test_cancel_takes_back_an_unredeemed_link_of_its_own_group(roster):
+ code, invite_id = await _link(roster, GROUP_B)
+ assert not await roster.cancel_invite(GROUP_A, invite_id)
+ assert await roster.cancel_invite(GROUP_B, invite_id)
+ assert await roster.consume_invite(code, "alice", group_id=GROUP_B) is None
+
+ code, invite_id = await _link(roster, GROUP_B)
+ await roster.consume_invite(code, "alice", group_id=GROUP_B)
+ assert not await roster.cancel_invite(GROUP_B, invite_id), (
+ "a redeemed link is the record of the join, not something to cancel")
+
+
+async def test_an_expired_link_is_refused(roster):
+ code, _, _ = await roster.create_link_invite(GROUP_B, "cbesson", ttl=-1)
+ assert await roster.consume_invite(code, "alice", group_id=GROUP_B) is None
+
+
+async def test_a_roster_from_before_links_opens_and_keeps_its_codes(tmp_path):
+ """The two columns arrive by ALTER TABLE; an existing code stays a bound one."""
+ path = tmp_path / "old.db"
+ con = sqlite3.connect(path)
+ con.execute("""CREATE TABLE invites (
+ code_hash TEXT PRIMARY KEY, group_id TEXT NOT NULL, user_id TEXT NOT NULL,
+ username TEXT NOT NULL DEFAULT '', role TEXT NOT NULL, created_by TEXT NOT NULL,
+ created_at TEXT NOT NULL, expires_at TEXT NOT NULL, used_at TEXT)""")
+ expires = (datetime.now(UTC) + timedelta(days=1)).isoformat(timespec="seconds")
+ con.execute("INSERT INTO invites VALUES (?, ?, ?, '', ?, 'op', ?, ?, NULL)",
+ (hash_code("K7P2-9WQX"), GROUP_B, "alice", ROLE_MEMBER,
+ datetime.now(UTC).isoformat(), expires))
+ con.commit()
+ con.close()
+
+ r = Roster(db_path=path)
+ await r.open()
+ try:
+ [row] = await r.list_invites()
+ assert row["kind"] == KIND_ACCOUNT
+ assert await r.consume_invite("K7P2-9WQX", "mallory", group_id=GROUP_B) is None
+ assert await r.consume_invite("K7P2-9WQX", "alice", group_id=GROUP_B)
+ finally:
+ await r.close()
+
+
+# ── The join ─────────────────────────────────────────────────────────────────
+
+async def test_a_newcomer_joins_with_a_link_code(tmp_path, roster):
+ gek = generate_gek()
+ code, _ = await _link(roster)
+ sk_ed, pk_ed_b64, pk_x_b64, sk_x = _keypair_full()
+ session = _session(tmp_path, roster, user_id="alice", group_id=GROUP_B, gek=gek)
+
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code,
+ user_id="alice", group_id=GROUP_B))
+
+ reply = _last(session)
+ assert reply["ok"] is True and reply["gek"] is True
+ assert unwrap_gek_aes(reply, *_x_raw(sk_x, pk_x_b64)) == gek
+ member = await roster.get_member(GROUP_B, "alice")
+ assert member and member["role"] == ROLE_MEMBER
+
+
+async def test_a_link_code_opens_no_other_group(tmp_path, roster):
+ code, _ = await _link(roster, GROUP_A)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ session = _session(tmp_path, roster, user_id="alice", group_id=GROUP_B,
+ gek=generate_gek())
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code,
+ user_id="alice", group_id=GROUP_B))
+ assert _last(session).get("reason") == "code_invalid"
+ assert await roster.get_member(GROUP_A, "alice") is None
+
+
+async def test_someone_already_pinned_elsewhere_joins_with_a_link(tmp_path, roster):
+ """The common case: known to this node through another group."""
+ gek_b = generate_gek()
+ sk_ed, pk_ed_b64, pk_x_b64, sk_x = _keypair_full()
+ await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code")
+ await roster.set_member(GROUP_A, "grenet", ROLE_MEMBER, "active", "cbesson")
+ code, _ = await _link(roster, GROUP_B)
+
+ session = _session(tmp_path, roster, user_id="grenet", group_id=GROUP_B, gek=gek_b)
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code,
+ user_id="grenet", group_id=GROUP_B))
+
+ reply = _last(session)
+ assert reply["ok"] is True and reply["gek"] is True
+ assert unwrap_gek_aes(reply, *_x_raw(sk_x, pk_x_b64)) == gek_b
+ assert await roster.get_member(GROUP_B, "grenet")
+
+
+async def test_a_member_opening_the_group_with_a_link_leaves_it_unspent(tmp_path, roster):
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code")
+ await roster.set_member(GROUP_B, "grenet", ROLE_MEMBER, "active", "cbesson")
+ code, _ = await _link(roster, GROUP_B)
+
+ session = _session(tmp_path, roster, user_id="grenet", group_id=GROUP_B,
+ gek=generate_gek())
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code,
+ user_id="grenet", group_id=GROUP_B))
+ assert _last(session)["ok"] is True
+ assert await roster.consume_invite(code, "alice", group_id=GROUP_B)
+
+
+async def test_a_known_device_with_a_wrong_code_is_told_so(tmp_path, roster):
+ """Not the flat `not_authorized_for_group`: a code was offered and refused,
+ and the refusal counts against the attempt budget like any other."""
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ await roster.pin_identity("eve", "eve", pk_ed_b64, pk_x_b64, "code")
+ await roster.set_member(GROUP_A, "eve", ROLE_MEMBER, "active", "cbesson")
+ session = _session(tmp_path, roster, user_id="eve", group_id=GROUP_B,
+ gek=generate_gek())
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code="AAAA-AAAA",
+ user_id="eve", group_id=GROUP_B))
+ assert _last(session).get("reason") == "code_invalid"
+ assert session._join_attempts == 1
+
+
+async def test_someone_removed_can_come_back_with_a_link(tmp_path, roster):
+ """A revoked member still has a member row here. A link sent to bring them
+ back must work — it is what the operator asked for."""
+ gek = generate_gek()
+ sk_ed, pk_ed_b64, pk_x_b64, sk_x = _keypair_full()
+ await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code")
+ await roster.set_member(GROUP_B, "grenet", ROLE_MEMBER, "revoked", "cbesson")
+ session = _session(tmp_path, roster, user_id="grenet", group_id=GROUP_B, gek=gek)
+
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, user_id="grenet", group_id=GROUP_B))
+ assert _last(session).get("reason") == "not_authorized_for_group"
+
+ code, _ = await _link(roster, GROUP_B)
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code,
+ user_id="grenet", group_id=GROUP_B))
+ reply = _last(session)
+ assert reply["ok"] is True and reply["gek"] is True
+ assert unwrap_gek_aes(reply, *_x_raw(sk_x, pk_x_b64)) == gek
+ assert (await roster.get_member(GROUP_B, "grenet"))["status"] == "active"
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py
index 0d2aa3a..c0dc6d1 100644
--- a/packages/meshbay-node/tests/test_webrtc_transport.py
+++ b/packages/meshbay-node/tests/test_webrtc_transport.py
@@ -29,7 +29,9 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
from meshbay_common import MNP_VERSION
from meshbay_common.adminop import (
OP_FILE_DELETE,
+ OP_INVITE_CANCEL,
OP_INVITE_CREATE,
+ OP_INVITE_LINK_CREATE,
admin_transcript,
)
from meshbay_common.crypto import (
@@ -1406,6 +1408,153 @@ async def test_invite_then_join_delivers_the_gek(sk_node, sk_hub, gek, shared_di
await transport.close_all()
+async def _bearer_join(transport, sk_hub, user_id, peer_id, code, x25519_keypair,
+ expect_node_pk):
+ """A newcomer's first connection with a link code, as the browser makes it:
+ the challenge must prove the node key the link named before the code goes."""
+ sk_x_raw, pk_x_raw = x25519_keypair
+ sk_ed = Ed25519PrivateKey.generate()
+ pc, ch, q = await _open_channel(transport, peer_id)
+ nonce_c = os.urandom(NONCE_LEN)
+ ch.send(_pack({
+ "type": MNP.HANDSHAKE, "v": MNP_VERSION,
+ "token": _token(sk_hub, user_id, peer_id, TEST_GROUP),
+ "group_id": TEST_GROUP, "nonce": base64.b64encode(nonce_c).decode(),
+ }))
+ challenge = await asyncio.wait_for(q.get(), timeout=5.0)
+ nonce_s = base64.b64decode(challenge["nonce"])
+ assert challenge["node_pk"] == expect_node_pk
+ Ed25519PublicKey.from_public_bytes(base64.b64decode(expect_node_pk)).verify(
+ base64.b64decode(challenge["sig"]),
+ challenge_transcript(TEST_GROUP, nonce_c, nonce_s, webrtc_binding(
+ _extract_dtls_fp(pc.localDescription.sdp),
+ _extract_dtls_fp(pc.remoteDescription.sdp))))
+
+ pk_ed_b64 = pk_to_b64(sk_ed.public_key())
+ pk_x_b64 = base64.b64encode(pk_x_raw).decode()
+ ts = int(time.time())
+ ch.send(_pack({
+ "type": MNP.JOIN_REQUEST, "v": MNP_VERSION, "group_id": TEST_GROUP,
+ "pk_ed25519": pk_ed_b64, "pk_x25519": pk_x_b64, "code": code, "ts": ts,
+ "sig": base64.b64encode(sk_ed.sign(join_transcript(
+ node_pk_b64=challenge["node_pk"], group_id=TEST_GROUP, user_id=user_id,
+ pk_ed25519_b64=pk_ed_b64, pk_x25519_b64=pk_x_b64,
+ nonce_node=nonce_s, ts=ts))).decode(),
+ }))
+ result = await asyncio.wait_for(q.get(), timeout=5.0)
+ return pc, result, sk_x_raw, pk_x_raw
+
+
+@pytest.mark.asyncio
+async def test_a_link_is_issued_signed_redeemed_once_and_cancellable(
+ sk_node, sk_hub, gek, shared_dir, tmp_path, x25519_keypair):
+ """
+ Invitation links over a real DataChannel (docs/invite-links.md §3.4).
+
+ The operator signs `invite_link_create` naming the outcome, `link:<group>`;
+ the first account to bring the code is admitted and handed the key; the
+ second is refused; a link not yet used can be taken back, by its handle,
+ and is then refused too. Nothing is registered on the hub: there is no
+ account to register until somebody redeems it.
+ """
+ indexer = DirectoryIndexer(roots=one_root(shared_dir), group_id="g", sk_node=sk_node, gek=gek)
+ await indexer.initial_scan()
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ transport = WebRTCTransport(
+ sk_node=sk_node, hub_pk_pem=_hub_pk_pem(sk_hub), gek=gek,
+ roots=one_root(shared_dir), index=indexer.index, stun_servers=[],
+ )
+ transport._ctx["roster"] = roster
+ transport._ctx["has_admin_authority"] = True
+ transport._ctx["groups"] = {
+ TEST_GROUP: {"gek": gek, "roots": one_root(shared_dir), "index": indexer.index},
+ }
+ hub = _InviteHub()
+ transport._ctx["daemon_state"] = {
+ "roster": roster, "groups_ctx": transport._ctx["groups"], "hub": hub,
+ }
+ sk_admin = Ed25519PrivateKey.generate()
+ await roster.pin_identity("user-001", "grenet", pk_to_b64(sk_admin.public_key()),
+ "AA==", "code")
+ await roster.set_member("", "user-001", ROLE_OPERATOR, "active", "local-cli")
+ pc_admin, ch_admin, q_admin = await _setup_peer(transport, sk_hub, gek, "peer-admin")
+ node_pk = pk_to_b64(sk_node.public_key())
+ peers = [pc_admin]
+
+ async def signed(request: dict, op: str, subject: str) -> dict:
+ ch_admin.send(_pack({"v": MNP_VERSION, **request}))
+ challenge = await asyncio.wait_for(q_admin.get(), timeout=5.0)
+ assert challenge["type"] == MNP.ADMIN_CHALLENGE, challenge
+ assert (challenge["op"], challenge["subject"]) == (op, subject)
+ ch_admin.send(_pack({
+ "type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION, "op_id": challenge["op_id"],
+ "signature": base64.b64encode(
+ sk_admin.sign(_transcript_from(challenge))).decode(),
+ }))
+ return await asyncio.wait_for(q_admin.get(), timeout=5.0)
+
+ try:
+ link = await signed({"type": MNP.INVITE_LINK_CREATE, "group_id": TEST_GROUP},
+ OP_INVITE_LINK_CREATE, f"link:{TEST_GROUP}")
+ assert link["type"] == MNP.INVITE_LINK_RESULT, link
+ assert len(link["code"]) == 9 and len(link["invite_id"]) == 32
+ assert hub.added == [], "a link registers nobody on the hub"
+
+ pc, result, sk_x_raw, pk_x_raw = await _bearer_join(
+ transport, sk_hub, "user-003", "peer-first", link["code"], x25519_keypair,
+ node_pk)
+ peers.append(pc)
+ assert result["ok"] is True and result["gek"] is True, result
+ assert unwrap_gek_aes(result, sk_x_raw, pk_x_raw) == gek
+
+ pc, result, _, _ = await _bearer_join(
+ transport, sk_hub, "user-004", "peer-second", link["code"], x25519_keypair,
+ node_pk)
+ peers.append(pc)
+ assert result.get("reason") == "code_invalid"
+
+ spare = await signed({"type": MNP.INVITE_LINK_CREATE, "group_id": TEST_GROUP},
+ OP_INVITE_LINK_CREATE, f"link:{TEST_GROUP}")
+ done = await signed({"type": MNP.INVITE_CANCEL, "invite_id": spare["invite_id"]},
+ OP_INVITE_CANCEL, spare["invite_id"])
+ assert done.get("detail") == "invite_cancelled", done
+ pc, result, _, _ = await _bearer_join(
+ transport, sk_hub, "user-005", "peer-late", spare["code"], x25519_keypair,
+ node_pk)
+ peers.append(pc)
+ assert result.get("reason") == "code_invalid"
+ finally:
+ await roster.close()
+ for pc in peers:
+ await pc.close()
+ await transport.close_all()
+
+
+@pytest.mark.asyncio
+async def test_a_link_needs_the_operator(sk_node, sk_hub, gek, shared_dir, tmp_path):
+ """A member who is not the operator is refused before any challenge is issued."""
+ indexer = DirectoryIndexer(roots=one_root(shared_dir), group_id="g", sk_node=sk_node, gek=gek)
+ await indexer.initial_scan()
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ transport = WebRTCTransport(
+ sk_node=sk_node, hub_pk_pem=_hub_pk_pem(sk_hub), gek=gek,
+ roots=one_root(shared_dir), index=indexer.index, stun_servers=[],
+ )
+ transport._ctx["roster"] = roster
+ transport._ctx["has_admin_authority"] = False
+ pc, ch, q = await _setup_peer(transport, sk_hub, gek, "peer-member")
+ try:
+ ch.send(_pack({"type": MNP.INVITE_LINK_CREATE, "v": MNP_VERSION}))
+ reply = await asyncio.wait_for(q.get(), timeout=5.0)
+ assert reply["type"] == "error" and "operator" in reply["detail"]
+ assert await roster.list_invites() == []
+ finally:
+ await roster.close()
+ await pc.close()
+ await transport.close_all()
+
@pytest.mark.asyncio
async def test_gek_bundle_fetch_during_handshake(sk_node, sk_hub, gek, shared_dir,
tmp_path, x25519_keypair):