diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-26 02:03:50 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-26 02:03:50 +0200 |
| commit | 2ccb6653e8841d4d6f3ab933f84746cce4e2fe2b (patch) | |
| tree | 04abaaf5df99f7f8ab167964c4a7653ee8f7a00a | |
| parent | 2657ffd62ece8b8461d55b398139503ec504c3c6 (diff) | |
| download | meshbay-2ccb6653e8841d4d6f3ab933f84746cce4e2fe2b.tar.gz | |
The audience split stopped a member's node credential from opening the hub API.
It did not stop the credential being *replayed to another node*: the MNP token
carried the member's whole group set and named no node, so a token handed to
node A's operator could be presented to node B the member also belongs to. That
does not read content on B — the handshake still requires proving node B's group
key, which the operator lacks — but it reaches B's pre-proof window and fetches
the member's *encrypted* keypair bundle for B (offline-attackable, bounded,
audited): a disclosure §2.4 says should not follow from hosting a member on A.
The token now names the node it is minted for (a `node` claim = that node's
Ed25519 key), and authorize_token refuses one that names a different key. The
client already knows the target node's key (from /v1/groups/{id}/nodes) and asks
for a token bound to it: POST /v1/nodes/mnp-token takes node_pk, and
transport.connect threads it (group-page, the connection pool and rewrap pass
n.pk_node; reconnect preserves it). A token that names no node is still
accepted, because the hub only mints one for the authenticated requester, so an
unbound token grants nothing across accounts — which also keeps non-binding
callers working with no churn.
Done before deploy, so it folds into the MNP 4.0 flag day rather than needing
its own. Docs: §5.2, register E10, MESHBAY_NODE_PROTOCOL.md §6.3.
test_handshake.py and test_mnp_token.py hold the binding (a token for node A is
refused by node B, accepted by node A; an unbound token still works); red
before, green after. common/node/hub suites green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
13 files changed, 114 insertions, 14 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 394dfb5..6e3513d 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -1006,6 +1006,18 @@ a film already playing — a reconnect fetches a fresh one. `meshbay_common/toke holds the two audience strings, shared by the hub that issues and the node that verifies. +**The MNP token also names the node it is for** (`node` claim = that node's +Ed25519 key), and the node refuses one that names a different key. The client +knows the target node's key before it connects and asks for a token bound to it. +This closes the one thing the audience split alone did not: a member's token, +handed to the operator of node A, could otherwise be **replayed to node B** the +member also belongs to — not to read content (that still needs node B's group +key in the proof, which the operator lacks) but to reach node B's **pre-proof +window** and fetch the member's *encrypted* keypair bundle for node B. Binding +the token to node A makes node B refuse it outright. A token that names no node +is still accepted, because the hub only ever mints one for the authenticated +requester, so it grants nothing across accounts. + **Authorization rules:** - `group_id` is **mandatory**. Omitting it once skipped the membership check and @@ -3237,7 +3249,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) | +| **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. The token also **names the node it is for** (`node` claim), so a token captured by one node's operator cannot be replayed to another node the member uses — not even to reach its pre-proof window. Closed by the audience split, the node binding, 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) | diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md index b3cd6eb..8de731e 100644 --- a/docs/MESHBAY_NODE_PROTOCOL.md +++ b/docs/MESHBAY_NODE_PROTOCOL.md @@ -463,6 +463,7 @@ absent. `verify_proof` compares with `hmac.compare_digest`. |---|---|---| | 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` | +| `node` claim, when set, equals this node's key (MNP 4.0) | `Token is not for this node`, code `wrong_node` | the token names the node it was minted for, so one captured by node A's operator cannot be replayed to node B (E10). A token naming no node is accepted — the hub mints an unbound one only for the requester | | `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 | diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py index dedfeb1..992fe8a 100644 --- a/packages/meshbay-common/src/meshbay_common/handshake.py +++ b/packages/meshbay-common/src/meshbay_common/handshake.py @@ -269,6 +269,7 @@ def authorize_token( hosted_groups: Any | None = None, denylist: DenylistLike | None = None, require_scope: str | None = "user", + node_pk_b64: str | None = None, ) -> AuthorizedPeer: """ Everything decided from the JWT, before any proof is exchanged. @@ -276,6 +277,13 @@ def authorize_token( Raises HandshakeError with a peer-safe message. Deliberately strict about `group_id`: it used to be optional, and omitting it skipped the membership check entirely and fell back to the node's first group (M1). + + `node_pk_b64` binds the token to **this** node (E10). The MNP token names + the node it was minted for (`node` claim), so a member's token captured by + the operator of one node cannot be replayed to another node the member also + belongs to — not even to reach the pre-proof window there. A node always + passes its own key; a caller that passes `None` (a unit test not exercising + this) skips the check. """ try: decoded = jwt.decode(token, hub_pk_pem, algorithms=["EdDSA"], @@ -294,6 +302,17 @@ def authorize_token( if require_scope is not None and decoded.get("scope", "user") != require_scope: raise HandshakeError("Wrong token scope") + # The token names the node it was minted for (`node` claim). A member's real + # client always binds it to the node it is reaching, so a token captured by + # that node's operator and replayed to a *second* node the member also uses + # is refused here — before the pre-proof window can serve anything (E10). + # A token that names no node is not refused: the hub only ever mints one for + # the authenticated requester, so an unbound token grants nothing across + # accounts, and this keeps older or non-binding callers working. + node_claim = decoded.get("node", "") + if node_pk_b64 is not None and node_claim and node_claim != node_pk_b64: + raise HandshakeError("Token is not for this node", code="wrong_node") + user_id = decoded.get("sub", "") jti = decoded.get("jti", "") if not user_id: diff --git a/packages/meshbay-common/tests/test_handshake.py b/packages/meshbay-common/tests/test_handshake.py index 8f385bb..f42d597 100644 --- a/packages/meshbay-common/tests/test_handshake.py +++ b/packages/meshbay-common/tests/test_handshake.py @@ -120,6 +120,27 @@ def test_a_token_with_no_audience_is_refused(hub_key): authorize_token(no_aud, pk_pem, group_id=GROUP) +def test_a_token_bound_to_another_node_is_refused(hub_key): + """E10: a token names the node it is for. A member's token captured by one + node's operator cannot be replayed to a second node the member also uses.""" + sk_pem, pk_pem = hub_key + token_for_A = _token(sk_pem, node="node-A-pk") + # Node B (its own key is 'node-B-pk') refuses it. + with pytest.raises(HandshakeError, match="this node"): + authorize_token(token_for_A, pk_pem, group_id=GROUP, node_pk_b64="node-B-pk") + # Node A accepts it. + peer = authorize_token(token_for_A, pk_pem, group_id=GROUP, node_pk_b64="node-A-pk") + assert peer.user_id == "user-1" + + +def test_a_token_naming_no_node_is_still_accepted(hub_key): + """Lenient by design: the hub mints an unbound token only for the requester, + so it grants nothing across accounts, and older callers keep working.""" + sk_pem, pk_pem = hub_key + peer = authorize_token(_token(sk_pem), pk_pem, group_id=GROUP, node_pk_b64="node-B-pk") + assert peer.group_id == GROUP + + def test_unhosted_group_refused(hub_key): sk_pem, pk_pem = hub_key with pytest.raises(HandshakeError, match="not hosted"): diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py index 6205180..403f450 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py @@ -21,10 +21,18 @@ from meshbay_hub.db.models import GroupMember, IPLog, Node, User router = APIRouter(prefix="/v1/nodes", tags=["nodes"]) +class MnpTokenRequest(BaseModel): + # The base64 Ed25519 key of the node this token is for. The token is bound + # to it (E10), so it cannot be replayed to another node. The client knows it + # from `/v1/groups/{id}/nodes` before it connects. + node_pk: str = "" + + @router.post("/mnp-token") @limiter.limit("60/minute") async def mnp_token( request: Request, + body: MnpTokenRequest | None = None, current_user: User = Depends(require_user_scope), db: AsyncSession = Depends(get_db), ): @@ -32,16 +40,22 @@ async def mnp_token( 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. + membership, `aud=MNP_AUD` and the target node's key, so it authorises the + member to **that** node only and is refused by the hub API and by any other + node. 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. + + The hub does not verify the node key it is handed — binding the token to it + only *restricts* the token to whatever node holds that key, which is the one + the client is connecting to; a wrong key yields a token no node will accept. """ 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), + "mnp_token": issue_mnp_token(current_user.id, groups=group_ids, + node_pk=(body.node_pk if body else "")), "expires_in": 900, } diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py index e182ba0..0d0fd95 100644 --- a/packages/meshbay-hub/src/meshbay_hub/auth.py +++ b/packages/meshbay-hub/src/meshbay_hub/auth.py @@ -241,7 +241,7 @@ def issue_access_token( def issue_mnp_token(user_id: str, groups: list[str] | None = None, - ttl: int = 900) -> str: + node_pk: 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 @@ -249,6 +249,11 @@ def issue_mnp_token(user_id: str, groups: list[str] | None = None, 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. + + `node` names the node this token is for (its base64 Ed25519 key), so it + cannot be replayed to another node the member also belongs to — the node + checks it in `authorize_token` (E10). The client knows the target node's key + before it connects and asks for a token bound to it. """ if _hub_sk_pem is None: raise RuntimeError("Hub keypair not loaded") @@ -260,6 +265,7 @@ def issue_mnp_token(user_id: str, groups: list[str] | None = None, "iat": now, "exp": now + ttl, "groups": groups or [], + "node": node_pk or "", "scope": "user", "aud": MNP_AUD, } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js b/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js index aac8225..15d352a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js @@ -78,7 +78,7 @@ async function connectToGroup(hubBase, groupId, token, bundleKey, username, user const ack = await Promise.race([ transport.connect( n.node_id, live, groupId, null, null, bundleKey, - username, userId, null), + username, userId, null, undefined, undefined, n.pk_node), new Promise((_, reject) => { const giveUp = () => reject(new Error('Connection timeout')); stallTimer = setTimeout(giveUp, SEARCH_STALL_MS); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 2ee6e05..eb012c3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -433,7 +433,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, try { ack = await transport.connect( n.node_id, live, groupId, null, sessionKeys, session.bundleKey, - username, userId, joinCode, session.recoveryKey, joinNodePk); + username, userId, joinCode, session.recoveryKey, joinNodePk, n.pk_node); break; } catch (e) { lastErr = e; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js index 6ae51d1..18ad9d4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js @@ -110,7 +110,7 @@ async function rewrapAllNodes(o) { try { await _acWithTimeout( tp.connect(n.node_id, o.token, g.id, null, null, oldKey, - o.username, o.userId, null, recoveryKey), + o.username, o.userId, null, recoveryKey, undefined, n.pk_node), 30000, 'connect'); if (tp.newNodeBundle) { // No identity existed on this node — connect just minted one under diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index f3683eb..546d01b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -663,21 +663,21 @@ class MeshBayTransport { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this._accessToken}`, }, - body: JSON.stringify({}), + body: JSON.stringify({ node_pk: this._nodePkTarget || '' }), }); 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) { + userId, joinCode, recoveryKey, joinNodePk, nodePk) { // Remembered for _reconnectLoop, which calls connect() again with these // same values (plus a freshly-fetched token and the identity connect() // itself settles on below) after the WebRTC connection is declared // "failed" — see the pc.onconnectionstatechange handler further down. this._connectArgs = { nodeId, groupId, gekRaw, bundleKey, username, userId, joinCode, recoveryKey, - joinNodePk, + joinNodePk, nodePk, }; this._lastToken = jwtToken; // The constructor sets this once from whatever token the caller had at @@ -695,6 +695,10 @@ class MeshBayTransport { this._recoveryKey = recoveryKey || null; this._username = username || null; this._userId = userId || null; + // The key of the node we mean to reach, from the hub's node list. The MNP + // token is bound to it (E10) so it cannot be replayed to another node. It is + // the *expected* key; `this.nodePk` below is the one the node then proves. + this._nodePkTarget = nodePk || ''; // The group this connection is for. Kept on the instance because the // handshake is not the only thing that needs it any more: device_hello and // the chat envelope both bind to it, and both run outside connect()'s scope. @@ -1321,7 +1325,7 @@ class MeshBayTransport { ack = await this.connect(args.nodeId, token, args.groupId, args.gekRaw, this._sessionKeys, args.bundleKey, args.username, args.userId, args.joinCode, undefined, - args.joinNodePk); + args.joinNodePk, args.nodePk); } finally { this._inReconnectAttempt = false; } diff --git a/packages/meshbay-hub/tests/test_mnp_token.py b/packages/meshbay-hub/tests/test_mnp_token.py index 7b483ff..3aa3115 100644 --- a/packages/meshbay-hub/tests/test_mnp_token.py +++ b/packages/meshbay-hub/tests/test_mnp_token.py @@ -69,3 +69,24 @@ async def test_a_session_token_is_refused_by_a_node_but_the_mnp_token_is_not(cli # The MNP token authorises the member to the node. peer = authorize_token(mnp, pk, group_id=gid) assert peer.group_id == gid + + +@pytest.mark.asyncio +async def test_the_mnp_token_is_bound_to_the_node_it_names(client): + """E10: a token minted for node A is refused by node B, so an operator who + captures a member's token cannot replay it to another of the member's nodes.""" + from meshbay_hub.auth import hub_public_key_pem + + tok = await _session_token(client, "mnp_bind_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"] + # A token bound to node A's key. + mnp = (await client.post("/v1/nodes/mnp-token", headers=H, + json={"node_pk": "node-A-pk"})).json()["mnp_token"] + pk = hub_public_key_pem() + # Node B refuses it; node A accepts it. + with pytest.raises(HandshakeError, match="this node"): + authorize_token(mnp, pk, group_id=gid, node_pk_b64="node-B-pk") + peer = authorize_token(mnp, pk, group_id=gid, node_pk_b64="node-A-pk") + assert peer.group_id == gid diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py index 96cd752..715448f 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py @@ -286,6 +286,7 @@ class _MNPServerProtocol(QuicConnectionProtocol): group_id=msg.get("group_id", ""), hosted_groups=self._ctx.get("groups"), denylist=self._ctx.get("denylist"), + node_pk_b64=pk_to_b64(self._ctx["sk_node"].public_key()), ) except HandshakeError as refusal: self._send(stream_id, {"type": "error", "detail": str(refusal), diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py index 87394d1..eceb2b4 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py @@ -65,6 +65,7 @@ class HandshakeMixin: group_id=group_id, hosted_groups=self._ctx.get("groups"), denylist=self._ctx.get("denylist"), + node_pk_b64=self._node_pk_b64(), ) except HandshakeError as refusal: # HandshakeError messages are authored to be peer-safe, unlike arbitrary |