aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/federation.py48
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py20
2 files changed, 54 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
],
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]: