diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/auth.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/auth.py | 63 |
1 files changed, 47 insertions, 16 deletions
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") |