diff options
59 files changed, 1482 insertions, 319 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 diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md index 2206971..b3cd6eb 100644 --- a/docs/MESHBAY_NODE_PROTOCOL.md +++ b/docs/MESHBAY_NODE_PROTOCOL.md @@ -1,6 +1,6 @@ # MeshBay Node Protocol (MNP) -**Wire version:** `3.4` — `meshbay_common/__init__.py` (`MNP_VERSION`) +**Wire version:** `4.0` — `meshbay_common/__init__.py` (`MNP_VERSION`) **Oldest peer accepted:** `3.0` — `handshake.py` (`MNP_MIN_SUPPORTED`) **Normative implementation:** `meshbay-common` (`protocol.py`, `handshake.py`, `groupbox.py`, `chatbox.py`, `adminop.py`, `join.py`, `device.py`, `crypto.py`, @@ -462,6 +462,7 @@ absent. `verify_proof` compares with `hmac.compare_digest`. | Rule | Refusal | Rationale | |---|---|---| | JWT verifies under the hub's Ed25519 public key (`EdDSA`) | `Invalid JWT: ...` | | +| `aud == MNP_AUD`, and `exp`/`sub`/`scope` present (MNP 4.0) | `Invalid JWT: ...` | the member presents a short-lived **node-audience** token (`POST /v1/nodes/mnp-token`), not its hub session token — the operator holds whatever is presented, and the session token opens the hub API. The two audience strings are in `meshbay_common/tokens.py` | | `scope == "user"` | `Wrong token scope` | a node-scoped daemon token must not be usable as a client token | | `sub` non-empty | `Token has no subject` | | | `group_id` non-empty | `group_id is required` | an absent group means no membership check to make; there is no default group, and a node's first group is not one | @@ -866,10 +867,11 @@ 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 (`MESHBAY_DESIGN.md` §3.4). At the -node it is therefore a **bearer code**, and everything else about it is fixed: +first account that redeems it (`invite_link_create`, §10.4). Nothing stops a +stranger holding the whole link — the hub's ticket is a bearer secret too +(`MESHBAY_DESIGN.md` §3.4) — so what bounds it is that it works once, for seven +days, and can be cancelled. At the node it is 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 diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index bf6cc80..a11bb00 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -318,9 +318,10 @@ Send that code however you normally talk to them — message, mail, out loud. It works once, for that account only, for that group only, and the hub never sees it. You do not need to be online when they use it. -Someone with no account yet gets a link instead, bound to their e-mail address: -`meshbay-node member invite alice@example.org --link`. They open it, register -with that address, and land in the group without typing a code +Someone with no account yet gets a link instead, which you send them however you +like — a messaging application, a text: `meshbay-node member invite --link`. It +works once, for seven days, for whoever opens it first. They open it, register, +and land in the group without typing a code ([`USERGUIDE.md` §7](USERGUIDE.md#7-managing-people)). You can do the same thing from the **Members** tab of the group, from the diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index 0589929..6a86897 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -226,15 +226,15 @@ The person running the group can send you a **link** instead of a code — the way in when you have no account yet. 1. **Open the link.** It says *"You have been invited"*. -2. **Create an account** with the e-mail address the invitation was sent to — - exactly that address — or sign in if you already have one. Confirm your - address with the code the hub mails you, then sign in. +2. **Create an account**, with any e-mail address you like, or sign in if you + already have one. Confirm your address with the code the hub mails you, + then sign in. 3. You are brought back to the invitation: *"… invites you to join …"*. **Join.** 4. The group opens, and you type no code: the link carried it. -The link works once, and only for the account registered with that address; to -anyone else it says it was sent to another address. In the desktop +The link works once, for seven days: after that, or once somebody has used it, +it says it is no longer valid — ask for a new one. In the desktop application, paste the link into **Join with an invitation link** on the home page. @@ -522,7 +522,7 @@ and running `meshbay-node reload`: | Setting | Default | What it decides | |---|---|---| -| `invite_ttl_hours` | 168 (7 days) | how long an invitation stays usable | +| `invite_ttl_hours` | 168 (7 days) | how long an invitation code stays usable (an invitation link is always 7 days) | | `pair_ttl_hours` | 24 | how long an operator pairing code lasts | | `device_request_ttl_minutes` | 60 | how long a device request waits for approval | | `max_concurrent_streams` | 8 | how many people can watch video at once | @@ -570,16 +570,20 @@ need to be online then. **Inviting someone who has no account yet** is a link: ```bash -meshbay-node member invite alice@example.org --link +meshbay-node member invite --link meshbay-node member cancel <link id> # printed with the link, and in member list ``` -or the **Invite by link** box in the Members tab, under the first one: type -their e-mail address and **Create link**. Send them the -link, or leave **Send the invitation by e-mail** ticked and the hub mails it. -They register with that address and land in the group without typing a code. -The link works once and only for an account with that address, so a copy that -travels further — a forwarded mail, a chat — lets nobody else in. Links nobody +or the **Invite by link** box in the Members tab, under the first one: **Create +link**, then **Copy** it — or **Share** it, on a phone — and send it however you +like: a messaging application, a text. The e-mail address is optional: type one +and leave **Send the invitation by e-mail** ticked, and the hub mails the link +there too; it also names the link in the list below. They register (with any +address) and land in the group without typing a code. +**The link is for whoever opens it first**: it works once, for seven days, and +anyone holding it can use it — so send it only to the person it is for, and +**Cancel** it if it went somewhere else. You are notified when somebody joins +through one. Links nobody has used yet are listed under the box, and **Cancel** takes one back; once somebody joins through a link it leaves that list, and they are in the member list above it. A hub mails at most ten @@ -855,7 +859,7 @@ and every member re-admitted. It is small — back it up somewhere safe. | | | |---|---| | Invitation code | 7 days, single use, one account, one group | -| Invitation link | 7 days, single use, one group, the account registered with its address; 20 pending per group | +| Invitation link | 7 days (fixed), single use, one group, whoever opens it first; 20 pending per group | | Operator pairing code | 24 hours | | Device linking code | 1 hour | | Devices per account per node | 5 | diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py index 216a18f..842c52d 100644 --- a/packages/meshbay-common/src/meshbay_common/__init__.py +++ b/packages/meshbay-common/src/meshbay_common/__init__.py @@ -241,5 +241,13 @@ __version__ = "0.16.0" # `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" +# +# 4.0 (2026-09-25) is a MAJOR — a change to what a peer must *present*, not an +# additive message (§5.6). A member now authenticates to a node with a +# short-lived MNP-audience token (aud=MNP_AUD), never its hub session token: the +# node operator holds whatever is presented, and the session token opens the hub +# API. A pre-4.0 client presents the session token and is refused at the +# handshake — there is no compatibility branch, because leaving one would keep +# the disclosure reachable on every node. So the floor moves with it. +MNP_VERSION = "4.0" MHP_VERSION = "0.1" diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py index 73e0b2e..dedfeb1 100644 --- a/packages/meshbay-common/src/meshbay_common/handshake.py +++ b/packages/meshbay-common/src/meshbay_common/handshake.py @@ -63,6 +63,7 @@ from typing import Any, Protocol import jwt from meshbay_common import MNP_VERSION +from meshbay_common.tokens import MNP_AUD HANDSHAKE_PREFIX = b"meshbay:mnp:handshake:v1" CHALLENGE_PREFIX = b"meshbay:mnp:challenge:v1" @@ -84,7 +85,11 @@ CHALLENGE_PREFIX = b"meshbay:mnp:challenge:v1" # has no vocabulary to understand — or serving it outside every cap the operator # set, which makes the caps decoration. Neither is honest, so it is refused # here, with a code and a sentence. -MNP_MIN_SUPPORTED = "3.0" +# 4.0 (2026-09-25): a member authenticates with an MNP-audience token, not the +# hub session token (MNP_VERSION note). A pre-4.0 peer presents the session +# token, which this node now refuses — so the floor moves to 4.0 rather than +# leaving a branch that would keep a hub credential reachable by every node. +MNP_MIN_SUPPORTED = "4.0" ROLE_CLIENT = "client" ROLE_NODE = "node" @@ -274,8 +279,15 @@ def authorize_token( """ try: decoded = jwt.decode(token, hub_pk_pem, algorithms=["EdDSA"], - leeway=JWT_LEEWAY_SECONDS) + leeway=JWT_LEEWAY_SECONDS, + audience=MNP_AUD, + options={"require": ["exp", "sub", "scope"]}) except Exception as exc: + # An audience mismatch lands here too: a hub *session* token + # (aud=HUB_API_AUD) presented to a node is refused. That is the point — + # the credential a member hands a node must not be one that also opens + # the hub API (see meshbay_common.tokens). A member presents the + # short-lived MNP token instead. raise HandshakeError(f"Invalid JWT: {exc}") from exc # A node-scoped daemon token must not be usable as a client token (M9). diff --git a/packages/meshbay-common/src/meshbay_common/tokens.py b/packages/meshbay-common/src/meshbay_common/tokens.py new file mode 100644 index 0000000..c393be5 --- /dev/null +++ b/packages/meshbay-common/src/meshbay_common/tokens.py @@ -0,0 +1,28 @@ +"""Token audiences — one hub key, two purposes, never interchangeable. + +The hub signs everything with one Ed25519 key, but a token has to say what it is +*for*, or a credential minted for one purpose is honoured for another. Two +audiences settle it: + +- ``HUB_API_AUD`` — a session token, presented to the **hub API** (``hubFetch``, + signaling). Carried by the browser and the desktop client, and by a node for + its own hub calls. +- ``MNP_AUD`` — a short-lived token a member presents to a **node** in the MNP + handshake, and to nothing else. It authorises the member to that node + (``sub``, ``groups``, ``jti``) and is **useless at the hub API**. + +The reason this exists: a member hands whatever token it holds to every node it +connects to (the handshake authenticates with it). If that were the session +token, a node operator — who is in the threat model — would hold a live hub +credential for the member and could act as them at the hub. Separating the +audiences means the credential a node receives opens nothing at the hub, and the +credential the hub API accepts is never disclosed to a node. + +The hub API decode (:func:`meshbay_hub.auth.decode_access_token`) binds +``HUB_API_AUD``; the node handshake decode +(:func:`meshbay_common.handshake.authorize_token`) binds ``MNP_AUD``. Each +rejects the other's audience. +""" + +HUB_API_AUD = "meshbay:hub-api" +MNP_AUD = "meshbay:mnp" diff --git a/packages/meshbay-common/tests/test_handshake.py b/packages/meshbay-common/tests/test_handshake.py index ad615a9..8f385bb 100644 --- a/packages/meshbay-common/tests/test_handshake.py +++ b/packages/meshbay-common/tests/test_handshake.py @@ -28,6 +28,7 @@ from meshbay_common.handshake import ( verify_proof, webrtc_binding, ) +from meshbay_common.tokens import HUB_API_AUD, MNP_AUD GEK = b"\x11" * 32 GROUP = "g" * 32 @@ -59,8 +60,14 @@ def _token(sk_pem, **over): "iss": "test-hub", "sub": "user-1", "jti": "jti-1", "iat": now, "exp": now + 3600, "groups": [GROUP], "scope": "user", "pk_user": "pk", + # A member presents an MNP-audience token to a node. A hub session token + # (aud=HUB_API_AUD) is refused here — see test_a_hub_session_token_is_refused. + "aud": MNP_AUD, } payload.update(over) + # A None override omits the claim entirely (e.g. aud=None → no audience), + # rather than encoding a null value. + payload = {k: v for k, v in payload.items() if v is not None} return jwt.encode(payload, sk_pem, algorithm="EdDSA") @@ -96,6 +103,23 @@ def test_node_scoped_token_refused_on_client_path(hub_key): authorize_token(token, pk_pem, group_id=GROUP) +def test_a_hub_session_token_is_refused_by_a_node(hub_key): + """The core of the audience split: a member hands whatever token it presents + to the node operator, so it must not be the hub session token (aud=HUB_API_AUD), + which opens the hub API. Only the MNP-audience token is accepted here.""" + sk_pem, pk_pem = hub_key + session_token = _token(sk_pem, aud=HUB_API_AUD) + with pytest.raises(HandshakeError): + authorize_token(session_token, pk_pem, group_id=GROUP) + + +def test_a_token_with_no_audience_is_refused(hub_key): + sk_pem, pk_pem = hub_key + no_aud = _token(sk_pem, aud=None) + with pytest.raises(HandshakeError): + authorize_token(no_aud, pk_pem, group_id=GROUP) + + def test_unhosted_group_refused(hub_key): sk_pem, pk_pem = hub_key with pytest.raises(HandshakeError, match="not hosted"): @@ -236,7 +260,8 @@ def test_membership_refusal_carries_a_code_a_client_can_act_on(): pem_pub = sk.public_key().public_bytes( serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo) - token = _jwt.encode({"sub": "u1", "jti": "j1", "scope": "user", "groups": []}, + token = _jwt.encode({"sub": "u1", "jti": "j1", "scope": "user", "groups": [], + "exp": int(time.time()) + 3600, "aud": MNP_AUD}, pem_priv, algorithm="EdDSA") with pytest.raises(HandshakeError) as excinfo: @@ -265,7 +290,8 @@ def test_a_group_this_node_does_not_host_is_refused_with_a_code(): group = "g" * 32 token = _jwt.encode( - {"sub": "u1", "jti": "j1", "scope": "user", "groups": [group]}, + {"sub": "u1", "jti": "j1", "scope": "user", "groups": [group], + "exp": int(time.time()) + 3600, "aud": MNP_AUD}, pem_priv, algorithm="EdDSA") # A member of the group, on a node that does not host it. diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py index b4b2f4f..381378c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py @@ -329,6 +329,25 @@ async def admin_patch_user( raise HTTPException( status_code=422, detail="status must be active, suspended, or revoked") + # Revoking is not a status write. It is signed and broadcast to every + # node so the account is refused there at once; PATCH would change only + # the hub row and leave the nodes unaware — a "revoked" that is not the + # revoked the design promises (§7.5). One door, and it broadcasts. + # (A moderator was already refused above by `privileged`; this is the + # admin, who is told the right door rather than given a broken one.) + if body.status == "revoked": + raise HTTPException( + status_code=400, + detail="Revoke through POST /v1/admin/revoke — it signs the " + "revocation and broadcasts it to every node.") + # Leaving `revoked` undoes a signed, node-enforced action, so it is an + # admin's call, never a moderator's: the nodes still deny this account + # from the broadcast, and a moderator flipping the hub row back to + # active would only disagree with them. + if user.status == "revoked" and not user_is_admin(current_user): + raise HTTPException( + status_code=403, + detail="Only an admin can change a revoked account.") user.status = body.status log.info("User %s status changed to %s by %s", user.username, body.status, current_user.username) @@ -489,6 +508,29 @@ async def admin_patch_group( raise HTTPException( status_code=422, detail="status must be active, suspended, or revoked") + # Suspend/unsuspend is a moderator's reversible, hub-only lever (§7.5). + # Revoke is neither: it is signed and broadcast to every node, and it is + # an administrative act — the same line `admin_patch_user` draws. Two + # gaps used to sit here: a moderator could set `revoked`, and a + # `revoked` set through this PATCH was never broadcast, so it behaved + # like `suspended` on nodes while claiming to be the signed, enforced + # state. Revoke has one door, `POST /v1/admin/revoke`, and it broadcasts. + if body.status == "revoked": + if not user_is_admin(current_user): + raise HTTPException( + status_code=403, + detail="Revoking a group requires admin rights.") + raise HTTPException( + status_code=400, + detail="Revoke a group through POST /v1/admin/revoke — it signs " + "the revocation and broadcasts it to every node.") + # Leaving `revoked` undoes that broadcast and is an admin's call: the + # nodes still enforce the revocation, and a moderator flipping the hub + # row back would only disagree with them. + if group.status == "revoked" and not user_is_admin(current_user): + raise HTTPException( + status_code=403, + detail="Only an admin can change a revoked group.") group.status = body.status log.info("Group %s status changed to %s by %s", group.name, body.status, current_user.username) diff --git a/packages/meshbay-hub/src/meshbay_hub/api/deps.py b/packages/meshbay-hub/src/meshbay_hub/api/deps.py index 501be9d..42f4101 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/deps.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/deps.py @@ -59,16 +59,34 @@ async def get_current_user( return user -async def require_user_scope( - payload: dict = Depends(_decode_token), - current_user: User = Depends(get_current_user), -) -> User: - """Reject node-scoped tokens — only browser (user-scope) can mutate groups.""" +def _reject_node_scope(payload: dict) -> None: + """Refuse a node-scoped daemon token on a route meant for a person. + + A node's authority and a hub role are **different notions**. What a node may + do is decided by its operator's roster pin on the node itself (NS4) and by + the node scope's deliberately narrow reach; being an admin or a moderator is + a hub role attached to a person's account. A node daemon authenticates with + the node key and receives a `scope:"node"` token so that the machine can + register, signal and host — never so that it can act as its operator on the + hub. When the operator's account happens to also hold a hub role, that role + is the *person's*, exercised from a browser with a user-scoped token, and + must not be reachable by a token the daemon holds in memory. So the scope + gate lives in one place and fronts every privileged dependency, not only + group mutation. + """ if payload.get("scope") == "node": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Node-scoped token cannot perform this operation — use browser", ) + + +async def require_user_scope( + payload: dict = Depends(_decode_token), + current_user: User = Depends(get_current_user), +) -> User: + """Reject node-scoped tokens — only browser (user-scope) can mutate groups.""" + _reject_node_scope(payload) return current_user @@ -84,8 +102,13 @@ def user_is_moderator(user: User) -> bool: async def require_moderator( + payload: dict = Depends(_decode_token), current_user: User = Depends(get_current_user), ) -> User: + # A node-scoped daemon token is refused here even for a moderator's own + # account: the hub moderation surface (suspending accounts, reading the IP + # audit log, listing nodes) is the person's, not the machine's. + _reject_node_scope(payload) if not user_is_moderator(current_user): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Moderator access required") @@ -93,8 +116,13 @@ async def require_moderator( async def require_admin( + payload: dict = Depends(_decode_token), current_user: User = Depends(get_current_user), ) -> User: + # Likewise: revoking accounts and groups (signed, broadcast to every node) + # and changing instance policy are administrative acts a person performs + # from a browser, never something a node token may reach. + _reject_node_scope(payload) if not user_is_admin(current_user): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py index cab60c8..efebc4d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py @@ -78,8 +78,13 @@ async def hub_pubkey(): # handshake refusal anyway. The operator is updating every client, node and hub # by hand for this flag day, which is what makes that acceptable exactly once. # The gate is in place for the next one, where it will work as intended. -MIN_CLIENT_VERSION = "0.13.0" -RECOMMENDED_CLIENT_VERSION = "0.13.0" +# +# 0.16.0 is the MNP 4.0 flag day: a client older than this presents its hub +# session token to a node, which a 4.0 node refuses. A desktop client below +# 0.16.0 is told to update *before* it connects rather than meeting a handshake +# refusal it cannot read; the browser reloads this build from the hub. +MIN_CLIENT_VERSION = "0.16.0" +RECOMMENDED_CLIENT_VERSION = "0.16.0" @router.get("/version") diff --git a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py index f33dc6e..3c50e19 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py @@ -4,14 +4,13 @@ Invitation links — the hub's half (docs/MESHBAY_DESIGN.md §3.4, §7.3). A link carries two secrets with two jobs. The node's code decides who gets the group key, and it is shown to the hub only when the inviter asks the hub to mail the link. The ticket here decides who may *reach* the node — membership, -which is all the hub has to give (§7.1) — and it gives it to one account only: -the one whose verified address the inviter named. A ticket that leaks, through a -messaging service that previews links or a forwarded mail, is therefore useless -without that mailbox. +which is all the hub has to give (§7.1) — and it gives it to one account: the +first to redeem it. Both halves are therefore bearer secrets, so the link can +travel through any messaging service; what bounds a leaked one is that it works +once, for seven days, and that the owner can cancel it. -Stated per the design's convention: that binding holds against third parties and -not against this hub, which verifies the addresses it compares. An active hub -could already be anybody. +The address is optional and binds nothing. When given, it is where the hub +mails the link and a masked label in the owner's list. No route here answers without an account, and nothing tells the inviter whether an address has an account (M1): creating a link looks the same either way. @@ -30,7 +29,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub import mail from meshbay_hub.api.deps import _decode_token, get_current_user, require_user_scope from meshbay_hub.api.middleware import limiter -from meshbay_hub.auth import hash_email_blind from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import Group, GroupInviteLink, GroupMember, User @@ -40,9 +38,9 @@ redeem_router = APIRouter(prefix="/v1/invite-links", tags=["invite-links"]) # The node holds at most as many unredeemed link codes per group; one more # ticket here than codes there would be a link that cannot work. MAX_OUTSTANDING_PER_GROUP = 20 -# A node's invitation lifetime is the operator's setting (7 days by default); -# the ticket follows it, up to this. -MAX_LIFETIME = timedelta(days=30) +# The node issues a link's code for exactly this long; the ticket follows it, +# and never outlives it. +MAX_LIFETIME = timedelta(days=7) # How long a spent link is kept before it is forgotten. The owner is not shown # it — the person is in the group — but while the row is here, a reload or a # second tab of the invitation page still answers the account that used it. @@ -83,6 +81,8 @@ def _aware(when: datetime) -> datetime: def _valid_email(v: str) -> str: v = v.strip() + if not v: + return v local, sep, domain = v.partition("@") if (not sep or not local or not domain or "." not in domain.strip(".") or len(v) > 254 or any(c.isspace() or ord(c) < 32 for c in v)): @@ -91,7 +91,8 @@ def _valid_email(v: str) -> str: class CreateLinkRequest(BaseModel): - email: str + # Optional: only where the hub mails the link, and a label for the owner. + email: str = "" expires_at: str node_invite_id: str # Only when the hub is to mail the link, since only then must it write it: @@ -149,6 +150,8 @@ async def create_invite_link( if not _NODE_INVITE_ID.match(body.node_invite_id): raise HTTPException(status_code=422, detail="Not a node invitation id") if body.send_email: + if not body.email: + raise HTTPException(status_code=422, detail="Mailing a link needs an address") if payload.get("scope") == "node": raise HTTPException(status_code=403, detail="Invitation mail is sent from the interface only") @@ -179,7 +182,7 @@ async def create_invite_link( ticket = secrets.token_urlsafe(16) row = GroupInviteLink( group_id=group_id, created_by=current_user.id, ticket_hash=ticket_hash(ticket), - email_hash=hash_email_blind(body.email), email_masked=_mask(body.email), + email_masked=_mask(body.email) if body.email else None, node_invite_id=body.node_invite_id, expires_at=expires) db.add(row) await db.flush() @@ -225,7 +228,7 @@ async def list_invite_links( now = datetime.now(UTC) return {"links": [{ "link_id": r.id, - "email": r.email_masked, + "email": r.email_masked or "", "node_invite_id": r.node_invite_id, "created_at": _aware(r.created_at).isoformat(), "expires_at": _aware(r.expires_at).isoformat(), @@ -257,14 +260,10 @@ async def delete_invite_link( async def _resolve(db: AsyncSession, ticket: str, user: User ) -> tuple[GroupInviteLink, Group]: """ - The link this ticket names, if this account may use it. - - One uniform refusal for everything that says nothing about the account — - unknown, spent by someone else, expired, a group no longer active — and one - distinct answer, `invite_other_account`, for the case the person can act - on: signed in as somebody other than the address it was sent to. That - answer names no address, and it is given only to someone holding the - ticket, who already knows a link exists. + The link this ticket names, if this account may use it: any account while + nobody has, and afterwards only the one that did. One uniform refusal for + everything else — unknown, spent by someone else, expired, a group no + longer active. """ invalid = HTTPException(status_code=404, detail="invite_not_valid") if not _TICKET.match(ticket or ""): @@ -280,8 +279,6 @@ async def _resolve(db: AsyncSession, ticket: str, user: User raise invalid if not row.redeemed_by and _aware(row.expires_at) <= datetime.now(UTC): raise invalid - if not user.email_hash or user.email_hash != row.email_hash: - raise HTTPException(status_code=403, detail="invite_other_account") return row, group @@ -312,8 +309,8 @@ async def redeem_invite_link( db: AsyncSession = Depends(get_db), ): """ - Membership for the addressed account, once — and the same answer again for - that account, because a second tab or a reload is the same person. + Membership for the first account that asks, once — and the same answer + again for that account, because a second tab or a reload is the same person. """ row, group = await _resolve(db, body.ticket, current_user) if not row.redeemed_by: diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py index 7478173..6205180 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py @@ -11,15 +11,40 @@ from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from meshbay_hub.api.deps import get_current_user +from meshbay_hub.api.deps import get_current_user, require_user_scope from meshbay_hub.api.middleware import limiter from meshbay_hub.api.netutil import client_ip -from meshbay_hub.auth import issue_access_token +from meshbay_hub.auth import issue_access_token, issue_mnp_token from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import GroupMember, IPLog, Node, User router = APIRouter(prefix="/v1/nodes", tags=["nodes"]) + +@router.post("/mnp-token") +@limiter.limit("60/minute") +async def mnp_token( + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """Mint the short-lived token a member presents to a node in the MNP handshake. + + Asked for with the member's own session token (require_user_scope, so a node + daemon token cannot mint one). The result carries the member's current group + membership and `aud=MNP_AUD`, so it authorises the member to a node and is + refused by the hub API. Short-lived on purpose; the client refetches it for a + new connection or a reconnect, and it is checked only at the handshake, so a + film already playing is never interrupted by its expiry. + """ + rows = await db.execute( + select(GroupMember.group_id).where(GroupMember.user_id == current_user.id)) + group_ids = [gid for (gid,) in rows.all()] + return { + "mnp_token": issue_mnp_token(current_user.id, groups=group_ids), + "expires_in": 900, + } + NODE_AUTH_TIMESTAMP_WINDOW = 60 # seconds diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index a994acb..7046c2f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -728,6 +728,14 @@ async def get_current_user_info( class UpdateProfileRequest(BaseModel): email: str | None = None + # Required to change the address on file. Changing it is the first step of + # an account takeover from a bare access token: the confirmation code goes + # to the new (attacker) address, and a verified address then unlocks the + # passphrase-reset path. A live access token is not enough for that — the + # passphrase is, exactly as for `change_password` and `delete_own_account`. + # This matters because a member hands its access token to every node it + # connects to (the MNP handshake), so a node operator holds one. + auth_key: str | None = None @field_validator("email") @classmethod @@ -768,6 +776,22 @@ async def update_profile( new_email = body.email.strip() eh = hash_email_blind(new_email) + # Changing the address on file requires the passphrase, not merely a + # live token. Same second factor, and the same throttle, as a passphrase + # change or an account deletion — the hub still never sees the + # passphrase, only the derived auth_key. + if not body.auth_key: + raise HTTPException( + status_code=403, + detail="Changing your e-mail requires your passphrase.") + await _take_login_attempt(db, current_user.username) + if not await verify_password_off_loop( + body.auth_key, current_user.pw_hash, current_user.pw_salt, + current_user.pw_version): + raise HTTPException(status_code=403, + detail="Passphrase does not match") + await login_throttle.clear(db, current_user.username) + # How often one account may point the hub at a *different* address. # Long, because this is the only path where a signed-in account chooses # who receives a message, and a short delay alone still allows one diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py index d038027..e182ba0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/auth.py +++ b/packages/meshbay-hub/src/meshbay_hub/auth.py @@ -25,6 +25,8 @@ from cryptography.hazmat.primitives.hashes import SHA256 from cryptography.hazmat.primitives.kdf.argon2 import Argon2id from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from meshbay_common.tokens import HUB_API_AUD, MNP_AUD + # Argon2id parameters — versioned for gradual migration _ARGON2_LANES = 4 _ARGON2_KEY_LEN = 32 @@ -230,17 +232,70 @@ def issue_access_token( "exp": now + ttl, "groups": groups or [], "scope": scope, + # This is a hub-API credential. The node handshake binds MNP_AUD and + # refuses it, so a session token disclosed to a node opens nothing at + # the hub (see meshbay_common.tokens). + "aud": HUB_API_AUD, + } + return jwt.encode(payload, _hub_sk_pem, algorithm="EdDSA") + + +def issue_mnp_token(user_id: str, groups: list[str] | None = None, + ttl: int = 900) -> str: + """Issue the short-lived token a member presents to a node in the handshake. + + `aud=MNP_AUD`, so it is accepted by `authorize_token` and refused by the hub + API. It is checked once, at the handshake, before any proof — so a short + lifetime does not interrupt a long transfer or a film already playing; only a + fresh connection or a reconnect needs a fresh one. It carries the same + `sub`/`groups`/`jti` the node authorises and denylists on. + """ + if _hub_sk_pem is None: + raise RuntimeError("Hub keypair not loaded") + now = int(time.time()) + payload = { + "iss": _hub_id, + "sub": user_id, + "jti": str(uuid.uuid4()), + "iat": now, + "exp": now + ttl, + "groups": groups or [], + "scope": "user", + "aud": MNP_AUD, } return jwt.encode(payload, _hub_sk_pem, algorithm="EdDSA") def decode_access_token(token: str) -> dict: - """Verify and decode an access token. Raises on failure.""" + """Verify and decode an access token. Raises on failure. + + This is the **hub-API** decode. It binds `audience=HUB_API_AUD` and requires + `exp`, `sub` and `scope`. One Ed25519 key signs several kinds of token — + session tokens (aud=HUB_API_AUD), the MNP token a member presents to a node + (aud=MNP_AUD), revocation broadcasts (no `exp`/`sub`, handed to admins and + pushed to every node), and MHP federation tokens (aud=peer hub). Binding the + audience here means only a session token opens the hub API: an **MNP token + disclosed to a node cannot be replayed against the hub**, which is the whole + point of splitting the two (see meshbay_common.tokens). Requiring `exp` + refuses any hub-signed token with no expiry, and `scope` must still name one + of the two access scopes. + + The node handshake uses its own decode (`meshbay_common.handshake`), which + binds `MNP_AUD` instead; the node's own self-decode of its node token passes + `audience=HUB_API_AUD` (hub_client.py), so both sides move together. + """ if _hub_pk_pem is None: raise RuntimeError("Hub keypair not loaded") # Clock-skew tolerance (meshbay_common.handshake.JWT_LEEWAY_SECONDS): a # client whose clock is a little fast must still be able to call the API. - return jwt.decode(token, _hub_pk_pem, algorithms=["EdDSA"], leeway=60) + payload = jwt.decode( + token, _hub_pk_pem, algorithms=["EdDSA"], leeway=60, + audience=HUB_API_AUD, + options={"require": ["exp", "sub", "scope", "aud"]}, + ) + if payload.get("scope") not in ("user", "node"): + raise jwt.InvalidTokenError("unrecognised token scope") + return payload # ── Email encryption at rest ────────────────────────────────────────────────── diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c4d5e6f7a8b9_invite_links_unbound.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c4d5e6f7a8b9_invite_links_unbound.py new file mode 100644 index 0000000..ae9d2c9 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c4d5e6f7a8b9_invite_links_unbound.py @@ -0,0 +1,34 @@ +"""invitation links bind no address + +A link is now redeemable by whichever account opens it first, so it can be sent +through any messaging service. The address, when the inviter gives one, is only +where the hub mails the link and a masked label in the owner's list. + +Revision ID: c4d5e6f7a8b9 +Revises: b2c3d4e5f6a7 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "c4d5e6f7a8b9" +down_revision: str | Sequence[str] | None = "b2c3d4e5f6a7" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table("group_invite_links") as batch: + batch.drop_column("email_hash") + batch.alter_column("email_masked", existing_type=sa.String(128), nullable=True) + + +def downgrade() -> None: + # The bound address cannot be recovered; outstanding links are dropped. + op.execute("DELETE FROM group_invite_links") + with op.batch_alter_table("group_invite_links") as batch: + batch.alter_column("email_masked", existing_type=sa.String(128), nullable=False) + batch.add_column(sa.Column("email_hash", sa.String(64), nullable=False, + server_default="")) diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index 51a3d47..09eb437 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -151,12 +151,12 @@ class GroupInviteLink(Base): A link carries two secrets. The node's code decides whether someone gets the group key, and the hub never sees it. This row decides whether someone may - *reach* the node at all — membership, which is all the hub has to give — and - only for the account whose verified address matches `email_hash`. The ticket - is stored as `sha256(ticket)`, so a copy of this table opens nothing. + *reach* the node at all — membership, which is all the hub has to give — for + the first account that redeems it. The ticket is stored as `sha256(ticket)`, + so a copy of this table opens nothing. - No address in the clear: `email_hash` is the same blind index `users` has, - and `email_masked` is what the owner's list shows (`al***@ex***.com`). + The address is optional and binds nothing: when the inviter gave one, + `email_masked` is what the owner's list shows (`al***@ex***.com`). """ __tablename__ = "group_invite_links" @@ -164,8 +164,7 @@ class GroupInviteLink(Base): group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"), nullable=False) created_by: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) ticket_hash: Mapped[str] = mapped_column(String(64), nullable=False) - email_hash: Mapped[str] = mapped_column(String(64), nullable=False) - email_masked: Mapped[str] = mapped_column(String(128), nullable=False) + email_masked: Mapped[str | None] = mapped_column(String(128)) # The node's handle for its half, so cancelling can take back both. node_invite_id: Mapped[str] = mapped_column(String(32), nullable=False, default="") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py index d36a829..b382849 100644 --- a/packages/meshbay-hub/src/meshbay_hub/mail.py +++ b/packages/meshbay-hub/src/meshbay_hub/mail.py @@ -469,7 +469,7 @@ def send_invite_link(to: str, link: str, inviter: str, group_name: str) -> None: "\n" f"{link}\n" "\n" - "It works once, and only for an account registered with this address.\n" + "It works once, and for seven days.\n" "If you did not expect it, you can ignore this message.\n" ) _send(msg, purpose="invite_link") diff --git a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js index f81247d..85fe53c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js @@ -142,11 +142,15 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru const linkNodeKey = useCallback(async (pk) => { if (!pk) return; - try { - await hubFetch('/v1/users/me/node_key', { - method: 'PUT', token, body: { pk_node_ed25519: pk }, - }); - } catch { /* already linked or same key */ } + // `PUT /me/node_key` is idempotent — linking the same key again returns 200, + // so there is no "already linked" case to swallow here. A failure means the + // hub did not record this node's key (a rejected session, a malformed key), + // and the node then fails to authenticate and never comes up. It must + // surface — `detectNode`'s catch shows it — rather than let the wizard + // proceed against a node that looks linked but is not. + await hubFetch('/v1/users/me/node_key', { + method: 'PUT', token, body: { pk_node_ed25519: pk }, + }); }, [token]); const detectNode = useCallback(async () => { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index 529a33d..a510e63 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -897,10 +897,11 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, // ── Invitation links (docs/MESHBAY_DESIGN.md §3.4) ────────────────── // // Two halves, in the order that leaves nothing half-made: the node's code - // first, then the hub's ticket bound to the address; a ticket the hub then - // refuses takes the code back with it, since a code nobody can reach the node - // with only occupies one of the group's twenty places. The code reaches the - // hub only when the box asks the hub to write the mail. + // first, then the hub's ticket; a ticket the hub then refuses takes the code + // back with it, since a code nobody can reach the node with only occupies one + // of the group's twenty places. The address is optional and binds nothing: + // the link is for whoever opens it first, so it can go by any messaging app. + // The code reaches the hub only when the box asks the hub to write the mail. const [linkEmail, setLinkEmail] = useState(''); const [linking, setLinking] = useState(false); const [linkError, setLinkError] = useState(''); @@ -932,7 +933,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, const doCreateLink = useCallback(async (e) => { e.preventDefault(); const email = linkEmail.trim(); - if (!email) return; + const mailIt = inviteByEmail && Boolean(email); setLinking(true); setLinkError(''); setNewLink(null); @@ -951,8 +952,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, method: 'POST', token, body: { email, expires_at: node.expires_at, node_invite_id: node.invite_id, - send_email: inviteByEmail, - ...(inviteByEmail ? { node_pk: n, code: node.code } : {}), + send_email: mailIt, + ...(mailIt ? { node_pk: n, code: node.code } : {}), }, }); } catch (err) { @@ -960,7 +961,6 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, throw err; } setNewLink({ - email, link: inviteLinkHere({ g: groupId, t: ticket.ticket, n, c: node.code }), emailStatus: ticket.email_status, }); @@ -1004,6 +1004,16 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, } catch { /* the field is selectable */ } }, [newLink]); + // The system share sheet, where there is one (phones mostly): the way a link + // reaches a messaging app without a round trip through the clipboard. + const canShare = typeof navigator !== 'undefined' && typeof navigator.share === 'function'; + const shareLink = useCallback(async () => { + if (!newLink) return; + try { + await navigator.share({ title: t('members.link_share_title'), url: newLink.link }); + } catch { /* dismissed; the field and Copy are still there */ } + }, [newLink]); + if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`; const isOwner = Boolean(isAdmin); @@ -1067,13 +1077,17 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, <form onSubmit=${doCreateLink}> ${newLink && html` <div class="success-msg" style="margin-bottom:8px"> - <p>${t('members.link_ready', { email: newLink.email })}</p> + <p>${t('members.link_ready')}</p> <div class="form-row"> <input type="text" readonly value=${newLink.link} onFocus=${e => e.target.select()} /> <button class="admin-btn" type="button" onClick=${copyLink}> ${linkCopied ? t('members.link_copied') : t('members.link_copy')} </button> + ${canShare && html` + <button class="admin-btn" type="button" onClick=${shareLink}> + ${t('members.link_share')} + </button>`} </div> ${newLink.emailStatus === 'sent' ? html`<p style="color:var(--success)">${t('members.link_email_sent')}</p>` @@ -1085,7 +1099,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, <div class="form-row"> <input type="email" placeholder=${t('members.link_email_placeholder')} value=${linkEmail} onInput=${e => setLinkEmail(e.target.value)} - disabled=${!connected || !operatorPaired} required /> + disabled=${!connected || !operatorPaired} /> <button class="admin-btn" type="submit" disabled=${linking || !connected || !operatorPaired}> ${linking ? '...' : t('members.link_btn')} @@ -1104,7 +1118,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, ${links.map(l => html` <li key=${l.link_id} style="display:flex;gap:8px;align-items:center; flex-wrap:wrap;word-break:break-word;margin:4px 0"> - <span>${l.email}</span> + <span>${l.email || t('members.link_unlabelled', + { date: new Date(l.created_at).toLocaleDateString() })}</span> <span style="color:var(--text-dim)"> ${l.status === 'expired' ? t('members.link_status_expired') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js b/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js index dd678aa..41b4c4c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js @@ -25,10 +25,7 @@ export function InvitePage({ user, onJoined }) { const [error, setError] = useState(''); const refused = useCallback((err) => { - if (err.message === 'invite_other_account') { - // Kept: signing out and in again as the right account is the fix. - setPhase('other'); - } else if (err.message === 'invite_not_valid') { + if (err.message === 'invite_not_valid') { clearPending(); setPhase('invalid'); } else { @@ -97,12 +94,6 @@ export function InvitePage({ user, onJoined }) { <button class="btn btn-primary" onClick=${join}>${t('invite.join')}</button> <button class="btn btn-secondary" onClick=${ignore}>${t('invite.ignore')}</button> </div>`; - } else if (phase === 'other') { - body = html` - <p class="error-msg">${t('invite.other_account')}</p> - <div style="margin-top:16px"> - <button class="btn btn-secondary" onClick=${ignore}>${t('invite.ignore')}</button> - </div>`; } else if (phase === 'invalid') { body = html`<p class="error-msg">${t('invite.invalid')}</p>`; } else { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 215f596..7bd5169 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -698,10 +698,10 @@ export default { 'members.invite_email_failed': 'Die E-Mail konnte nicht gesendet werden — bitte teilen Sie den Code manuell mit.', 'members.invite_email_opt': 'Einladung per E-Mail senden (kann im Spam landen)', 'members.link_title': "Per Link einladen", - 'members.link_hint': "Für jemanden, der vielleicht noch kein Konto hat. Der Link funktioniert einmal und nur für ein Konto mit dieser Adresse.", - 'members.link_email_placeholder': "E-Mail-Adresse", + 'members.link_hint': "Für jemanden, der vielleicht noch kein Konto hat. Verschicken Sie ihn, wie Sie möchten — Messenger, SMS. Der Link funktioniert einmal, sieben Tage lang, für die Person, die ihn zuerst öffnet.", + 'members.link_email_placeholder': "E-Mail-Adresse (optional)", 'members.link_btn': "Link erstellen", - 'members.link_ready': "Einladungslink für {email}:", + 'members.link_ready': "Einladungslink — 7 Tage gültig, einmalig verwendbar:", 'members.link_copy': "Kopieren", 'members.link_copied': "Kopiert", 'members.link_email_sent': "Der Link wurde per E-Mail gesendet.", @@ -710,9 +710,12 @@ export default { 'members.link_status_expired': "abgelaufen", 'members.link_expires': "läuft ab am {date}", 'members.link_cancel': "Abbrechen", + 'members.link_share': "Teilen", + 'members.link_share_title': "Einladung", + 'members.link_unlabelled': "Link vom {date}", 'invite.title': "Sie wurden eingeladen", 'invite.none': "In diesem Tab wartet keine Einladung. Öffnen Sie den erhaltenen Link erneut.", - 'invite.signed_out': "Jemand hat Sie in eine Gruppe auf diesem Hub eingeladen. Erstellen Sie ein Konto mit der E-Mail-Adresse, an die die Einladung ging, oder melden Sie sich an, falls Sie bereits eines haben.", + 'invite.signed_out': "Jemand hat Sie in eine Gruppe auf diesem Hub eingeladen. Erstellen Sie ein Konto, oder melden Sie sich an, falls Sie bereits eines haben.", 'invite.register': "Konto erstellen", 'invite.signin': "Anmelden", 'invite.confirm': "{inviter} lädt Sie in {group} ein.", @@ -720,7 +723,6 @@ export default { 'invite.ignore': "Ignorieren", 'invite.open': "Gruppe öffnen", 'invite.already_member': "Sie sind bereits Mitglied von {group}.", - 'invite.other_account': "Diese Einladung wurde an eine andere E-Mail-Adresse gesendet. Melden Sie sich mit dem Konto dieser Adresse an — Aliasse und Punkte müssen genau übereinstimmen.", 'invite.invalid': "Diese Einladung ist nicht mehr gültig: Sie wurde verwendet, widerrufen oder ist abgelaufen. Bitten Sie um eine neue.", 'invite.joining': "Beitritt…", 'invite.after_register': "Melden Sie sich an, um der Gruppe beizutreten, in die Sie eingeladen wurden.", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 4c4cf38..0c9cb19 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -814,10 +814,10 @@ export default { 'members.invite_email_failed': 'Could not send the email — share the code manually.', 'members.invite_email_opt': 'Send the invitation by e-mail (may land in spam)', 'members.link_title': "Invite by link", - 'members.link_hint': "For someone who may not have an account yet. The link works once, and only for an account registered with this address.", - 'members.link_email_placeholder': "E-mail address", + 'members.link_hint': "For someone who may not have an account yet. Send it however you like — a messaging app, a text. It works once, for seven days, for whoever opens it first.", + 'members.link_email_placeholder': "E-mail address (optional)", 'members.link_btn': "Create link", - 'members.link_ready': "Invitation link for {email}:", + 'members.link_ready': "Invitation link — valid 7 days, single use:", 'members.link_copy': "Copy", 'members.link_copied': "Copied", 'members.link_email_sent': "The link has been sent by e-mail.", @@ -826,9 +826,12 @@ export default { 'members.link_status_expired': "expired", 'members.link_expires': "expires {date}", 'members.link_cancel': "Cancel", + 'members.link_share': "Share", + 'members.link_share_title': "Invitation", + 'members.link_unlabelled': "link created {date}", 'invite.title': "You have been invited", 'invite.none': "There is no invitation waiting in this tab. Open the link you received again.", - 'invite.signed_out': "Someone invited you to a group on this hub. Create an account with the e-mail address the invitation was sent to, or sign in if you already have one.", + 'invite.signed_out': "Someone invited you to a group on this hub. Create an account, or sign in if you already have one.", 'invite.register': "Create an account", 'invite.signin': "Sign in", 'invite.confirm': "{inviter} invites you to join {group}.", @@ -836,7 +839,6 @@ export default { 'invite.ignore': "Ignore", 'invite.open': "Open the group", 'invite.already_member': "You are already a member of {group}.", - 'invite.other_account': "This invitation was sent to another e-mail address. Sign in with the account registered with that address — aliases and dots must match exactly.", 'invite.invalid': "This invitation is no longer valid: it has been used, cancelled or has expired. Ask for a new one.", 'invite.joining': "Joining…", 'invite.after_register': "Sign in to join the group you were invited to.", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 9f5158f..1399a83 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -693,10 +693,10 @@ export default { 'members.invite_email_failed': 'No se pudo enviar el correo — comparta el código manualmente.', 'members.invite_email_opt': 'Enviar la invitación por correo (puede llegar a spam)', 'members.link_title': "Invitar con un enlace", - 'members.link_hint': "Para alguien que quizá aún no tenga cuenta. El enlace sirve una vez, y solo para una cuenta registrada con esta dirección.", - 'members.link_email_placeholder': "Dirección de correo", + 'members.link_hint': "Para alguien que quizá aún no tenga cuenta. Envíelo como prefiera — mensajería, SMS. El enlace sirve una sola vez, durante siete días, para quien lo abra primero.", + 'members.link_email_placeholder': "Dirección de correo (opcional)", 'members.link_btn': "Crear enlace", - 'members.link_ready': "Enlace de invitación para {email}:", + 'members.link_ready': "Enlace de invitación — válido 7 días, un solo uso:", 'members.link_copy': "Copiar", 'members.link_copied': "Copiado", 'members.link_email_sent': "El enlace se ha enviado por correo.", @@ -705,9 +705,12 @@ export default { 'members.link_status_expired': "caducado", 'members.link_expires': "caduca el {date}", 'members.link_cancel': "Cancelar", + 'members.link_share': "Compartir", + 'members.link_share_title': "Invitación", + 'members.link_unlabelled': "enlace creado el {date}", 'invite.title': "Le han invitado", 'invite.none': "No hay ninguna invitación esperando en esta pestaña. Vuelva a abrir el enlace que recibió.", - 'invite.signed_out': "Alguien le ha invitado a un grupo en este hub. Cree una cuenta con la dirección de correo a la que se envió la invitación, o inicie sesión si ya tiene una.", + 'invite.signed_out': "Alguien le ha invitado a un grupo en este hub. Cree una cuenta, o inicie sesión si ya tiene una.", 'invite.register': "Crear una cuenta", 'invite.signin': "Iniciar sesión", 'invite.confirm': "{inviter} le invita a unirse a {group}.", @@ -715,7 +718,6 @@ export default { 'invite.ignore': "Ignorar", 'invite.open': "Abrir el grupo", 'invite.already_member': "Ya es miembro de {group}.", - 'invite.other_account': "Esta invitación se envió a otra dirección de correo. Inicie sesión con la cuenta registrada con esa dirección — los alias y los puntos deben coincidir exactamente.", 'invite.invalid': "Esta invitación ya no es válida: se ha usado, cancelado o ha caducado. Pida una nueva.", 'invite.joining': "Uniéndose…", 'invite.after_register': "Inicie sesión para unirse al grupo al que le invitaron.", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index c072d5b..ae278d9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -696,10 +696,10 @@ export default { 'members.invite_email_failed': "Impossible d’envoyer l’e-mail — partagez le code manuellement.", 'members.invite_email_opt': 'Envoyer l’invitation par e-mail (risque d’arriver dans les spams)', 'members.link_title': "Inviter par lien", - 'members.link_hint': "Pour quelqu’un qui n’a peut-être pas encore de compte. Le lien ne sert qu’une fois, et seulement pour un compte créé avec cette adresse.", - 'members.link_email_placeholder': "Adresse e-mail", + 'members.link_hint': "Pour quelqu’un qui n’a peut-être pas encore de compte. Envoyez-le comme vous voulez — messagerie, SMS. Le lien ne sert qu’une fois, pendant sept jours, à la première personne qui l’ouvre.", + 'members.link_email_placeholder': "Adresse e-mail (facultative)", 'members.link_btn': "Créer le lien", - 'members.link_ready': "Lien d’invitation pour {email} :", + 'members.link_ready': "Lien d’invitation — valable 7 jours, usage unique :", 'members.link_copy': "Copier", 'members.link_copied': "Copié", 'members.link_email_sent': "Le lien a été envoyé par e-mail.", @@ -708,9 +708,12 @@ export default { 'members.link_status_expired': "expiré", 'members.link_expires': "expire le {date}", 'members.link_cancel': "Annuler", + 'members.link_share': "Partager", + 'members.link_share_title': "Invitation", + 'members.link_unlabelled': "lien créé le {date}", 'invite.title': "Vous êtes invité", 'invite.none': "Aucune invitation n’attend dans cet onglet. Rouvrez le lien que vous avez reçu.", - 'invite.signed_out': "Quelqu’un vous a invité dans un groupe sur ce hub. Créez un compte avec l’adresse e-mail à laquelle l’invitation a été envoyée, ou connectez-vous si vous en avez déjà un.", + 'invite.signed_out': "Quelqu’un vous a invité dans un groupe sur ce hub. Créez un compte, ou connectez-vous si vous en avez déjà un.", 'invite.register': "Créer un compte", 'invite.signin': "Se connecter", 'invite.confirm': "{inviter} vous invite à rejoindre {group}.", @@ -718,7 +721,6 @@ export default { 'invite.ignore': "Ignorer", 'invite.open': "Ouvrir le groupe", 'invite.already_member': "Vous êtes déjà membre de {group}.", - 'invite.other_account': "Cette invitation a été envoyée à une autre adresse e-mail. Connectez-vous avec le compte créé avec cette adresse — les alias et les points doivent correspondre exactement.", 'invite.invalid': "Cette invitation n’est plus valable : elle a été utilisée, annulée ou a expiré. Demandez-en une nouvelle.", 'invite.joining': "Adhésion…", 'invite.after_register': "Connectez-vous pour rejoindre le groupe auquel vous avez été invité.", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 28c6a3d..f0fb0d1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -696,10 +696,10 @@ export default { 'members.invite_email_failed': "Impossibile inviare l'e-mail — condivida il codice manualmente.", 'members.invite_email_opt': 'Invia l’invito per e-mail (potrebbe finire nello spam)', 'members.link_title': "Invita tramite link", - 'members.link_hint': "Per chi forse non ha ancora un account. Il link funziona una volta, e solo per un account registrato con questo indirizzo.", - 'members.link_email_placeholder': "Indirizzo e-mail", + 'members.link_hint': "Per chi forse non ha ancora un account. Lo invii come preferisce — messaggistica, SMS. Il link funziona una sola volta, per sette giorni, per chi lo apre per primo.", + 'members.link_email_placeholder': "Indirizzo e-mail (facoltativo)", 'members.link_btn': "Crea link", - 'members.link_ready': "Link d’invito per {email}:", + 'members.link_ready': "Link di invito — valido 7 giorni, uso singolo:", 'members.link_copy': "Copia", 'members.link_copied': "Copiato", 'members.link_email_sent': "Il link è stato inviato per e-mail.", @@ -708,9 +708,12 @@ export default { 'members.link_status_expired': "scaduto", 'members.link_expires': "scade il {date}", 'members.link_cancel': "Annulla", + 'members.link_share': "Condividi", + 'members.link_share_title': "Invito", + 'members.link_unlabelled': "link creato il {date}", 'invite.title': "È stato invitato", 'invite.none': "Nessun invito in attesa in questa scheda. Riapra il link ricevuto.", - 'invite.signed_out': "Qualcuno l’ha invitata in un gruppo su questo hub. Crei un account con l’indirizzo e-mail a cui è stato inviato l’invito, o acceda se ne ha già uno.", + 'invite.signed_out': "Qualcuno l’ha invitata in un gruppo su questo hub. Crei un account, o acceda se ne ha già uno.", 'invite.register': "Crea un account", 'invite.signin': "Accedi", 'invite.confirm': "{inviter} la invita a unirsi a {group}.", @@ -718,7 +721,6 @@ export default { 'invite.ignore': "Ignora", 'invite.open': "Apri il gruppo", 'invite.already_member': "È già membro di {group}.", - 'invite.other_account': "Questo invito è stato inviato a un altro indirizzo e-mail. Acceda con l’account registrato con quell’indirizzo — alias e punti devono corrispondere esattamente.", 'invite.invalid': "Questo invito non è più valido: è stato usato, annullato o è scaduto. Ne chieda uno nuovo.", 'invite.joining': "Adesione…", 'invite.after_register': "Acceda per unirsi al gruppo a cui è stato invitato.", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index f357881..560332d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -688,10 +688,10 @@ export default { 'members.invite_email_failed': 'メールを送信できませんでした。コードを手動で共有してください。', 'members.invite_email_opt': '招待をメールで送信(迷惑メールに入る場合があります)', 'members.link_title': "リンクで招待", - 'members.link_hint': "まだアカウントを持っていない人向けです。リンクは1回だけ、このアドレスで登録したアカウントでのみ使えます。", - 'members.link_email_placeholder': "メールアドレス", + 'members.link_hint': "まだアカウントを持っていないかもしれない人向けです。メッセージアプリやSMSなど、お好きな方法で送ってください。リンクは7日間有効で、最初に開いた人が一度だけ使えます。", + 'members.link_email_placeholder': "メールアドレス(任意)", 'members.link_btn': "リンクを作成", - 'members.link_ready': "{email} への招待リンク:", + 'members.link_ready': "招待リンク — 7日間有効、1回限り:", 'members.link_copy': "コピー", 'members.link_copied': "コピーしました", 'members.link_email_sent': "リンクをメールで送信しました。", @@ -700,9 +700,12 @@ export default { 'members.link_status_expired': "期限切れ", 'members.link_expires': "{date} に期限切れ", 'members.link_cancel': "取り消す", + 'members.link_share': "共有", + 'members.link_share_title': "招待", + 'members.link_unlabelled': "{date} に作成したリンク", 'invite.title': "招待されています", 'invite.none': "このタブで待機中の招待はありません。受け取ったリンクをもう一度開いてください。", - 'invite.signed_out': "このハブのグループに招待されています。招待が送られたメールアドレスでアカウントを作成するか、すでにお持ちならサインインしてください。", + 'invite.signed_out': "このハブのグループに招待されています。アカウントを作成するか、すでにお持ちならサインインしてください。", 'invite.register': "アカウントを作成", 'invite.signin': "サインイン", 'invite.confirm': "{inviter} さんが {group} への参加に招待しています。", @@ -710,7 +713,6 @@ export default { 'invite.ignore': "無視", 'invite.open': "グループを開く", 'invite.already_member': "すでに {group} のメンバーです。", - 'invite.other_account': "この招待は別のメールアドレスに送られました。そのアドレスで登録したアカウントでサインインしてください。エイリアスやドットも完全に一致する必要があります。", 'invite.invalid': "この招待は無効です。使用済み、取り消し済み、または期限切れです。新しい招待を依頼してください。", 'invite.joining': "参加しています…", 'invite.after_register': "招待されたグループに参加するにはサインインしてください。", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 64e1f00..adb94c6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -697,10 +697,10 @@ export default { 'members.invite_email_failed': 'Kon de e-mail niet verzenden — deel de code handmatig.', 'members.invite_email_opt': 'Uitnodiging per e-mail versturen (kan in spam belanden)', 'members.link_title': "Uitnodigen via link", - 'members.link_hint': "Voor iemand die misschien nog geen account heeft. De link werkt één keer, en alleen voor een account met dit adres.", - 'members.link_email_placeholder': "E-mailadres", + 'members.link_hint': "Voor iemand die misschien nog geen account heeft. Verstuur hem zoals u wilt — berichtenapp, sms. De link werkt één keer, zeven dagen lang, voor wie hem als eerste opent.", + 'members.link_email_placeholder': "E-mailadres (optioneel)", 'members.link_btn': "Link maken", - 'members.link_ready': "Uitnodigingslink voor {email}:", + 'members.link_ready': "Uitnodigingslink — 7 dagen geldig, eenmalig:", 'members.link_copy': "Kopiëren", 'members.link_copied': "Gekopieerd", 'members.link_email_sent': "De link is per e-mail verstuurd.", @@ -709,9 +709,12 @@ export default { 'members.link_status_expired': "verlopen", 'members.link_expires': "verloopt op {date}", 'members.link_cancel': "Annuleren", + 'members.link_share': "Delen", + 'members.link_share_title': "Uitnodiging", + 'members.link_unlabelled': "link gemaakt op {date}", 'invite.title': "U bent uitgenodigd", 'invite.none': "Er wacht geen uitnodiging in dit tabblad. Open de ontvangen link opnieuw.", - 'invite.signed_out': "Iemand heeft u uitgenodigd voor een groep op deze hub. Maak een account aan met het e-mailadres waarnaar de uitnodiging is gestuurd, of meld u aan als u er al een hebt.", + 'invite.signed_out': "Iemand heeft u uitgenodigd voor een groep op deze hub. Maak een account aan, of meld u aan als u er al een hebt.", 'invite.register': "Account maken", 'invite.signin': "Aanmelden", 'invite.confirm': "{inviter} nodigt u uit voor {group}.", @@ -719,7 +722,6 @@ export default { 'invite.ignore': "Negeren", 'invite.open': "Groep openen", 'invite.already_member': "U bent al lid van {group}.", - 'invite.other_account': "Deze uitnodiging is naar een ander e-mailadres gestuurd. Meld u aan met het account van dat adres — aliassen en punten moeten exact overeenkomen.", 'invite.invalid': "Deze uitnodiging is niet meer geldig: ze is gebruikt, geannuleerd of verlopen. Vraag een nieuwe.", 'invite.joining': "Deelnemen…", 'invite.after_register': "Meld u aan om deel te nemen aan de groep waarvoor u bent uitgenodigd.", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 1d5f05f..fb5a4ed 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -709,10 +709,10 @@ export default { 'members.invite_email_failed': 'Nie udało się wysłać e-maila — przekaż kod ręcznie.', 'members.invite_email_opt': 'Wyślij zaproszenie e-mailem (może trafić do spamu)', 'members.link_title': "Zaproś linkiem", - 'members.link_hint': "Dla kogoś, kto może jeszcze nie mieć konta. Link działa raz i tylko dla konta założonego na ten adres.", - 'members.link_email_placeholder': "Adres e-mail", + 'members.link_hint': "Dla kogoś, kto może jeszcze nie mieć konta. Wyślij go, jak chcesz — komunikatorem, SMS-em. Link działa raz, przez siedem dni, dla osoby, która otworzy go pierwsza.", + 'members.link_email_placeholder': "Adres e-mail (opcjonalnie)", 'members.link_btn': "Utwórz link", - 'members.link_ready': "Link zaproszenia dla {email}:", + 'members.link_ready': "Link z zaproszeniem — ważny 7 dni, jednorazowy:", 'members.link_copy': "Kopiuj", 'members.link_copied': "Skopiowano", 'members.link_email_sent': "Link został wysłany e-mailem.", @@ -721,9 +721,12 @@ export default { 'members.link_status_expired': "wygasł", 'members.link_expires': "wygasa {date}", 'members.link_cancel': "Anuluj", + 'members.link_share': "Udostępnij", + 'members.link_share_title': "Zaproszenie", + 'members.link_unlabelled': "link utworzony {date}", 'invite.title': "Otrzymano zaproszenie", 'invite.none': "W tej karcie nie czeka żadne zaproszenie. Otwórz ponownie otrzymany link.", - 'invite.signed_out': "Ktoś zaprosił cię do grupy na tym hubie. Załóż konto na adres e-mail, na który wysłano zaproszenie, albo zaloguj się, jeśli już je masz.", + 'invite.signed_out': "Ktoś zaprosił cię do grupy na tym hubie. Załóż konto albo zaloguj się, jeśli już je masz.", 'invite.register': "Załóż konto", 'invite.signin': "Zaloguj się", 'invite.confirm': "{inviter} zaprasza cię do {group}.", @@ -731,7 +734,6 @@ export default { 'invite.ignore': "Ignoruj", 'invite.open': "Otwórz grupę", 'invite.already_member': "Jesteś już członkiem {group}.", - 'invite.other_account': "To zaproszenie wysłano na inny adres e-mail. Zaloguj się na konto założone na ten adres — aliasy i kropki muszą się dokładnie zgadzać.", 'invite.invalid': "To zaproszenie jest już nieważne: zostało użyte, anulowane lub wygasło. Poproś o nowe.", 'invite.joining': "Dołączanie…", 'invite.after_register': "Zaloguj się, aby dołączyć do grupy, do której cię zaproszono.", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index c2cb35e..7bd11f6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -695,10 +695,10 @@ export default { 'members.invite_email_failed': 'Não foi possível enviar o e-mail — compartilhe o código manualmente.', 'members.invite_email_opt': 'Enviar o convite por e-mail (pode cair no spam)', 'members.link_title': "Convidar por link", - 'members.link_hint': "Para alguém que talvez ainda não tenha conta. O link funciona uma vez, e só para uma conta registrada com este endereço.", - 'members.link_email_placeholder': "Endereço de e-mail", + 'members.link_hint': "Para alguém que talvez ainda não tenha conta. Envie como preferir — aplicativo de mensagens, SMS. O link funciona uma vez, por sete dias, para quem abrir primeiro.", + 'members.link_email_placeholder': "Endereço de e-mail (opcional)", 'members.link_btn': "Criar link", - 'members.link_ready': "Link de convite para {email}:", + 'members.link_ready': "Link de convite — válido por 7 dias, uso único:", 'members.link_copy': "Copiar", 'members.link_copied': "Copiado", 'members.link_email_sent': "O link foi enviado por e-mail.", @@ -707,9 +707,12 @@ export default { 'members.link_status_expired': "expirado", 'members.link_expires': "expira em {date}", 'members.link_cancel': "Cancelar", + 'members.link_share': "Compartilhar", + 'members.link_share_title': "Convite", + 'members.link_unlabelled': "link criado em {date}", 'invite.title': "Você foi convidado", 'invite.none': "Não há convite aguardando nesta aba. Abra novamente o link que recebeu.", - 'invite.signed_out': "Alguém convidou você para um grupo neste hub. Crie uma conta com o endereço de e-mail para o qual o convite foi enviado, ou entre se já tiver uma.", + 'invite.signed_out': "Alguém convidou você para um grupo neste hub. Crie uma conta, ou entre se já tiver uma.", 'invite.register': "Criar uma conta", 'invite.signin': "Entrar", 'invite.confirm': "{inviter} convida você para participar de {group}.", @@ -717,7 +720,6 @@ export default { 'invite.ignore': "Ignorar", 'invite.open': "Abrir o grupo", 'invite.already_member': "Você já é membro de {group}.", - 'invite.other_account': "Este convite foi enviado para outro endereço de e-mail. Entre com a conta registrada com esse endereço — aliases e pontos devem coincidir exatamente.", 'invite.invalid': "Este convite não é mais válido: foi usado, cancelado ou expirou. Peça um novo.", 'invite.joining': "Entrando…", 'invite.after_register': "Entre para participar do grupo para o qual foi convidado.", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 37c57aa..5509332 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -677,10 +677,10 @@ export default { 'members.invite_email_failed': '无法发送邮件——请手动分享验证码。', 'members.invite_email_opt': '通过电子邮件发送邀请(可能进入垃圾邮件)', 'members.link_title': "通过链接邀请", - 'members.link_hint': "适用于可能还没有账户的人。该链接只能使用一次,且仅限使用此地址注册的账户。", - 'members.link_email_placeholder': "电子邮件地址", + 'members.link_hint': "适用于可能还没有账户的人。可以用任何方式发送——即时通讯应用、短信。链接仅可使用一次,有效期七天,归最先打开的人。", + 'members.link_email_placeholder': "电子邮件地址(可选)", 'members.link_btn': "创建链接", - 'members.link_ready': "发给 {email} 的邀请链接:", + 'members.link_ready': "邀请链接——有效期 7 天,仅限一次:", 'members.link_copy': "复制", 'members.link_copied': "已复制", 'members.link_email_sent': "链接已通过电子邮件发送。", @@ -689,9 +689,12 @@ export default { 'members.link_status_expired': "已过期", 'members.link_expires': "{date} 过期", 'members.link_cancel': "取消", + 'members.link_share': "分享", + 'members.link_share_title': "邀请", + 'members.link_unlabelled': "{date} 创建的链接", 'invite.title': "您收到了邀请", 'invite.none': "此标签页中没有待处理的邀请。请重新打开您收到的链接。", - 'invite.signed_out': "有人邀请您加入此中心上的一个群组。请使用收到邀请的电子邮件地址创建账户,如果已有账户请登录。", + 'invite.signed_out': "有人邀请您加入此中心上的一个群组。请创建账户,如果已有账户请登录。", 'invite.register': "创建账户", 'invite.signin': "登录", 'invite.confirm': "{inviter} 邀请您加入 {group}。", @@ -699,7 +702,6 @@ export default { 'invite.ignore': "忽略", 'invite.open': "打开群组", 'invite.already_member': "您已经是 {group} 的成员。", - 'invite.other_account': "此邀请发送到了另一个电子邮件地址。请使用该地址注册的账户登录——别名和点号必须完全一致。", 'invite.invalid': "此邀请已失效:已被使用、取消或已过期。请索取新的邀请。", 'invite.joining': "正在加入…", 'invite.after_register': "登录以加入您受邀的群组。", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js index ed7a2fa..d3d06d7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js @@ -37,6 +37,10 @@ export function ProfilePage({ user, onLogout }) { const [emailVerifyPending, setEmailVerifyPending] = useState(false); const [emailCode, setEmailCode] = useState(''); const [emailVerifying, setEmailVerifying] = useState(false); + // Changing the address on file now needs the passphrase (the hub verifies the + // derived auth_key): a bare access token — which every node this account + // connects to is handed — must not be able to start an account takeover. + const [emailPass, setEmailPass] = useState(''); const [pinCount, setPinCount] = useState( () => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0)); const [delOpen, setDelOpen] = useState(false); @@ -232,12 +236,18 @@ export function ProfilePage({ user, onLogout }) { const saveEmail = useCallback(async () => { const val = emailDraft.trim(); if (!val || val === email) { setEmailEditing(false); return; } + if (!emailPass) return; // the Save button is disabled until it is entered setEmailSaving(true); setEmailStatus(''); try { + // The passphrase is the second factor here, exactly as for a passphrase + // change or an account deletion: the hub gets the derived auth_key, never + // the passphrase itself. + const authKey = await window.MeshBayKeys.deriveAuthKey(emailPass, user.username); const resp = await hubFetch('/v1/users/me', { - method: 'PATCH', token: user.token, body: { email: val }, + method: 'PATCH', token: user.token, body: { email: val, auth_key: authKey }, }); + setEmailPass(''); if (resp.email_verification_required) { setEmailVerifyPending(true); setEmailStatus(t('settings.email_code_sent')); @@ -248,11 +258,13 @@ export function ProfilePage({ user, onLogout }) { setTimeout(() => setEmailStatus(''), 3000); } } catch (e) { - setEmailStatus(e.message); + const msg = /403|does not match/i.test(e.message) + ? t('settings.pw_wrong_current') : e.message; + setEmailStatus(msg); } finally { setEmailSaving(false); } - }, [emailDraft, email, user.token]); + }, [emailDraft, email, emailPass, user.token, user.username]); const verifyEmailChange = useCallback(async () => { if (!emailCode.trim()) return; @@ -311,14 +323,18 @@ export function ProfilePage({ user, onLogout }) { ? html`<span style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"> <input type="email" value=${emailDraft} onInput=${e => setEmailDraft(e.target.value)} - onKeyDown=${e => e.key === 'Enter' && saveEmail()} disabled=${emailVerifyPending} style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" /> ${!emailVerifyPending && html` + <input type="password" autocomplete="current-password" + placeholder=${t('login.password')} + value=${emailPass} onInput=${e => setEmailPass(e.target.value)} + onKeyDown=${e => e.key === 'Enter' && saveEmail()} + style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" /> <button class="admin-btn" onClick=${saveEmail} - disabled=${emailSaving}>${t('settings.email_save')}</button> + disabled=${emailSaving || !emailPass}>${t('settings.email_save')}</button> <button class="btn-secondary" onClick=${() => { - setEmailEditing(false); setEmailDraft(email); + setEmailEditing(false); setEmailDraft(email); setEmailPass(''); }}>${t('settings.cancel')}</button> `} </span>` diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 202db94..f3683eb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -305,16 +305,15 @@ window.addEventListener('hashchange', () => { // The `v: '0.1'` on every other message in this file is the historical value // and is read by nothing; it is left alone deliberately. The range is // negotiated once, at the start, not restated per message. -const MNP_V = '3.1'; -// **Not** raised with it, and that is the whole difference between 3.0 and 3.1. -// 3.0 was a flag day because a node older than it cannot grant the lease this -// client opens for every download and upload, so talking to one would mean -// every transfer failing for a reason the person cannot act on. 3.1 only adds -// `user_blob_*`: a 3.0 node answers "unknown message type" and the client -// stores its playlists on the next node it reaches, keeping its own copy -// meanwhile (docs/playlists.md §6.4). Refusing every 3.0 node over a feature -// that degrades this quietly would be the flag day nobody needed. -const MNP_V_MIN = '3.0'; +const MNP_V = '4.0'; +// Raised with it: 4.0 is a flag day. A member now presents a short-lived +// MNP-audience token in the handshake, not its hub session token — a node +// older than 4.0 expected the session token, and one newer refuses it, so the +// two cannot authenticate across the break. This is the C6 rule: no +// compatibility branch, or the old path (a hub credential handed to a node) +// stays reachable. The desktop client is gated by client.minimum before it +// even connects; the browser picks up this build on reload. +const MNP_V_MIN = '4.0'; // Codes a NODE sends us, in its own vocabulary (meshbay_common/handshake.py's // check_version): `version_too_old` means *we* are too old for it, @@ -651,6 +650,25 @@ class MeshBayTransport { try { this.onConnectProgress(phase); } catch { /* the caller's problem */ } } + // Fetch the short-lived token presented to a node in the handshake. It is a + // different credential from the session token used for hub calls: aud=MNP_AUD, + // useless at the hub API, so a node operator who captures it gains nothing + // there (see meshbay_common/tokens.py). Uses the session token to ask. + async _fetchNodeToken(call) { + const doFetch = call + || (window.MeshBayPlatform && window.MeshBayPlatform.apiFetch) || fetch; + const r = await doFetch(`${this._hubUrl}/v1/nodes/mnp-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this._accessToken}`, + }, + body: JSON.stringify({}), + }); + if (!r.ok) throw new Error(`Could not obtain a node token: ${r.status}`); + return (await r.json()).mnp_token; + } + async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username, userId, joinCode, recoveryKey, joinNodePk) { // Remembered for _reconnectLoop, which calls connect() again with these @@ -896,11 +914,21 @@ class MeshBayTransport { // recorded handshake_ack could be replayed by an impersonating peer. this._nonceClient = crypto.getRandomValues(new Uint8Array(32)); + // The member authenticates to the node with a short-lived MNP token, never + // its hub session token. A node operator holds whatever is presented here, + // and the session token opens the hub API — so presenting it would hand an + // operator a live credential for the member (audience-bound, see + // meshbay_common/tokens.py). Fetched per connect and per reconnect with the + // session token (`this._accessToken`), so it always carries current group + // membership and a fresh expiry. Signaling above still uses the session + // token, because that is a hub call. + const nodeToken = await this._fetchNodeToken(call); + const reply = await this._sendAndWait({ type: 'handshake', v: MNP_V, v_min: MNP_V_MIN, - token: jwtToken, + token: nodeToken, group_id: groupId || '', nonce: window.MeshBayCrypto.b64encode(this._nonceClient), }); diff --git a/packages/meshbay-hub/tests/test_availability_between_members.py b/packages/meshbay-hub/tests/test_availability_between_members.py index 4f5cbfb..cd10046 100644 --- a/packages/meshbay-hub/tests/test_availability_between_members.py +++ b/packages/meshbay-hub/tests/test_availability_between_members.py @@ -482,13 +482,14 @@ async def test_changing_your_address_cannot_mail_strangers_at_will( user = await _make_user(client, "av_mailer") headers = {"Authorization": f"Bearer {user['token']}"} + ak = base64.b64encode(b"k" * 32).decode() # the auth_key _make_user signs up with r = await client.patch("/v1/users/me", headers=headers, - json={"email": "a-stranger@example.test"}) + json={"email": "a-stranger@example.test", "auth_key": ak}) assert r.status_code == 200, r.text assert len(sent) == 1 r = await client.patch("/v1/users/me", headers=headers, - json={"email": "another-stranger@example.test"}) + json={"email": "another-stranger@example.test", "auth_key": ak}) assert r.status_code == 429, r.text assert len(sent) == 1, "the hub mailed a second stranger on demand" diff --git a/packages/meshbay-hub/tests/test_email_change_requires_passphrase.py b/packages/meshbay-hub/tests/test_email_change_requires_passphrase.py new file mode 100644 index 0000000..697e6f9 --- /dev/null +++ b/packages/meshbay-hub/tests/test_email_change_requires_passphrase.py @@ -0,0 +1,55 @@ +"""Changing the address on file requires the passphrase, not merely a token. + +A member hands its hub access token to every node it connects to (the MNP +handshake), so a node operator holds a live bearer token for that member. +`PATCH /v1/users/me {email}` used to need only that token, and the confirmation +code goes to the *new* address — so an operator could point the account's e-mail +at their own inbox, confirm it, and then use the passphrase-reset path to take +the account over. The passphrase (as the derived auth_key, which is all the hub +ever sees) is now required, exactly as for a passphrase change or an account +deletion. +""" + +import pytest + + +async def _account(client, username="mail_pass_test", auth_key="k" * 44): + await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@test.local", "auth_key": auth_key}) + r = await client.post("/v1/users/login", json={ + "username": username, "auth_key": auth_key}) + return r.json()["access_token"] + + +@pytest.mark.asyncio +async def test_email_change_without_passphrase_is_refused(client): + tok = await _account(client) + r = await client.patch("/v1/users/me", headers={"Authorization": f"Bearer {tok}"}, + json={"email": "attacker@evil.invalid"}) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_email_change_with_wrong_passphrase_is_refused(client): + tok = await _account(client) + r = await client.patch("/v1/users/me", headers={"Authorization": f"Bearer {tok}"}, + json={"email": "attacker@evil.invalid", "auth_key": "z" * 44}) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_email_change_with_correct_passphrase_proceeds(client): + tok = await _account(client, username="mail_ok_test", auth_key="k" * 44) + r = await client.patch("/v1/users/me", headers={"Authorization": f"Bearer {tok}"}, + json={"email": "new@real.invalid", "auth_key": "k" * 44}) + assert r.status_code == 200 + assert r.json().get("email_verification_required") is True + + +@pytest.mark.asyncio +async def test_profile_patch_without_email_needs_no_passphrase(client): + """Regression: a PATCH that does not change the address is unaffected.""" + tok = await _account(client, username="mail_noop_test") + r = await client.patch("/v1/users/me", headers={"Authorization": f"Bearer {tok}"}, + json={}) + assert r.status_code == 200 diff --git a/packages/meshbay-hub/tests/test_group_purge.py b/packages/meshbay-hub/tests/test_group_purge.py index 9c6a664..40d2d54 100644 --- a/packages/meshbay-hub/tests/test_group_purge.py +++ b/packages/meshbay-hub/tests/test_group_purge.py @@ -84,7 +84,7 @@ async def _group_with_everything(client, db, owner_token: str, member: str, name ip_address="192.0.2.1")) owner_id = (await db.execute(select(Group.admin_id).where(Group.id == gid))).scalar_one() db.add(GroupInviteLink(group_id=gid, created_by=owner_id, ticket_hash=gid[:8] * 8, - email_hash="1" * 64, email_masked="m***@e***.com", + email_masked="m***@e***.com", expires_at=datetime.now(UTC) + timedelta(days=1), redeemed_by=member_id, redeemed_at=datetime.now(UTC))) await db.commit() diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py index 2afd27b..162b074 100644 --- a/packages/meshbay-hub/tests/test_hub_api.py +++ b/packages/meshbay-hub/tests/test_hub_api.py @@ -3,6 +3,7 @@ Integration tests for the Hub API. Uses SQLite in-memory + httpx.AsyncClient — no PostgreSQL, no network. """ +from meshbay_common.tokens import HUB_API_AUD from datetime import UTC import pytest @@ -142,7 +143,7 @@ async def test_jwt_offline_verify(client, hub_key_path): r_pk = await client.get("/v1/hub/pubkey") hub_pk_pem = r_pk.json()["pk_hub_pem"].encode() - decoded = pyjwt.decode(token, hub_pk_pem, algorithms=["EdDSA"]) + decoded = pyjwt.decode(token, hub_pk_pem, algorithms=["EdDSA"], audience=HUB_API_AUD) assert "jti" in decoded # mandatory # The token carries no user key. It used to, and the node recorded it as the # uploader's identity — so whoever issued tokens decided who could delete a @@ -339,7 +340,7 @@ async def test_jwt_contains_groups_claim(client): token_pre = r.json()["access_token"] r_pk = await client.get("/v1/hub/pubkey") hub_pk = r_pk.json()["pk_hub_pem"].encode() - decoded_pre = pyjwt.decode(token_pre, hub_pk, algorithms=["EdDSA"]) + decoded_pre = pyjwt.decode(token_pre, hub_pk, algorithms=["EdDSA"], audience=HUB_API_AUD) assert decoded_pre["groups"] == [] # Alice creates a group and adds Bob @@ -359,13 +360,13 @@ async def test_jwt_contains_groups_claim(client): r = await client.post("/v1/users/login", json={ "username": "grp_bob_test", "password": "bobpass99"}) token_post = r.json()["access_token"] - decoded_post = pyjwt.decode(token_post, hub_pk, algorithms=["EdDSA"]) + decoded_post = pyjwt.decode(token_post, hub_pk, algorithms=["EdDSA"], audience=HUB_API_AUD) assert group_id in decoded_post["groups"] # Alice (admin) should also have the group in her JWT r = await client.post("/v1/users/login", json={ "username": "grp_alice", "password": "alicepass99"}) - decoded_alice = pyjwt.decode(r.json()["access_token"], hub_pk, algorithms=["EdDSA"]) + decoded_alice = pyjwt.decode(r.json()["access_token"], hub_pk, algorithms=["EdDSA"], audience=HUB_API_AUD) assert group_id in decoded_alice["groups"] diff --git a/packages/meshbay-hub/tests/test_invite_link_client.py b/packages/meshbay-hub/tests/test_invite_link_client.py index 2223ad8..313e224 100644 --- a/packages/meshbay-hub/tests/test_invite_link_client.py +++ b/packages/meshbay-hub/tests/test_invite_link_client.py @@ -188,7 +188,9 @@ def test_the_members_tab_sends_the_code_only_for_the_mail(): sends = [m.start() for m in re.finditer(r"code: node\.code", settings)] assert len(sends) == 1 before = settings[settings.rfind("\n", 0, sends[0] - 200):sends[0]] - assert "inviteByEmail ?" in before, "the code reaches the hub only when the box asks" + assert "mailIt ?" in before, "the code reaches the hub only when the box asks" + assert "const mailIt = inviteByEmail && Boolean(email);" in settings, ( + "and the box asks only when there is an address to mail") def test_signing_out_forgets_the_invitation(): diff --git a/packages/meshbay-hub/tests/test_invite_links.py b/packages/meshbay-hub/tests/test_invite_links.py index 461cf8a..2217cfe 100644 --- a/packages/meshbay-hub/tests/test_invite_links.py +++ b/packages/meshbay-hub/tests/test_invite_links.py @@ -63,22 +63,16 @@ def sent(monkeypatch): # ── Who gets in ────────────────────────────────────────────────────────────── @pytest.mark.asyncio -async def test_the_addressed_account_joins_and_nobody_else(client, db_session): +async def test_whoever_opens_it_first_joins_and_nobody_after(client, db_session): + # A link travels by any messaging app, so the address the owner typed binds + # nothing: an account registered with another one redeems it. owner = await _account(client, "link_owner") gid = await _group(client, owner) r = await _link(client, owner, gid, email="Invitee@Example.test") assert r.status_code == 201, r.text ticket = r.json()["ticket"] - mallory = await _account(client, "link_mallory") - for route in ("preview", "redeem"): - r = await client.post(f"/v1/invite-links/{route}", json={"ticket": ticket}, - headers=mallory["h"]) - assert r.status_code == 403 and r.json()["detail"] == "invite_other_account" - assert "invitee" not in r.text.lower(), "the refusal must not name the address" - - # Registered with the address the owner typed, case aside. - invitee = await _account(client, "link_invitee", email="invitee@example.test") + invitee = await _account(client, "link_invitee", email="elsewhere@example.test") r = await client.post("/v1/invite-links/preview", json={"ticket": ticket}, headers=invitee["h"]) assert r.status_code == 200, r.text @@ -102,11 +96,30 @@ async def test_the_addressed_account_joins_and_nobody_else(client, db_session): GroupMember.group_id == gid))).scalars().all() assert len(rows) == 2 - # And the one who comes after, even with the right address, gets nothing: - # a twin account cannot exist (addresses are unique), so try the other. - r = await client.post("/v1/invite-links/redeem", json={"ticket": ticket}, - headers=mallory["h"]) - assert r.status_code == 404 + # Whoever comes after, even with the address the owner typed, gets nothing. + late = await _account(client, "link_late", email="invitee@example.test") + for route in ("preview", "redeem"): + r = await client.post(f"/v1/invite-links/{route}", json={"ticket": ticket}, + headers=late["h"]) + assert r.status_code == 404 and r.json()["detail"] == "invite_not_valid" + + +@pytest.mark.asyncio +async def test_a_link_needs_no_address_unless_it_is_mailed(client, db_session, sent): + owner = await _account(client, "noaddr_owner") + gid = await _group(client, owner) + r = await _link(client, owner, gid, email="") + assert r.status_code == 201, r.text + assert r.json()["email_status"] == "not_requested" + row = (await db_session.execute(select(GroupInviteLink))).scalar_one() + assert row.email_masked is None + listed = (await client.get(f"/v1/groups/{gid}/invite-links", + headers=owner["h"])).json()["links"] + assert [link["email"] for link in listed] == [""] + + r = await _link(client, owner, gid, email="", send_email=True, + node_pk=NODE_PK, code=CODE) + assert r.status_code == 422 and sent == [] @pytest.mark.asyncio @@ -115,7 +128,7 @@ async def test_a_ticket_is_stored_only_as_a_hash(client, db_session): gid = await _group(client, owner) ticket = (await _link(client, owner, gid)).json()["ticket"] row = (await db_session.execute(select(GroupInviteLink))).scalar_one() - assert ticket not in (row.ticket_hash, row.email_masked, row.email_hash) + assert ticket not in (row.ticket_hash, row.email_masked) assert row.ticket_hash == invite_links.ticket_hash(ticket) assert "invitee@" not in row.email_masked diff --git a/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py b/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py index 157dbd5..040ff43 100644 --- a/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py +++ b/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py @@ -314,6 +314,9 @@ def test_a_refusal_never_names_the_address(): # ── The doors, driven through the API ──────────────────────────────────────── +_AK = base64.b64encode(b"k" * 32).decode() # the passphrase-derived auth_key these accounts use + + async def _register(client, username: str, email: str, captcha=None): sk_ed, sk_x = Ed25519PrivateKey.generate(), X25519PrivateKey.generate() return await client.post("/v1/users/register", json={ @@ -383,11 +386,11 @@ async def test_an_account_may_point_the_hub_at_one_stranger_then_wait( before = len(wire) r = await client.patch("/v1/users/me", headers=headers, - json={"email": "a-stranger@example.test"}) + json={"auth_key": _AK, "email": "a-stranger@example.test"}) assert r.status_code == 200, r.text r = await client.patch("/v1/users/me", headers=headers, - json={"email": "another-stranger@example.test"}) + json={"auth_key": _AK, "email": "another-stranger@example.test"}) assert r.status_code == 429, r.text assert len(wire) - before == 1, "the hub mailed a second stranger on demand" @@ -408,7 +411,7 @@ async def test_the_address_already_pending_may_be_asked_for_again( "relay_d@example.test") typo = "jean@gmial.test" - r = await client.patch("/v1/users/me", headers=headers, json={"email": typo}) + r = await client.patch("/v1/users/me", headers=headers, json={"auth_key": _AK, "email": typo}) assert r.status_code == 200, r.text # The recipient's own cooldown is not what is under test here. @@ -419,7 +422,7 @@ async def test_the_address_already_pending_may_be_asked_for_again( await db_session.commit() before = len(wire) - r = await client.patch("/v1/users/me", headers=headers, json={"email": typo}) + r = await client.patch("/v1/users/me", headers=headers, json={"auth_key": _AK, "email": typo}) assert r.status_code == 200, ( "a typo locked the account out of correcting it: " + r.text) assert len(wire) - before == 1 @@ -437,7 +440,7 @@ async def test_the_delay_survives_the_verification_row_being_deleted( "relay_c@example.test") r = await client.patch("/v1/users/me", headers=headers, - json={"email": "c-first@example.test"}) + json={"auth_key": _AK, "email": "c-first@example.test"}) assert r.status_code == 200, r.text from meshbay_hub.db.models import EmailVerification @@ -446,7 +449,7 @@ async def test_the_delay_survives_the_verification_row_being_deleted( await db_session.commit() r = await client.patch("/v1/users/me", headers=headers, - json={"email": "c-second@example.test"}) + json={"auth_key": _AK, "email": "c-second@example.test"}) assert r.status_code == 429, ( "the delay was counted from a table the handler empties") diff --git a/packages/meshbay-hub/tests/test_mnp_token.py b/packages/meshbay-hub/tests/test_mnp_token.py new file mode 100644 index 0000000..7b483ff --- /dev/null +++ b/packages/meshbay-hub/tests/test_mnp_token.py @@ -0,0 +1,71 @@ +"""The MNP token: a member's credential to a node, useless at the hub API. + +A member hands whatever token it presents to every node it connects to (the MNP +handshake). That must not be the hub session token, which opens the hub API — +otherwise a node operator holds a live credential for the member. `POST +/v1/nodes/mnp-token` mints a short-lived, node-audience token for that purpose; +these tests pin that it authorises to a node and is refused by the hub API. +""" + +import pytest + +from meshbay_common.handshake import HandshakeError, authorize_token +from meshbay_common.tokens import MNP_AUD + + +async def _session_token(client, username="mnp_user_test"): + await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@test.local", "auth_key": "k" * 44}) + r = await client.post("/v1/users/login", json={ + "username": username, "auth_key": "k" * 44}) + return r.json()["access_token"] + + +@pytest.mark.asyncio +async def test_mnp_token_endpoint_needs_a_session(client): + # No Authorization header at all — FastAPI rejects the required header (422), + # like every other authenticated route; the point is it is not minted anonymously. + r = await client.post("/v1/nodes/mnp-token") + assert r.status_code in (401, 403, 422) + + +@pytest.mark.asyncio +async def test_mnp_token_is_minted_for_a_member(client): + tok = await _session_token(client) + r = await client.post("/v1/nodes/mnp-token", + headers={"Authorization": f"Bearer {tok}"}) + assert r.status_code == 200 + assert r.json().get("mnp_token") + + +@pytest.mark.asyncio +async def test_mnp_token_is_refused_at_the_hub_api(client): + """The whole point: the credential a node receives opens nothing at the hub.""" + tok = await _session_token(client, "mnp_api_test") + mnp = (await client.post("/v1/nodes/mnp-token", + headers={"Authorization": f"Bearer {tok}"})).json()["mnp_token"] + # Presenting it to a hub endpoint fails. + r = await client.get("/v1/users/me", headers={"Authorization": f"Bearer {mnp}"}) + assert r.status_code == 401 + + +@pytest.mark.asyncio +async def test_a_session_token_is_refused_by_a_node_but_the_mnp_token_is_not(client): + """The mirror image, at the node's decode: the session token (what the API + accepts) is refused by `authorize_token`, and the MNP token is accepted.""" + from meshbay_hub.auth import hub_public_key_pem + + # Make the member a member of a group so the MNP token carries it. + tok = await _session_token(client, "mnp_node_test") + H = {"Authorization": f"Bearer {tok}"} + gid = (await client.post("/v1/groups", headers=H, json={ + "name": "g", "visibility": "private", "join_policy": "invite"})).json()["group_id"] + mnp = (await client.post("/v1/nodes/mnp-token", headers=H)).json()["mnp_token"] + pk = hub_public_key_pem() + + # The session token is refused by the node handshake (wrong audience). + with pytest.raises(HandshakeError): + authorize_token(tok, pk, group_id=gid) + # The MNP token authorises the member to the node. + peer = authorize_token(mnp, pk, group_id=gid) + assert peer.group_id == gid diff --git a/packages/meshbay-hub/tests/test_node_scope_not_admin.py b/packages/meshbay-hub/tests/test_node_scope_not_admin.py new file mode 100644 index 0000000..58cabb1 --- /dev/null +++ b/packages/meshbay-hub/tests/test_node_scope_not_admin.py @@ -0,0 +1,159 @@ +"""A node-scoped daemon token must never reach the hub admin/moderator surface. + +A node's authority and a hub role are different notions (docs/MESHBAY_DESIGN.md +§7.1, NS4): what a node may do is decided by its operator's roster pin on the +node and by the deliberately narrow node scope; being an admin or moderator is a +hub role on a *person's* account, exercised from a browser with a user-scoped +token. When the operator's account also holds a hub role — which is the case on +the reference deployment, where the operator is an admin and runs a node — the +`scope:"node"` token the daemon keeps in memory used to pass `require_admin` and +`require_moderator`, because those checked only the role and not the scope. The +scope gate was wired onto `require_user_scope` (group mutation) alone. + +These are refusals: each asserts the node token is turned away with 403, and the +same account's user token is let through, so the guard is proven to bite on the +scope and not on the account. +""" + +import base64 +import time + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_hub.api.deps import set_admin_usernames + + +def _gen_ed25519(): + sk = Ed25519PrivateKey.generate() + pk_raw = sk.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + return sk, base64.b64encode(pk_raw).decode() + + +async def _register(client, username): + r = await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@test.local", + "auth_key": "k" * 44}) + assert r.status_code == 201 + + +async def _user_login(client, username): + r = await client.post("/v1/users/login", json={ + "username": username, "auth_key": "k" * 44}) + assert r.status_code == 200 + return r.json()["access_token"] + + +async def _node_token(client, username, user_token): + """Link a node key for `username` and return a scope:"node" token for it.""" + sk_node, pk_node = _gen_ed25519() + r = await client.put("/v1/users/me/node_key", + json={"pk_node_ed25519": pk_node}, + headers={"Authorization": f"Bearer {user_token}"}) + assert r.status_code == 200 + ts = int(time.time()) + sig = sk_node.sign(f"meshbay:node_auth:{username}:{ts}".encode()) + r = await client.post("/v1/nodes/auth", json={ + "username": username, "timestamp": ts, + "signature": base64.b64encode(sig).decode()}) + assert r.status_code == 200 + data = r.json() + # Confirm we really are holding a node-scoped token. + import jwt as _jwt + assert _jwt.decode(data["access_token"], options={"verify_signature": False} + )["scope"] == "node" + return data["access_token"] + + +async def _admin_with_node(client, username="op_admin_test"): + """An admin account that also runs a node — the reference-deployment case. + + Returns (user_token, node_token) for the same account. + """ + await _register(client, username) + set_admin_usernames([username]) + user_token = await _user_login(client, username) + node_token = await _node_token(client, username, user_token) + return user_token, node_token + + +def _bearer(token): + return {"Authorization": f"Bearer {token}"} + + +# ── require_admin ──────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_node_token_cannot_reach_admin_stats(client): + user_token, node_token = await _admin_with_node(client) + assert (await client.get("/v1/admin/stats", headers=_bearer(node_token)) + ).status_code == 403 + # The same account, from a browser, is admin and gets in. + assert (await client.get("/v1/admin/stats", headers=_bearer(user_token)) + ).status_code == 200 + + +@pytest.mark.asyncio +async def test_node_token_cannot_revoke(client): + """The sharpest one: revoke is signed and broadcast to every node.""" + _, node_token = await _admin_with_node(client) + r = await client.post("/v1/admin/revoke", headers=_bearer(node_token), + json={"target": "group", "target_id": "whatever"}) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_node_token_cannot_change_instance_policy(client): + _, node_token = await _admin_with_node(client) + r = await client.patch("/v1/admin/settings", headers=_bearer(node_token), + json={"allow_public_groups": True}) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_node_token_cannot_delete_account(client): + _, node_token = await _admin_with_node(client) + r = await client.delete("/v1/admin/users/some-id", headers=_bearer(node_token)) + assert r.status_code == 403 + + +# ── require_moderator (a wider set of accounts, including the IP audit log) ─── + +@pytest.mark.asyncio +async def test_node_token_cannot_read_ip_audit_log(client): + user_token, node_token = await _admin_with_node(client) + assert (await client.get("/v1/admin/logs", headers=_bearer(node_token)) + ).status_code == 403 + assert (await client.get("/v1/admin/logs", headers=_bearer(user_token)) + ).status_code == 200 + + +@pytest.mark.asyncio +async def test_node_token_cannot_list_nodes(client): + _, node_token = await _admin_with_node(client) + assert (await client.get("/v1/admin/nodes", headers=_bearer(node_token)) + ).status_code == 403 + + +@pytest.mark.asyncio +async def test_a_moderators_node_token_is_also_refused(client): + """A moderator (not admin) who runs a node: the moderation surface is still + the person's, not the machine's.""" + # An admin promotes a second account to moderator, which then links a node. + admin_user_token, _ = await _admin_with_node(client, "boss_admin_test") + await _register(client, "mod_test") + mod_user_token = await _user_login(client, "mod_test") + # promote + import jwt as _jwt + mod_id = _jwt.decode(mod_user_token, options={"verify_signature": False})["sub"] + r = await client.patch(f"/v1/admin/users/{mod_id}", json={"role": "moderator"}, + headers=_bearer(admin_user_token)) + assert r.status_code == 200 + mod_node_token = await _node_token(client, "mod_test", mod_user_token) + # user-scoped moderator token gets in; node-scoped one does not + assert (await client.get("/v1/admin/stats", headers=_bearer(mod_user_token)) + ).status_code == 200 + assert (await client.get("/v1/admin/stats", headers=_bearer(mod_node_token)) + ).status_code == 403 diff --git a/packages/meshbay-hub/tests/test_revoke_is_one_path.py b/packages/meshbay-hub/tests/test_revoke_is_one_path.py new file mode 100644 index 0000000..2acb46c --- /dev/null +++ b/packages/meshbay-hub/tests/test_revoke_is_one_path.py @@ -0,0 +1,164 @@ +"""Revoke is one door, it is admin-only, and it broadcasts. + +Two coupled gaps used to sit in the moderation surface (docs/MESHBAY_DESIGN.md +§7.5): + + * `admin_patch_group` let a *moderator* set a group to `revoked`, while the + user handler makes revoke admin-only; + * a `revoked` set through either PATCH was **never broadcast** to nodes — + unlike `POST /v1/admin/revoke` and account deletion — so it behaved like + `suspended` on nodes while claiming to be the signed, node-enforced state. + +Revoke now has one path, `POST /v1/admin/revoke` (admin-only, signs and +broadcasts). PATCH refuses `revoked` and refuses to move an entity *out* of +`revoked` unless the caller is an admin. +""" + +import pytest + +from meshbay_hub.api.deps import set_admin_usernames + + +async def _register(client, username): + r = await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@test.local", "auth_key": "k" * 44}) + assert r.status_code == 201 + return r.json()["user_id"] + + +async def _login(client, username): + r = await client.post("/v1/users/login", json={ + "username": username, "auth_key": "k" * 44}) + assert r.status_code == 200 + return r.json()["access_token"] + + +def _h(token): + return {"Authorization": f"Bearer {token}"} + + +async def _admin(client, name="admin_rev_test"): + await _register(client, name) + set_admin_usernames([name]) + return await _login(client, name) + + +async def _moderator(client, admin_token, name="mod_rev_test"): + uid = await _register(client, name) + r = await client.patch(f"/v1/admin/users/{uid}", json={"role": "moderator"}, + headers=_h(admin_token)) + assert r.status_code == 200 + return uid, await _login(client, name) + + +async def _a_group(client, owner="owner_rev_test"): + await _register(client, owner) + tok = await _login(client, owner) + r = await client.post("/v1/groups", headers=_h(tok), + json={"name": "g", "visibility": "private", "join_policy": "invite"}) + assert r.status_code == 201 + return r.json()["group_id"] + + +async def _group_status(client, admin_token, group_id): + data = (await client.get("/v1/admin/groups?limit=200", headers=_h(admin_token))).json() + return next(g["status"] for g in data["groups"] if g["id"] == group_id) + + +# ── PATCH cannot revoke ────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_moderator_cannot_revoke_a_group_via_patch(client): + admin_token = await _admin(client) + _, mod_token = await _moderator(client, admin_token) + gid = await _a_group(client) + r = await client.patch(f"/v1/admin/groups/{gid}", json={"status": "revoked"}, + headers=_h(mod_token)) + assert r.status_code == 403 + assert await _group_status(client, admin_token, gid) == "active" + + +@pytest.mark.asyncio +async def test_admin_patch_revoked_group_is_redirected_not_silently_applied(client): + admin_token = await _admin(client) + gid = await _a_group(client) + r = await client.patch(f"/v1/admin/groups/{gid}", json={"status": "revoked"}, + headers=_h(admin_token)) + assert r.status_code == 400 + assert "revoke" in r.json()["detail"].lower() + # And it was not quietly applied. + assert await _group_status(client, admin_token, gid) == "active" + + +@pytest.mark.asyncio +async def test_admin_patch_revoked_user_is_redirected(client): + admin_token = await _admin(client) + victim = await _register(client, "vic_rev_test") + r = await client.patch(f"/v1/admin/users/{victim}", json={"status": "revoked"}, + headers=_h(admin_token)) + assert r.status_code == 400 + + +# ── The one door: /v1/admin/revoke, admin-only, and it broadcasts ──────────── + +@pytest.mark.asyncio +async def test_admin_revoke_group_broadcasts_and_sets_status(client): + admin_token = await _admin(client) + gid = await _a_group(client) + r = await client.post("/v1/admin/revoke", headers=_h(admin_token), + json={"target": "group", "target_id": gid}) + assert r.status_code == 200 + body = r.json() + assert body["status"] == "revoked" + assert "nodes_notified" in body # it went through the broadcast path + assert await _group_status(client, admin_token, gid) == "revoked" + + +@pytest.mark.asyncio +async def test_moderator_cannot_reach_the_revoke_endpoint(client): + admin_token = await _admin(client) + _, mod_token = await _moderator(client, admin_token) + gid = await _a_group(client) + r = await client.post("/v1/admin/revoke", headers=_h(mod_token), + json={"target": "group", "target_id": gid}) + assert r.status_code == 403 + + +# ── Leaving `revoked` is an admin's call ───────────────────────────────────── + +@pytest.mark.asyncio +async def test_moderator_cannot_unrevoke_a_group(client): + admin_token = await _admin(client) + _, mod_token = await _moderator(client, admin_token) + gid = await _a_group(client) + await client.post("/v1/admin/revoke", headers=_h(admin_token), + json={"target": "group", "target_id": gid}) + r = await client.patch(f"/v1/admin/groups/{gid}", json={"status": "active"}, + headers=_h(mod_token)) + assert r.status_code == 403 + assert await _group_status(client, admin_token, gid) == "revoked" + + +@pytest.mark.asyncio +async def test_moderator_cannot_unrevoke_a_user(client): + admin_token = await _admin(client) + _, mod_token = await _moderator(client, admin_token) + victim = await _register(client, "vic2_rev_test") + await client.post("/v1/admin/revoke", headers=_h(admin_token), + json={"target": "user", "target_id": victim}) + r = await client.patch(f"/v1/admin/users/{victim}", json={"status": "active"}, + headers=_h(mod_token)) + assert r.status_code == 403 + + +# ── Regression: suspend/unsuspend by a moderator still works ───────────────── + +@pytest.mark.asyncio +async def test_moderator_can_still_suspend_and_restore(client): + admin_token = await _admin(client) + _, mod_token = await _moderator(client, admin_token) + gid = await _a_group(client) + assert (await client.patch(f"/v1/admin/groups/{gid}", json={"status": "suspended"}, + headers=_h(mod_token))).status_code == 200 + assert (await client.patch(f"/v1/admin/groups/{gid}", json={"status": "active"}, + headers=_h(mod_token))).status_code == 200 diff --git a/packages/meshbay-hub/tests/test_token_hardening.py b/packages/meshbay-hub/tests/test_token_hardening.py new file mode 100644 index 0000000..f381f69 --- /dev/null +++ b/packages/meshbay-hub/tests/test_token_hardening.py @@ -0,0 +1,128 @@ +"""A hub-signed token is a session only if it says so, and only while it lasts. + +One Ed25519 key signs four kinds of token: user access, node access, revocation +broadcasts and MHP federation tokens. `decode_access_token` used to accept any +of them that carried a valid signature, requiring no `exp` and binding no +purpose — so a token with no expiry was honoured, and the separation between +the four rested only on which fields each consumer happened to read. A +revocation token is even handed back in the body of `POST /v1/admin/revoke` and +pushed to every node, so nodes hold hub-signed tokens. + +These are refusals: `decode_access_token` must require `exp`, `sub` and a known +`scope`, so a token with no expiry, a revocation token, or an MHP token can +never be mistaken for a session — while a real login token still works. +""" + +import time + +import pytest + +from meshbay_common.tokens import HUB_API_AUD +from meshbay_hub import auth + + +def _sk_pem_loaded(): + # The `client` fixture's app runs load_hub_keypair in its lifespan, so the + # module key is loaded by the time a test body runs. + return auth._hub_sk_pem is not None + + +# ── Unit-level: the decode contract ────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_token_without_exp_is_refused(client): + import jwt + assert _sk_pem_loaded() + # A hub-signed token with a valid scope and sub but NO exp. + forged = jwt.encode({"sub": "u", "scope": "user", "aud": HUB_API_AUD}, + auth.hub_private_key_pem(), algorithm="EdDSA") + with pytest.raises(Exception): + auth.decode_access_token(forged) + + +@pytest.mark.asyncio +async def test_expired_token_is_refused(client): + import jwt + forged = jwt.encode({"sub": "u", "scope": "user", "aud": HUB_API_AUD, + "exp": int(time.time()) - 100}, + auth.hub_private_key_pem(), algorithm="EdDSA") + with pytest.raises(jwt.ExpiredSignatureError): + auth.decode_access_token(forged) + + +@pytest.mark.asyncio +async def test_token_without_scope_is_refused(client): + import jwt + forged = jwt.encode({"sub": "u", "exp": int(time.time()) + 3600, "aud": HUB_API_AUD}, + auth.hub_private_key_pem(), algorithm="EdDSA") + with pytest.raises(Exception): + auth.decode_access_token(forged) + + +@pytest.mark.asyncio +async def test_unknown_scope_is_refused(client): + import jwt + forged = jwt.encode({"sub": "u", "scope": "root", "aud": HUB_API_AUD, + "exp": int(time.time()) + 3600}, + auth.hub_private_key_pem(), algorithm="EdDSA") + with pytest.raises(jwt.InvalidTokenError): + auth.decode_access_token(forged) + + +@pytest.mark.asyncio +async def test_an_mnp_token_is_refused_at_the_hub_api(client): + """The MNP token (aud=MNP_AUD) authorises a member to a node; it must not be + a session at the hub. A node operator holds one, and this is what stops them + replaying it against the hub API.""" + from meshbay_hub.auth import issue_mnp_token + mnp = issue_mnp_token("some-user", groups=[]) + with pytest.raises(Exception): + auth.decode_access_token(mnp) + + +@pytest.mark.asyncio +async def test_a_revocation_token_is_not_a_session(client): + """The concrete cross-type case: revocation tokens are hub-signed, carry no + exp/sub/scope, and are handed to admins and pushed to every node.""" + import jwt + from meshbay_hub.api.revocation import _sign_revocation + rev = _sign_revocation("user", "some-id", "policy") + # It is a genuine hub-signed token (signature verifies) ... + jwt.decode(rev, auth.hub_public_key_pem(), algorithms=["EdDSA"]) + # ... but it is not a session. + with pytest.raises(Exception): + auth.decode_access_token(rev) + + +# ── Endpoint-level: a revocation token gets no access ──────────────────────── + +@pytest.mark.asyncio +async def test_revocation_token_gets_no_api_access(client): + from meshbay_hub.api.revocation import _sign_revocation + rev = _sign_revocation("user", "x", "policy") + r = await client.get("/v1/users/me", headers={"Authorization": f"Bearer {rev}"}) + assert r.status_code == 401 + + +# ── The path that must keep working ────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_a_real_login_token_still_works(client): + await client.post("/v1/users/register", json={ + "username": "live_token_test", "email": "l@test.local", "auth_key": "k" * 44}) + tok = (await client.post("/v1/users/login", json={ + "username": "live_token_test", "auth_key": "k" * 44})).json()["access_token"] + dec = auth.decode_access_token(tok) + assert dec["scope"] == "user" and "exp" in dec and "sub" in dec + r = await client.get("/v1/users/me", headers={"Authorization": f"Bearer {tok}"}) + assert r.status_code == 200 + + +@pytest.mark.asyncio +async def test_a_node_token_still_decodes(client): + """Node access tokens carry scope=node/exp/sub and must keep decoding — the + node decodes its own token, and the hub decodes it on the WS.""" + from meshbay_hub.auth import issue_access_token + tok = issue_access_token("nid", ttl=3600, groups=[], scope="node") + dec = auth.decode_access_token(tok) + assert dec["scope"] == "node" diff --git a/packages/meshbay-node/src/meshbay_node/cli/members.py b/packages/meshbay-node/src/meshbay_node/cli/members.py index ba68056..5d26bd0 100644 --- a/packages/meshbay-node/src/meshbay_node/cli/members.py +++ b/packages/meshbay-node/src/meshbay_node/cli/members.py @@ -62,18 +62,15 @@ def member(args) -> None: print("which is what turning the old switch off meant.") sys.exit(1) - if not args.target: - print(f"usage: meshbay-node member {sub} <username>") - sys.exit(1) - if sub == "invite" and args.link: - # A link for someone who may have no account yet, bound to their - # address on the hub. The code and the ticket are both in it, so - # it goes to them and to nobody else — the CLI mails nothing. + # A link for someone who may have no account yet, redeemable by the + # first account that opens it. The code and the ticket are both in + # it, so it goes to them and to nobody else — the CLI mails nothing. + # The address, if given, only labels the link in the owner's list. group_id = _resolve_group(cfg, args.group) + query = f"?email={quote(args.target)}" if args.target else "" out = _daemon_api( - cfg, f"/api/groups/{group_id}/invite-links?email={quote(args.target)}", - method="POST") + cfg, f"/api/groups/{group_id}/invite-links{query}", method="POST") from meshbay_node.roster import write_code_file write_code_file(cfg.data_dir, out["link"], out.get("expires_at", ""), name="invite-link") @@ -81,11 +78,15 @@ def member(args) -> None: print(f"valid until {out.get('expires_at', '?')}") print(f"cancel with meshbay-node member cancel {out['invite_id']}") print() - print(f"Send it to {args.target} yourself. It works once, and only for an") - print("account registered with that address: they open it, create their") - print("account or sign in, and land in the group without typing a code.") + print("Send it yourself, by any messaging app. It works once, for seven") + print("days, for whoever opens it first: they create their account or") + print("sign in, and land in the group without typing a code.") return + if not args.target: + print(f"usage: meshbay-node member {sub} <username>") + sys.exit(1) + if sub == "invite": group_id = _resolve_group(cfg, args.group) out = _daemon_api( diff --git a/packages/meshbay-node/src/meshbay_node/cli/parser.py b/packages/meshbay-node/src/meshbay_node/cli/parser.py index c1f15be..29a9f63 100644 --- a/packages/meshbay-node/src/meshbay_node/cli/parser.py +++ b/packages/meshbay-node/src/meshbay_node/cli/parser.py @@ -52,8 +52,8 @@ def build_parser() -> argparse.ArgumentParser: "install|remove|start|stop|status for autostart and " "for service") parser.add_argument("target", nargs="?", - help="username for member invite|revoke|unpin (an e-mail address with " - "--link, a link id for member cancel); group name " + help="username for member invite|revoke|unpin (an optional e-mail label " + "with --link, a link id for member cancel); group name " "for group add; file id for file rm; identifier for " "denylist clear; download cap for transfers set; " "size in GB for transfers max-size") @@ -73,8 +73,8 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--group", default=None, help="group id (optional if only one is configured)") parser.add_argument("--link", action="store_true", - help="member invite: an invitation link for this e-mail " - "address, for someone who may have no account yet") + help="member invite: an invitation link, for someone who " + "may have no account yet (valid 7 days, single use)") parser.add_argument("--writable", action="store_true", default=None, dest="writable", help="root accepts member uploads (root add/set)") diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 4800dac..dc5df81 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -983,6 +983,23 @@ class NodeDaemon(EnrichmentMixin): self._config.hub.username, self._config.hub.url, ) await asyncio.sleep(5) + elif e.response.status_code in (429, 500, 502, 503, 504): + # Transient: the hub is busy (429 — often this daemon's own + # retry storm against the sign-in rate limit), restarting + # (502/503) or erroring (500/504). None of these is a reason + # to exit: the daemon exiting here crash-loops under systemd + # and strands the operator, who needs it alive to read the + # node key (`meshbay-node status`, the desktop client) so + # they can link it. Back off — respecting Retry-After when + # the hub sends one — and try again, rather than dying. + self._state["status"] = "waiting_for_hub" + delay = 10 + ra = (e.response.headers or {}).get("Retry-After") + if ra and str(ra).isdigit(): + delay = min(max(delay, int(ra)), 300) + log.warning("Hub returned %s on login — retrying in %ds", + e.response.status_code, delay) + await asyncio.sleep(delay) else: raise except Exception as e: diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index 06d3503..346a4cd 100644 --- a/packages/meshbay-node/src/meshbay_node/hub_client.py +++ b/packages/meshbay-node/src/meshbay_node/hub_client.py @@ -135,8 +135,14 @@ class HubClient: access_token = data["access_token"] from meshbay_common.handshake import JWT_LEEWAY_SECONDS + from meshbay_common.tokens import HUB_API_AUD + # This is the node's own hub-API session token (scope=node), so it + # carries aud=HUB_API_AUD and must be decoded with that audience — the + # node reads its own exp/scope/jti here. It is a different credential + # from the MNP token a member presents in the handshake (aud=MNP_AUD), + # which authorize_token binds separately. decoded = jwt.decode(access_token, hub_pk_pem, algorithms=["EdDSA"], - leeway=JWT_LEEWAY_SECONDS) + leeway=JWT_LEEWAY_SECONDS, audience=HUB_API_AUD) # No pk_user claim to check any more: tokens carry no key. What binds this # token to this node is the Ed25519 challenge it was issued against. assert "jti" in decoded, "Hub token missing jti — hub is outdated" @@ -266,7 +272,7 @@ class HubClient: async def create_invite_link(self, group_id: str, email: str, expires_at: str, node_invite_id: str) -> dict: """ - The hub's half of an invitation link: a ticket bound to `email`. + The hub's half of an invitation link: a ticket, labelled `email` if any. Never with `send_email`: the hub refuses mail to a node token, and the CLI mails nothing — the operator sends the link (docs/USERGUIDE.md §7). diff --git a/packages/meshbay-node/src/meshbay_node/ops/members.py b/packages/meshbay-node/src/meshbay_node/ops/members.py index 2fd3899..b5da9c0 100644 --- a/packages/meshbay-node/src/meshbay_node/ops/members.py +++ b/packages/meshbay-node/src/meshbay_node/ops/members.py @@ -159,15 +159,13 @@ async def create_link_invite(state: dict, group_id: str, *, 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/MESHBAY_DESIGN.md §7.3). + the hub for, redeemable by the first account that opens the link + (docs/MESHBAY_DESIGN.md §7.3). Seven days, whatever `invite_ttl_hours` says. """ 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) + code, invite_id, expires = await roster.create_link_invite(group_id, created_by) 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]) @@ -199,11 +197,12 @@ def _invite_url(hub_url: str, group_id: str, ticket: str, node_pk_b64: str, code return f"{origin}/#/invite?v=1&g={group_id}&t={ticket}&n={n}&c={code}" -async def create_link_invitation(state: dict, group_id: str, email: str, *, +async def create_link_invitation(state: dict, group_id: str, email: str = "", *, created_by: str = "local-cli") -> dict: """ A whole invitation link, from the operator's own machine: the node's code, - then the hub's ticket bound to `email`, then the link. + then the hub's ticket, then the link. `email` is optional and only labels + the link in the owner's list. In that order because the ticket names the code's handle. A ticket the hub refuses takes the code back with it — a code nobody can reach the node with @@ -211,8 +210,8 @@ async def create_link_invitation(state: dict, group_id: str, email: str, *, the operator sends the link. """ email = (email or "").strip() - if "@" not in email: - raise OpError("An invitation link is bound to an e-mail address", status=422) + if email and "@" not in email: + raise OpError("Not an e-mail address", status=422) hub = _hub(state) sk_node = state.get("sk_node") if sk_node is None: diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index 80bf16d..8144373 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -54,6 +54,10 @@ CODE_LEN = 8 # 8 × 5 bits = 40 bits of entropy # node-wide lockout. DEFAULT_INVITE_TTL = 7 * 24 * 3600 # seconds — member invitations DEFAULT_PAIR_TTL = 24 * 3600 # seconds — operator pairing +# An invitation link is a bearer secret that may travel through any messaging +# service, so its lifetime is fixed rather than the operator's setting: the +# hub clamps its ticket to the same seven days. +LINK_INVITE_TTL = 7 * 24 * 3600 # A device-add code is read off one screen and typed into another, in one # sitting. An hour is comfort, not security: the code is bound to the requesting # keys by its hash, so a longer window widens nothing an attacker can use. @@ -1109,7 +1113,7 @@ class Roster: return code async def create_link_invite( - self, group_id: str, created_by: str, ttl: int = DEFAULT_INVITE_TTL, + self, group_id: str, created_by: str, ttl: int = LINK_INVITE_TTL, ) -> tuple[str, str, str]: """ Issue a code bound to no account: `(code, invite_id, expires_at)`. diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index eabe97a..090eb21 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -325,7 +325,7 @@ def create_ui_app(state: dict) -> FastAPI: # Both halves, node and hub, for the CLI: an operator at the machine gets a # whole link, not a code without a ticket. @app.post("/api/groups/{group_id}/invite-links") - async def create_link_invite(group_id: str, email: str): + async def create_link_invite(group_id: str, email: str = ""): return await _op(lambda: ops.create_link_invitation(state, group_id, email)) @app.delete("/api/groups/{group_id}/invite-links/{invite_id}") diff --git a/packages/meshbay-node/tests/golden/cli.json b/packages/meshbay-node/tests/golden/cli.json index 5ddd7fd..8db5abd 100644 --- a/packages/meshbay-node/tests/golden/cli.json +++ b/packages/meshbay-node/tests/golden/cli.json @@ -4,7 +4,7 @@ "asked": [], "exit": 0, "stderr": "", - "stdout": "usage: meshbay-node [-h] [--hub-url HUB_URL] [--username USERNAME] [--dir DIR] [--yes]\n [--config CONFIG] [--group GROUP] [--link] [--writable] [--no-writable]\n [--removable] [--no-removable] [--name NAME]\n [--log-level {DEBUG,INFO,WARNING,ERROR}]\n [{init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}]\n [subcommand] [target] [value]\n\nMeshBay Node daemon\n\npositional arguments:\n {init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}\n init: provision config + keystore | reset: erase all node state | status:\n node state and keys | operator pair: pair a browser with this node |\n member list|invite|cancel|revoke|unpin | group list|add|remove | root\n list|add|remove|set|eject|plug | gek init|rotate | file list|rm | video\n rematch: re-resolve TMDB matches for a group's videos | chat\n status|rotate|encrypt-history|prune | denylist show|clear | stun\n list|add|remove|reset | transfers show|set|max-size|per-member: live\n transfer slots, the node-wide caps, the largest single upload, and how\n many one member may run at once in a group | reload: re-read node.toml\n (hot; systemd or the loopback API) | restart-daemon: restart the node\n (systemd unit, the Windows autostart launcher, or the service task,\n whichever applies) | autostart install|remove|start|stop|status (Windows:\n run meshbay-node at each sign-in, no admin) | service\n install|remove|start|stop|status (Windows: run at boot, before sign-in,\n needs admin once to install) | calibrate-argon2: benchmark\n subcommand 'pair' for operator; list|invite|revoke|unpin for member; list|add|remove\n for group; list|add|remove|set|eject|plug for root; init|rotate for gek;\n list|rm for file; rematch for video; show|clear for denylist;\n list|add|remove|reset for stun; show|set|max-size|per-member for\n transfers; install|remove|start|stop|status for autostart and for service\n target username for member invite|revoke|unpin (an e-mail address with --link, a\n link id for member cancel); group name for group add; file id for file rm;\n identifier for denylist clear; download cap for transfers set; size in GB\n for transfers max-size\n value the second value where a verb takes two: the upload cap for transfers set\n\noptions:\n -h, --help show this help message and exit\n --hub-url HUB_URL hub URL, for init (e.g. https://meshbay.org)\n --username USERNAME hub username, for init\n --dir DIR shared directory, for group add\n --yes skip the confirmation for destructive commands\n --config CONFIG Config file path\n --group GROUP group id (optional if only one is configured)\n --link member invite: an invitation link for this e-mail address, for someone who\n may have no account yet\n --writable root accepts member uploads (root add/set)\n --no-writable root is read-only (root add/set, group add)\n --removable mark root as removable (root set/add)\n --no-removable mark root as not removable (root set)\n --name NAME root name (root add; defaults to directory basename)\n --log-level {DEBUG,INFO,WARNING,ERROR}\n", + "stdout": "usage: meshbay-node [-h] [--hub-url HUB_URL] [--username USERNAME] [--dir DIR] [--yes]\n [--config CONFIG] [--group GROUP] [--link] [--writable] [--no-writable]\n [--removable] [--no-removable] [--name NAME]\n [--log-level {DEBUG,INFO,WARNING,ERROR}]\n [{init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}]\n [subcommand] [target] [value]\n\nMeshBay Node daemon\n\npositional arguments:\n {init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}\n init: provision config + keystore | reset: erase all node state | status:\n node state and keys | operator pair: pair a browser with this node |\n member list|invite|cancel|revoke|unpin | group list|add|remove | root\n list|add|remove|set|eject|plug | gek init|rotate | file list|rm | video\n rematch: re-resolve TMDB matches for a group's videos | chat\n status|rotate|encrypt-history|prune | denylist show|clear | stun\n list|add|remove|reset | transfers show|set|max-size|per-member: live\n transfer slots, the node-wide caps, the largest single upload, and how\n many one member may run at once in a group | reload: re-read node.toml\n (hot; systemd or the loopback API) | restart-daemon: restart the node\n (systemd unit, the Windows autostart launcher, or the service task,\n whichever applies) | autostart install|remove|start|stop|status (Windows:\n run meshbay-node at each sign-in, no admin) | service\n install|remove|start|stop|status (Windows: run at boot, before sign-in,\n needs admin once to install) | calibrate-argon2: benchmark\n subcommand 'pair' for operator; list|invite|revoke|unpin for member; list|add|remove\n for group; list|add|remove|set|eject|plug for root; init|rotate for gek;\n list|rm for file; rematch for video; show|clear for denylist;\n list|add|remove|reset for stun; show|set|max-size|per-member for\n transfers; install|remove|start|stop|status for autostart and for service\n target username for member invite|revoke|unpin (an optional e-mail label with\n --link, a link id for member cancel); group name for group add; file id\n for file rm; identifier for denylist clear; download cap for transfers\n set; size in GB for transfers max-size\n value the second value where a verb takes two: the upload cap for transfers set\n\noptions:\n -h, --help show this help message and exit\n --hub-url HUB_URL hub URL, for init (e.g. https://meshbay.org)\n --username USERNAME hub username, for init\n --dir DIR shared directory, for group add\n --yes skip the confirmation for destructive commands\n --config CONFIG Config file path\n --group GROUP group id (optional if only one is configured)\n --link member invite: an invitation link, for someone who may have no account yet\n (valid 7 days, single use)\n --writable root accepts member uploads (root add/set)\n --no-writable root is read-only (root add/set, group add)\n --removable mark root as removable (root set/add)\n --no-removable mark root as not removable (root set)\n --name NAME root name (root add; defaults to directory basename)\n --log-level {DEBUG,INFO,WARNING,ERROR}\n", "systemctl": [] }, "autostart no-such-sub": { @@ -348,7 +348,7 @@ "asked": [], "exit": 0, "stderr": "", - "stdout": "INVITATION LINK https://example.invalid/#/invite?v=1\nvalid until \ncancel with meshbay-node member cancel abababababababababababababababab\n\nSend it to bob@example.test yourself. It works once, and only for an\naccount registered with that address: they open it, create their\naccount or sign in, and land in the group without typing a code.\n", + "stdout": "INVITATION LINK https://example.invalid/#/invite?v=1\nvalid until \ncancel with meshbay-node member cancel abababababababababababababababab\n\nSend it yourself, by any messaging app. It works once, for seven\ndays, for whoever opens it first: they create their account or\nsign in, and land in the group without typing a code.\n", "systemctl": [] }, "member list": { diff --git a/packages/meshbay-node/tests/golden/dispatch.json b/packages/meshbay-node/tests/golden/dispatch.json index 98911f5..5d15057 100644 --- a/packages/meshbay-node/tests/golden/dispatch.json +++ b/packages/meshbay-node/tests/golden/dispatch.json @@ -2228,7 +2228,7 @@ "subject": "x", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -2551,7 +2551,7 @@ "subject": "gggggggggggggggggggggggggggggggg", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -2570,7 +2570,7 @@ "subject": "['x']", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -2589,7 +2589,7 @@ "subject": "7", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -2608,7 +2608,7 @@ "subject": "x", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -2878,7 +2878,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -2892,7 +2892,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -2906,7 +2906,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -2920,7 +2920,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -2934,7 +2934,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -2948,7 +2948,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -2962,7 +2962,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -2976,7 +2976,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -8855,7 +8855,7 @@ "subject": "gggggggggggggggggggggggggggggggg", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -8874,7 +8874,7 @@ "subject": "['x']", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -8893,7 +8893,7 @@ "subject": "7", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -8912,7 +8912,7 @@ "subject": "x", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -9247,7 +9247,7 @@ "subject": "['x']", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -9266,7 +9266,7 @@ "subject": "7", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -9285,7 +9285,7 @@ "subject": "x", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -9620,7 +9620,7 @@ "subject": "['x']", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -9639,7 +9639,7 @@ "subject": "7", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -9658,7 +9658,7 @@ "subject": "x", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -11961,7 +11961,7 @@ "subject": "link:gggggggggggggggggggggggggggggggg", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -14060,7 +14060,7 @@ "subject": "['x']", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -14079,7 +14079,7 @@ "subject": "7", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -14098,7 +14098,7 @@ "subject": "x", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -14433,7 +14433,7 @@ "subject": "['x']", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -14452,7 +14452,7 @@ "subject": "7", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -14471,7 +14471,7 @@ "subject": "x", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -16532,7 +16532,7 @@ "req_id": 4242, "token": null, "type": "pong", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -16547,7 +16547,7 @@ "x" ], "type": "pong", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -16560,7 +16560,7 @@ "req_id": 4242, "token": 7, "type": "pong", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -16573,7 +16573,7 @@ "req_id": 4242, "token": "x", "type": "pong", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -16586,7 +16586,7 @@ "req_id": 4242, "token": null, "type": "pong", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -16601,7 +16601,7 @@ "x" ], "type": "pong", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -16614,7 +16614,7 @@ "req_id": 4242, "token": 7, "type": "pong", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -16627,7 +16627,7 @@ "req_id": 4242, "token": "x", "type": "pong", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -16962,7 +16962,7 @@ "subject": "['x']", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -16981,7 +16981,7 @@ "subject": "7", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -17000,7 +17000,7 @@ "subject": "x", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -17335,7 +17335,7 @@ "subject": "['x']", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -17354,7 +17354,7 @@ "subject": "7", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -17373,7 +17373,7 @@ "subject": "x", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -17708,7 +17708,7 @@ "subject": "['x']", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -17727,7 +17727,7 @@ "subject": "7", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -17746,7 +17746,7 @@ "subject": "x", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -18081,7 +18081,7 @@ "subject": "['x']", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -18100,7 +18100,7 @@ "subject": "7", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -18119,7 +18119,7 @@ "subject": "x", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -18454,7 +18454,7 @@ "subject": "['x']:rw=on,rem=on", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -18473,7 +18473,7 @@ "subject": "7:rw=on,rem=on", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -18492,7 +18492,7 @@ "subject": "x:rw=on,rem=on", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -21439,7 +21439,7 @@ "subject": "custom_token=no,language=default", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -21482,7 +21482,7 @@ "subject": "custom_token=yes,language=x", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] @@ -23369,7 +23369,7 @@ "subject": "d=7,u=7", "ts": "<volatile>", "type": "admin_challenge", - "v": "3.4" + "v": "4.0" } ], "spawned": [] diff --git a/packages/meshbay-node/tests/test_hub_client.py b/packages/meshbay-node/tests/test_hub_client.py index e733610..95c93c7 100644 --- a/packages/meshbay-node/tests/test_hub_client.py +++ b/packages/meshbay-node/tests/test_hub_client.py @@ -10,6 +10,7 @@ import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from meshbay_common.tokens import HUB_API_AUD from meshbay_node.hub_client import HubClient, HubConfig from meshbay_node.keystore import NodeKeys @@ -52,7 +53,7 @@ def make_node_token(sk_pem, user_id, pk_user_b64, hub_id="fake-hub", ttl=3600): return jwt.encode({ "iss": hub_id, "sub": user_id, "pk_user": pk_user_b64, "hub_id": hub_id, "jti": "test-jti", "scope": "node", - "iat": now, "exp": now + ttl, + "iat": now, "exp": now + ttl, "aud": HUB_API_AUD, }, sk_pem, algorithm="EdDSA") @@ -89,7 +90,7 @@ async def test_login_rejects_missing_jti(hub_keys, node_keys, hub_config): sk_hub, sk_hub_pem, pk_hub_pem = hub_keys bad_token = jwt.encode({ "iss": "fake-hub", "sub": "uid", "pk_user": node_keys.pk_ed25519_b64, - "hub_id": "fake-hub", "scope": "node", + "hub_id": "fake-hub", "scope": "node", "aud": HUB_API_AUD, "iat": int(time.time()), "exp": int(time.time()) + 3600, }, sk_hub_pem, algorithm="EdDSA") diff --git a/packages/meshbay-node/tests/test_login_retry_is_resilient.py b/packages/meshbay-node/tests/test_login_retry_is_resilient.py new file mode 100644 index 0000000..e37a413 --- /dev/null +++ b/packages/meshbay-node/tests/test_login_retry_is_resilient.py @@ -0,0 +1,109 @@ +"""A transient hub state on node sign-in must not crash the daemon. + +`_login_with_retry` retries a 401 (the node key is not linked yet — a human has +to link it, and the daemon must stay alive so its key can be read). It used to +`raise` on every other status, so a **429** (the daemon's own retries hitting +the sign-in rate limit) or a **502/503** (the hub restarting during a deploy) +killed the process — systemd then crash-looped it, which is what "impossible de +démarrer le node" looked like after a reset left the node with a fresh, unlinked +key. Those transient statuses are now retried with a back-off that respects +`Retry-After`. +""" + +import asyncio + +import httpx +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_node.config import Config, GroupConfig, HubConfig, KeystoreConfig, NodeConfig +from meshbay_node.daemon import NodeDaemon + + +def _daemon(tmp_path): + cfg = Config( + hub=HubConfig(url="http://localhost:9999", username="testuser"), + node=NodeConfig(quic_port=29011, ui_port=29012), + groups=[], + keystore=KeystoreConfig(path=tmp_path / "keystore.enc"), + data_dir=tmp_path / "data", + ) + return NodeDaemon(cfg) + + +def _http_error(status: int, headers: dict | None = None) -> httpx.HTTPStatusError: + req = httpx.Request("POST", "http://localhost:9999/v1/nodes/auth") + resp = httpx.Response(status, headers=headers or {}, request=req) + return httpx.HTTPStatusError(f"{status}", request=req, response=resp) + + +class _Hub: + """A hub whose `startup` raises the given sequence, then returns a session.""" + def __init__(self, seq): + self._seq = list(seq) + self.calls = 0 + + async def startup(self, endpoint_hint=None): + self.calls += 1 + item = self._seq.pop(0) + if isinstance(item, Exception): + raise item + return item + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [429, 500, 502, 503, 504]) +async def test_a_transient_status_is_retried_not_fatal(tmp_path, status, monkeypatch): + slept = [] + + async def _sleep(d): + slept.append(d) + monkeypatch.setattr(asyncio, "sleep", _sleep) + + daemon = _daemon(tmp_path) + session = object() + hub = _Hub([_http_error(status), session]) # transient, then success + got = await daemon._login_with_retry(hub) + assert got is session # it recovered instead of crashing + assert hub.calls == 2 # retried once + assert slept # it backed off + + +@pytest.mark.asyncio +async def test_retry_after_is_respected(tmp_path, monkeypatch): + slept = [] + + async def _sleep(d): + slept.append(d) + monkeypatch.setattr(asyncio, "sleep", _sleep) + + daemon = _daemon(tmp_path) + hub = _Hub([_http_error(429, {"Retry-After": "42"}), object()]) + await daemon._login_with_retry(hub) + assert 42 in slept + + +@pytest.mark.asyncio +async def test_a_401_still_retries_and_stays_alive(tmp_path, monkeypatch): + async def _sleep(d): + pass + monkeypatch.setattr(asyncio, "sleep", _sleep) + + daemon = _daemon(tmp_path) + session = object() + hub = _Hub([_http_error(401), session]) + got = await daemon._login_with_retry(hub) + assert got is session + assert daemon._state.get("status") in ("waiting_for_node_key", "waiting_for_account") + + +@pytest.mark.asyncio +async def test_a_genuine_client_error_still_raises(tmp_path, monkeypatch): + """A 400/422 is a bug, not a transient state — it must not be swallowed.""" + async def _sleep(d): + pass + monkeypatch.setattr(asyncio, "sleep", _sleep) + + daemon = _daemon(tmp_path) + hub = _Hub([_http_error(400), object()]) + with pytest.raises(httpx.HTTPStatusError): + await daemon._login_with_retry(hub) diff --git a/packages/meshbay-node/tests/test_multi_group.py b/packages/meshbay-node/tests/test_multi_group.py index b75bad0..01b2abc 100644 --- a/packages/meshbay-node/tests/test_multi_group.py +++ b/packages/meshbay-node/tests/test_multi_group.py @@ -7,6 +7,7 @@ Verifies that: - A user in both groups can access both """ +from meshbay_common.tokens import MNP_AUD import time import jwt @@ -63,7 +64,7 @@ def make_jwt(sk_hub, pk_node_b64, groups, user_id="user-001", ttl=3600): "iss": "test-hub", "sub": user_id, "pk_user": pk_node_b64, "hub_id": "test-hub", "jti": "test-jti", "iat": now, "exp": now + ttl, - "groups": groups, + "groups": groups, "scope": "user", "aud": MNP_AUD, }, sk_pem, algorithm="EdDSA") diff --git a/packages/meshbay-node/tests/test_ops_links.py b/packages/meshbay-node/tests/test_ops_links.py index 00258d3..9ad01d2 100644 --- a/packages/meshbay-node/tests/test_ops_links.py +++ b/packages/meshbay-node/tests/test_ops_links.py @@ -1,13 +1,15 @@ """ Invitation links from the operator's own machine (`member invite --link`). -The CLI makes both halves itself: the node's code, then the hub's ticket bound -to an address, then the link. What can go wrong is a half left behind — a code -the hub never ticketed, occupying one of the group's places, or a ticket whose -code was cancelled — and the CLI asking the hub to mail, which it may not. +The CLI makes both halves itself: the node's code, then the hub's ticket, then +the link. The address is optional and only labels the link. What can go wrong +is a half left behind — a code the hub never ticketed, occupying one of the +group's places, or a ticket whose code was cancelled — and the CLI asking the +hub to mail, which it may not. """ import re +from datetime import UTC, datetime, timedelta import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey @@ -76,12 +78,30 @@ async def test_a_refused_ticket_takes_the_code_back(roster): assert [i for i in await roster.list_invites() if i["kind"] == KIND_LINK] == [] -async def test_an_address_is_required(roster): +async def test_an_address_is_optional_but_must_be_one_when_given(roster): + hub = _Hub() + out = await ops.create_link_invitation(_state(roster, hub), GROUP) + assert LINK.match(out["link"]), out["link"] + assert hub.created == [(GROUP, "", out["invite_id"])] with pytest.raises(ops.OpError) as refused: - await ops.create_link_invitation(_state(roster, _Hub()), GROUP, "alice") + await ops.create_link_invitation(_state(roster, hub), GROUP, "alice") assert refused.value.status == 422 +async def test_a_link_lives_seven_days_whatever_the_invite_setting(roster): + class _Node: + invite_ttl_hours = 24 * 30 + + class _Config: + node = _Node() + + state = _state(roster, _Hub()) + state["config"] = _Config() + out = await ops.create_link_invite(state, GROUP) + left = datetime.fromisoformat(out["expires_at"]) - datetime.now(UTC) + assert timedelta(days=6, hours=23) < left <= timedelta(days=7) + + async def test_cancelling_takes_back_both_halves(roster): hub = _Hub() state = _state(roster, hub) diff --git a/packages/meshbay-node/tests/test_quic_transport.py b/packages/meshbay-node/tests/test_quic_transport.py index 8eb0622..a32402a 100644 --- a/packages/meshbay-node/tests/test_quic_transport.py +++ b/packages/meshbay-node/tests/test_quic_transport.py @@ -3,6 +3,7 @@ Integration test: QuicChunkServer ↔ QuicChunkClient over QUIC/UDP loopback. Same structure as test_transport.py but uses QUIC instead of TCP+TLS. """ +from meshbay_common.tokens import MNP_AUD import asyncio import os import time @@ -51,7 +52,8 @@ def make_jwt(sk_hub, pk_node_b64, ttl=3600, groups=None): "pk_user": pk_node_b64, "hub_id": "test-hub", "jti": "test-jti", "iat": now, "exp": now + ttl, # group_id is mandatory (M1), so default tokens are members of "g". - "groups": groups if groups is not None else ["g"], + "groups": groups if groups is not None else ["g"], "scope": "user", + "aud": MNP_AUD, }, sk_pem, algorithm="EdDSA") diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py index 0245c4e..990b1da 100644 --- a/packages/meshbay-node/tests/test_webrtc_transport.py +++ b/packages/meshbay-node/tests/test_webrtc_transport.py @@ -7,6 +7,7 @@ for MNP protocol exchange (handshake, index_sync, file_request, file_chunk). Uses local loopback (no STUN/ICE needed for localhost). """ +from meshbay_common.tokens import MNP_AUD import asyncio import base64 import hashlib @@ -115,6 +116,7 @@ def _make_jwt(sk_hub, groups=None, pk_user="test"): "pk_user": pk_user, "hub_id": "test-hub", "jti": "test-jti-webrtc", "iat": now, "exp": now + 3600, "groups": groups if groups is not None else [TEST_GROUP], + "scope": "user", "aud": MNP_AUD, }, sk_pem, algorithm="EdDSA") @@ -218,7 +220,7 @@ def _token(sk_hub, jwt_sub, peer_id, group_id, pk_user="test"): "iss": "test-hub", "sub": jwt_sub, "pk_user": pk_user, "hub_id": "test-hub", "jti": f"jti-{peer_id}", "iat": now, "exp": now + 3600, - "groups": [group_id], "scope": "user", + "groups": [group_id], "scope": "user", "aud": MNP_AUD, }, sk_h_pem, algorithm="EdDSA") |