From 903ea022e918a05c7c8cb43d95e46d82368566f1 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 12 Sep 2026 12:14:40 +0200 Subject: fix(hub): MHP binds its audience, and the hub knows its own name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Federation has never worked between two hubs, and the tests said so without anyone reading it that way. `federation.py` did `from meshbay_hub.auth import _hub_id, _hub_sk_pem` at import — which is before `load_hub_keypair` runs. So it held the key as `None` and the identity as the module default: `_issue_mhp_token` could only raise, and `/mhp/info`, the directory export and every token announced this instance as `meshbay.org` whatever it was configured as. Read through accessors now, at call time. And `_verify_mhp_token` named no audience while `_issue_mhp_token` sets one. PyJWT refuses a token carrying `aud` when decode is given none, so every token this hub issues was rejected by every hub running this code. Naming the audience fixes that and makes the binding real: a token minted for one peer is refused by another, which is what stops a captured request being replayed at a third hub. The comment claiming audience binding was unavailable because "the sending side is unbuilt" was describing a function four lines below it. Both were already written down. `test_federation.py` built envelopes by hand without an `aud`; `test_public_groups_toggle.py` signed its own token with a comment saying `_issue_mhp_token` "binds `_hub_sk_pem` at import time, before the lifespan loads it, so it cannot be used from a test", and another saying PyJWT rejects a token carrying `aud` when decode is given none. Both observations were exactly right, and both were treated as facts to route around. When a test has to work around the code to run, the thing it worked around is the finding. Those helpers now go through the real issuer, and two tests pin the identity and the audience refusal. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT --- .../meshbay-hub/tests/test_public_groups_toggle.py | 29 +++++++++------------- 1 file changed, 12 insertions(+), 17 deletions(-) (limited to 'packages/meshbay-hub/tests/test_public_groups_toggle.py') diff --git a/packages/meshbay-hub/tests/test_public_groups_toggle.py b/packages/meshbay-hub/tests/test_public_groups_toggle.py index 0d36b99..e52fa85 100644 --- a/packages/meshbay-hub/tests/test_public_groups_toggle.py +++ b/packages/meshbay-hub/tests/test_public_groups_toggle.py @@ -212,24 +212,19 @@ async def test_disabled_hands_a_non_member_no_node(client, db_session): def _mhp_token(hub_id): - """A peer-hub JWT, signed with the running hub's own key. - - `federation._issue_mhp_token` binds `_hub_sk_pem` at import time, before the - lifespan loads it, so it cannot be used from a test. This signs directly. + """A peer-hub JWT — from the hub's own issuer, which is the point. + + This used to sign by hand, and its two comments recorded, accurately, the + reasons it had to: `_issue_mhp_token` bound `_hub_sk_pem` at import, before + the lifespan loads it, so it signed with `None`; and it sets an `aud` that + the verifier named no audience for, which PyJWT refuses outright. Both were + written down here as facts to route around rather than as the defects they + were — between them MHP could not complete one authenticated request + between two real hubs. Fixed at the source, so this can call it. """ - import time - import uuid - - import jwt - from meshbay_hub import auth as hub_auth - - now = int(time.time()) - # No `aud`: export_directory verifies without an expected audience, and PyJWT - # rejects a token that carries `aud` when decode() is given none. - return jwt.encode( - {"iss": hub_id, "sub": hub_id, - "jti": str(uuid.uuid4()), "iat": now, "exp": now + 300}, - hub_auth._hub_sk_pem, algorithm="EdDSA") + from meshbay_hub.api.federation import _issue_mhp_token + + return _issue_mhp_token(hub_id) @pytest.mark.asyncio -- cgit v1.2.3