aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/auth.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/auth.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py65
1 files changed, 63 insertions, 2 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py
index d038027..0d0fd95 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,17 +232,76 @@ 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,
+ 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
+ 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.
+
+ `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")
+ now = int(time.time())
+ payload = {
+ "iss": _hub_id,
+ "sub": user_id,
+ "jti": str(uuid.uuid4()),
+ "iat": now,
+ "exp": now + ttl,
+ "groups": groups or [],
+ "node": node_pk or "",
+ "scope": "user",
+ "aud": MNP_AUD,
}
return jwt.encode(payload, _hub_sk_pem, algorithm="EdDSA")
def decode_access_token(token: str) -> dict:
- """Verify and decode an access token. Raises on failure."""
+ """Verify and decode an access token. Raises on failure.
+
+ 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.
+
+ 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")
# Clock-skew tolerance (meshbay_common.handshake.JWT_LEEWAY_SECONDS): a
# client whose clock is a little fast must still be able to call the API.
- return jwt.decode(token, _hub_pk_pem, algorithms=["EdDSA"], leeway=60)
+ payload = jwt.decode(
+ token, _hub_pk_pem, algorithms=["EdDSA"], leeway=60,
+ audience=HUB_API_AUD,
+ options={"require": ["exp", "sub", "scope", "aud"]},
+ )
+ if payload.get("scope") not in ("user", "node"):
+ raise jwt.InvalidTokenError("unrecognised token scope")
+ return payload
# ── Email encryption at rest ──────────────────────────────────────────────────