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 /packages/meshbay-hub/src | |
| 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>
Diffstat (limited to 'packages/meshbay-hub/src')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/nodes.py | 29 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/auth.py | 63 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 31 |
3 files changed, 104 insertions, 19 deletions
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), }); |