aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/federation.py48
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py20
-rw-r--r--packages/meshbay-hub/tests/test_federation.py64
-rw-r--r--packages/meshbay-hub/tests/test_public_groups_toggle.py25
4 files changed, 126 insertions, 31 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
],
diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py
index 34baf45..c5ea34d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/auth.py
+++ b/packages/meshbay-hub/src/meshbay_hub/auth.py
@@ -88,6 +88,26 @@ def hub_public_key_pem() -> bytes:
return _hub_pk_pem
+def hub_private_key_pem() -> bytes:
+ if _hub_sk_pem is None:
+ raise RuntimeError("Hub keypair not loaded — call load_hub_keypair() first")
+ return _hub_sk_pem
+
+
+def hub_id() -> str:
+ """This hub's configured identity.
+
+ An accessor, not the module global, because `load_hub_keypair` runs at
+ startup and every one of these is set *after* import. A module that wrote
+ `from meshbay_hub.auth import _hub_id` captured the default and kept it:
+ `federation.py` did, so it signed with a `None` key and announced itself
+ as `meshbay.org` whatever its configuration said. Reading through a
+ function is what makes "call once at startup" true for readers as well as
+ for the writer.
+ """
+ return _hub_id
+
+
# ── Password ──────────────────────────────────────────────────────────────────
def hash_password(password: str) -> tuple[bytes, bytes]:
diff --git a/packages/meshbay-hub/tests/test_federation.py b/packages/meshbay-hub/tests/test_federation.py
index 6035b0c..54f86a7 100644
--- a/packages/meshbay-hub/tests/test_federation.py
+++ b/packages/meshbay-hub/tests/test_federation.py
@@ -48,10 +48,23 @@ class Peer:
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption())
- def envelope(self, jti: str | None = None) -> str:
+ def envelope(self, jti: str | None = None, audience: str | None = None) -> str:
+ """The shape `federation._issue_mhp_token` actually emits.
+
+ This used to omit `aud`, and so tested a token production never sends:
+ the verifier names an audience, and PyJWT refuses a token carrying
+ `aud` when the caller names none — which meant MHP could not complete
+ one authenticated request between two real hubs while this file stayed
+ green. A fixture narrower than the real thing tests the fixture.
+ """
+ # Read from the module rather than written down here: the receiving
+ # hub's id is what `aud` has to carry, and a copy of it in a test is a
+ # second place for it to drift from.
+ from meshbay_hub.auth import _hub_id
+
now = int(time.time())
return jwt.encode(
- {"iss": self.hub_id, "sub": self.hub_id,
+ {"iss": self.hub_id, "sub": self.hub_id, "aud": audience or _hub_id,
"jti": jti or str(uuid.uuid4()), "iat": now, "exp": now + 300},
self._sk_pem(), algorithm="EdDSA")
@@ -177,3 +190,50 @@ async def test_federation_cannot_revoke_a_user(client):
r = await client.post("/mhp/revoke", headers=peer.header(),
json={"token": peer.revocation("user", "some-user-id")})
assert r.status_code == 202 and r.json()["pruned"] == 0
+
+
+# ── The hub's own identity, and the audience it binds ────────────────────────
+#
+# MHP could not complete one authenticated request between two real hubs, and
+# nothing said so: `_issue_mhp_token` sets `aud`, `_verify_mhp_token` named no
+# audience, and PyJWT refuses a token carrying `aud` when the caller names
+# none. This file stayed green because its envelopes were built by hand
+# without one — a fixture narrower than what production emits tests the
+# fixture. Underneath that, `federation.py` bound `_hub_id` and `_hub_sk_pem`
+# at import, which is *before* `load_hub_keypair` runs: it signed with `None`
+# and called itself `meshbay.org` whatever the instance was named.
+
+
+def test_the_hub_issues_tokens_under_its_configured_identity(app):
+ """Driven through the real issuer, which is the half no test called."""
+ from meshbay_hub.api.federation import _issue_mhp_token
+ from meshbay_hub.auth import hub_id
+
+ assert hub_id() == "test-hub", "the fixture hub is not configured"
+
+ token = jwt.decode(_issue_mhp_token("peer-hub"),
+ options={"verify_signature": False})
+ assert token["iss"] == "test-hub", (
+ "the hub announced an identity that is not its own")
+ assert token["aud"] == "peer-hub"
+
+
+@pytest.mark.asyncio
+async def test_a_token_for_another_hub_is_refused(client):
+ """What the audience binding is for: a token captured here must not be
+ replayable at a peer, and one minted for a peer must not work here."""
+ admin = await _admin(client)
+ peer = Peer("peer-elsewhere.test")
+ await _register_peer(client, admin, peer)
+
+ r = await client.post(
+ "/mhp/directory",
+ headers={"Authorization":
+ f"Bearer {peer.envelope(audience='some-other-hub.test')}"},
+ json={"hub_id": peer.hub_id, "groups": []})
+ assert r.status_code == 401, r.text
+
+ r = await client.post(
+ "/mhp/directory", headers=peer.header(),
+ json={"hub_id": peer.hub_id, "groups": []})
+ assert r.status_code == 202, r.text
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.
+ """A peer-hub JWT — from the hub's own issuer, which is the point.
- `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.
+ 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
+ from meshbay_hub.api.federation import _issue_mhp_token
- 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")
+ return _issue_mhp_token(hub_id)
@pytest.mark.asyncio