diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/federation.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/federation.py | 48 |
1 files changed, 34 insertions, 14 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/federation.py b/packages/meshbay-hub/src/meshbay_hub/api/federation.py index b1e0e30..327102e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/federation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/federation.py @@ -33,7 +33,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from meshbay_common import MHP_VERSION from meshbay_hub import __version__, hub_settings from meshbay_hub.api.deps import require_admin -from meshbay_hub.auth import _hub_id, _hub_sk_pem, hub_public_key_pem +from meshbay_hub.auth import ( + hub_id, hub_private_key_pem, hub_public_key_pem) from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import FederatedGroup, Group, HubPeer, User @@ -45,26 +46,36 @@ router = APIRouter(prefix="/mhp", tags=["federation"]) MAX_FEDERATED_GROUPS_PER_PUSH = 500 MAX_FEDERATED_GROUPS_PER_PEER = 2000 -# Seen `jti` values for the state-changing MHP endpoints, pruned lazily. The -# sending side that would set an `aud` claim is unbuilt, so audience binding is -# not available; this stops a captured POST /mhp/directory or /mhp/revoke from -# being replayed inside the token's short TTL. GET /mhp/directory is idempotent -# and not covered. +# Seen `jti` values for the state-changing MHP endpoints, pruned lazily. This +# stops a captured POST /mhp/directory or /mhp/revoke from being replayed at +# *this* hub inside the token's short TTL; the `aud` check in +# `_verify_mhp_token` is what stops it being replayed at a different one. GET +# /mhp/directory is idempotent and not covered. +# +# The comment here used to say the sending side sets no `aud` claim, so +# audience binding was unavailable. `_issue_mhp_token` four lines below has +# always set one. _seen_mhp_jti: dict[str, float] = {} def _issue_mhp_token(target_hub_id: str) -> str: - """Issue a short-lived JWT for authenticating to a peer hub.""" + """Issue a short-lived JWT for authenticating to a peer hub. + + Read through the accessors, not from names bound at import: this module + held `_hub_sk_pem` as it was *before* startup, which is `None`, so this + function could only ever have raised — and `_hub_id` as `meshbay.org`, + whatever the instance was actually called. + """ now = int(time.time()) return jwt.encode({ - "iss": _hub_id, - "sub": _hub_id, + "iss": hub_id(), + "sub": hub_id(), "aud": target_hub_id, "jti": str(uuid.uuid4()), "iat": now, "exp": now + 300, "mhp": MHP_VERSION, - }, _hub_sk_pem, algorithm="EdDSA") + }, hub_private_key_pem(), algorithm="EdDSA") async def _verify_mhp_token( @@ -84,10 +95,19 @@ async def _verify_mhp_token( if not peer: raise PermissionError(f"Unknown hub: {sender_id!r}. Register as peer first.") + # `audience` is what makes the `aud` claim mean anything — and omitting it + # did not merely leave the binding unenforced. PyJWT refuses a token that + # carries `aud` when the caller names no audience, so **every token this + # hub issues was rejected by every hub running this code**: MHP could not + # complete a single authenticated request between two peers. Nothing + # noticed because `test_federation.py` builds its envelopes by hand, + # without an `aud`, so the suite exercised a token shape production never + # emits — the fixture was not the thing. decoded = jwt.decode( token, peer.pk_hub_pem.encode(), algorithms=["EdDSA"], - options={"require": ["exp", "iss"]}, + audience=hub_id(), + options={"require": ["exp", "iss", "aud"]}, ) if single_use: @@ -109,7 +129,7 @@ async def _verify_mhp_token( async def mhp_info(): """Return this hub's identity for peer registration.""" return { - "hub_id": _hub_id, + "hub_id": hub_id(), "mhp_version": MHP_VERSION, "hub_version": __version__, "pk_hub_pem": hub_public_key_pem().decode(), @@ -139,7 +159,7 @@ async def export_directory( groups = result.scalars().all() return { - "hub_id": _hub_id, + "hub_id": hub_id(), "mhp_version": MHP_VERSION, "groups": [ { @@ -147,7 +167,7 @@ async def export_directory( "name": g.name, "join_policy": g.join_policy, "created_at": g.created_at.isoformat(), - "hub_id": _hub_id, + "hub_id": hub_id(), } for g in groups ], |