diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-25 14:53:42 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-25 17:24:16 +0200 |
| commit | bce962c39fcb2d124506e33f77c3ca9082f145dd (patch) | |
| tree | 54cce4900f27f639bc8dec63f5e62d747ccdfe07 | |
| parent | 6b9b5394c5ec01c5de01b7bf23bc161792f38278 (diff) | |
| download | meshbay-bce962c39fcb2d124506e33f77c3ca9082f145dd.tar.gz | |
fix(hub): present a node-audience token in the handshake, not the hub session token
A member authenticated to a node in the MNP handshake with its hub *session*
token — scope=user, valid at the hub API for hours. A node operator is in the
threat model, so this handed them a live hub credential for the member: enough
to enumerate the member's other groups, act as them, and (before the previous
commit closed it) take the account over. The node genuinely needs a hub-signed
membership assertion, so the fix is to make that a separate credential that
opens nothing at the hub API.
Two audiences signed by the one hub key (meshbay_common/tokens.py):
- HUB_API_AUD — session tokens (login, device-auth, node-auth, refresh), used
for hub calls and signaling. decode_access_token now binds this audience, so
an MNP token cannot be replayed against the hub API.
- MNP_AUD — a short-lived token a member presents to a node and nothing else,
from POST /v1/nodes/mnp-token. authorize_token now binds this audience, so a
session token presented to a node is refused. This closes the disclosure.
The node's own self-decode (hub_client.py) reads its node token with
audience=HUB_API_AUD. The client fetches the MNP token inside transport.connect()
(and on every reconnect) using the session token, so callers are unchanged and
signaling keeps using the session token.
No regression to a long session: the MNP token is checked once, at the
handshake, before any proof — a film already playing is not re-authenticated, so
a 15-minute token does not interrupt a 4-hour film; reconnects refetch a fresh
one. Denylist and membership checks are unchanged (the MNP token carries
sub/jti/groups).
Tests: authorize_token refuses a session/no-audience token and accepts an MNP
token; the hub API refuses an MNP token; POST /v1/nodes/mnp-token is minted only
for a member's own session. Verified red-before/green-after; common, node and
hub suites green (the pre-existing test_cli_golden failure is an argparse/pytest
prog artifact unrelated to this change).
Still to do before deploy (B2): bump the MNP version and client.minimum so a
stale desktop client is told to update rather than getting a handshake refusal,
update docs/MESHBAY_DESIGN.md and MESHBAY_NODE_PROTOCOL.md, and validate against
a real node locally, then deploy hub+node+SPA atomically.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
15 files changed, 284 insertions, 38 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py index 73e0b2e..2393df1 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" @@ -274,8 +275,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/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/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py index 309738c..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,6 +232,36 @@ 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") @@ -237,22 +269,20 @@ def issue_access_token( def decode_access_token(token: str) -> dict: """Verify and decode an access token. Raises on failure. - `exp`, `sub` and `scope` are **required**, and `scope` must name one of the - two access scopes. One Ed25519 key signs four kinds of token — user access, - node access, revocation broadcasts (no `exp`, no `sub`, and returned in the - body of `POST /v1/admin/revoke` and pushed to every node), and MHP - federation tokens (`aud`, `sub=hub_id`, no `scope`). Without these - requirements a token with no `exp` was accepted, and separation between the - types rested only on which fields each consumer happened to read. Requiring - `scope` here turns a revocation or MHP token away before it can be mistaken - for a session, and requiring `exp` refuses any hub-signed token that never - expires. + 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. - A full RFC 5987 `aud` binding is deliberately not used: the node decodes its - own hub-issued token without passing `audience`, so adding `aud` would make - every already-deployed node reject its own token (`InvalidAudienceError`) — - a coordinated, node-breaking change. `scope` gives the same purpose - separation among the hub's own token types without it. + 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") @@ -260,7 +290,8 @@ def decode_access_token(token: str) -> dict: # client whose clock is a little fast must still be able to call the API. payload = jwt.decode( token, _hub_pk_pem, algorithms=["EdDSA"], leeway=60, - options={"require": ["exp", "sub", "scope"]}, + audience=HUB_API_AUD, + options={"require": ["exp", "sub", "scope", "aud"]}, ) if payload.get("scope") not in ("user", "node"): raise jwt.InvalidTokenError("unrecognised token scope") diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 202db94..27edc4e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -651,6 +651,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 +915,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_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_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_token_hardening.py b/packages/meshbay-hub/tests/test_token_hardening.py index 0835180..f381f69 100644 --- a/packages/meshbay-hub/tests/test_token_hardening.py +++ b/packages/meshbay-hub/tests/test_token_hardening.py @@ -17,6 +17,7 @@ import time import pytest +from meshbay_common.tokens import HUB_API_AUD from meshbay_hub import auth @@ -33,7 +34,7 @@ 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"}, + 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) @@ -42,7 +43,8 @@ async def test_token_without_exp_is_refused(client): @pytest.mark.asyncio async def test_expired_token_is_refused(client): import jwt - forged = jwt.encode({"sub": "u", "scope": "user", "exp": int(time.time()) - 100}, + 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) @@ -51,7 +53,7 @@ async def test_expired_token_is_refused(client): @pytest.mark.asyncio async def test_token_without_scope_is_refused(client): import jwt - forged = jwt.encode({"sub": "u", "exp": int(time.time()) + 3600}, + 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) @@ -60,13 +62,25 @@ async def test_token_without_scope_is_refused(client): @pytest.mark.asyncio async def test_unknown_scope_is_refused(client): import jwt - forged = jwt.encode({"sub": "u", "scope": "root", "exp": int(time.time()) + 3600}, + 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.""" diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index 49bb920..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" 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_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_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") |