aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-25 14:53:42 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-25 17:24:16 +0200
commitbce962c39fcb2d124506e33f77c3ca9082f145dd (patch)
tree54cce4900f27f639bc8dec63f5e62d747ccdfe07 /packages
parent6b9b5394c5ec01c5de01b7bf23bc161792f38278 (diff)
downloadmeshbay-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>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-common/src/meshbay_common/handshake.py10
-rw-r--r--packages/meshbay-common/src/meshbay_common/tokens.py28
-rw-r--r--packages/meshbay-common/tests/test_handshake.py30
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py63
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js31
-rw-r--r--packages/meshbay-hub/tests/test_availability_between_members.py5
-rw-r--r--packages/meshbay-hub/tests/test_hub_api.py9
-rw-r--r--packages/meshbay-hub/tests/test_mnp_token.py71
-rw-r--r--packages/meshbay-hub/tests/test_token_hardening.py22
-rw-r--r--packages/meshbay-node/src/meshbay_node/hub_client.py8
-rw-r--r--packages/meshbay-node/tests/test_hub_client.py5
-rw-r--r--packages/meshbay-node/tests/test_multi_group.py3
-rw-r--r--packages/meshbay-node/tests/test_quic_transport.py4
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py4
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")