Take the portal's JWKS from the tunnel and verify by kid

The welcome frame's keys replace the set pinned at enrolment, but only
over wss:// or loopback, the rule enrolment applies to the portal
address. Portal tokens are verified against the key their kid names, so
the hub can publish a new key beside the old one. A welcome without keys,
from an older hub, leaves the pinned set alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
2026-10-15 10:31:40 +02:00
co-authored by Claude Opus 5.5
parent 3a7c10b8a9
commit ffa044724b
5 changed files with 83 additions and 12 deletions
+15 -7
View File
@@ -6,10 +6,12 @@ flows repository. Deleting it is the local, unilateral way to sever the
connection — the portal's tokens stop verifying immediately, whatever the
portal still believes.
The portal's public keys are stored here rather than fetched: they are pinned
at enrolment, when a person was holding a claim code they had just read off the
portal's own screen. A hub whose DNS or TLS is later hijacked cannot re-key an
instance that already enrolled; it can only fail to verify.
The portal's public keys are stored here rather than fetched per request. They
are pinned at enrolment, when a person was holding a claim code they had just
read off the portal's own screen, and replaced by the set the hub sends each
time the tunnel attaches, so the hub can publish a new key beside the old one.
That makes the tunnel's TLS what a later key rests on: only a hub that answers
at the enrolled address, to this instance's own credential, can hand it one.
"""
from __future__ import annotations
@@ -123,10 +125,16 @@ def decode_portal_token(token: str) -> dict[str, Any]:
if config is None:
raise InvalidTokenError("this instance is not enrolled with a portal")
# By `kid`, so the hub can publish the next key beside the current one and
# tokens signed with either verify while it rotates.
kid = jwt.get_unverified_header(token).get("kid")
try:
key = jwt.PyJWKSet.from_dict(config.jwks).keys[0]
except (IndexError, jwt.PyJWKError) as exc:
raise InvalidTokenError(f"the pinned portal key is unusable: {exc}") from exc
keys = jwt.PyJWKSet.from_dict(config.jwks).keys
except (jwt.PyJWKError, jwt.PyJWKSetError) as exc:
raise InvalidTokenError(f"the pinned portal keys are unusable: {exc}") from exc
key = next((candidate for candidate in keys if candidate.key_id == kid), None)
if key is None:
raise InvalidTokenError("signed with a key the portal has not published")
claims: dict[str, Any] = jwt.decode(
token,
+24
View File
@@ -27,10 +27,12 @@ from datetime import timedelta
from typing import Any
import httpx
import jwt
from fastapi import FastAPI
import fluksio
from fluksio.cloud import config as cloud_config
from fluksio.cloud.enroll import is_loopback
logger = logging.getLogger(__name__)
@@ -239,6 +241,7 @@ class CloudConnector:
self._owner = welcome.get("owner")
self._adopt_owner(config, self._owner)
self._adopt_jwks(welcome.get("jwks"))
attached = True
self._connected = True
@@ -319,6 +322,27 @@ class CloudConnector:
# link: everything else this connection does still works.
logger.exception("Could not map the enrolling account to the portal owner")
@staticmethod
def _adopt_jwks(jwks: Any) -> None:
"""Replace the pinned signing keys with the set the hub sends on attach.
Taken only from a link that is encrypted or on this machine, the rule
enrolment applies to the portal address. Over cleartext anything on the
path could hand this instance a key of its own.
"""
config = cloud_config.load()
if config is None or not isinstance(jwks, dict) or jwks == config.jwks:
return
if not config.ws_url.startswith("wss://") and not is_loopback(config.ws_url):
return
try:
jwt.PyJWKSet.from_dict(jwks)
except jwt.PyJWTError as exc:
logger.warning("Ignoring unusable portal keys: %s", exc)
return
cloud_config.save(replace(config, jwks=jwks))
logger.info("Took the portal's current signing keys")
@staticmethod
def _forget_member(portal_sub: str, owner: str | None) -> None:
"""Delete the local user of somebody the portal no longer admits.
+3 -3
View File
@@ -38,7 +38,7 @@ class EnrollError(Exception):
self.detail = detail
def _is_loopback(url: str) -> bool:
def is_loopback(url: str) -> bool:
"""Whether the address is this machine.
Not a `*.local` name: mDNS answers it with whoever claims it on the LAN,
@@ -62,7 +62,7 @@ def redeem_claim(
# this address. Over plain http both are readable by anything on the path,
# so refuse rather than enrol insecurely — bar a loopback portal, which is
# how the stack is developed against itself.
if not base.startswith("https://") and not _is_loopback(base):
if not base.startswith("https://") and not is_loopback(base):
raise EnrollError(422, "The portal address must be https:// (or this machine)")
try:
response = httpx.post(
@@ -110,7 +110,7 @@ def enroll(
token=data["instance_token"],
issuer=data["issuer"],
# Pinned here, at the one moment the claim code proves who we are
# talking to. Nothing refreshes this.
# talking to. The tunnel refreshes it on every attach after this.
jwks=data["jwks"],
local_user_id=str(user.id),
enrolled_at=datetime.now(UTC).isoformat(),
+38
View File
@@ -772,3 +772,41 @@ def test_admin_lists_and_revokes_portal_members(
)
db.delete(arrived)
db.commit()
def test_the_portal_keys_follow_the_tunnel(
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
portal_key: rsa.RSAPrivateKey,
) -> None:
"""A rotated keypair arrives on attach, and either key verifies by kid."""
import json
from cryptography.hazmat.primitives import serialization
from fluksio.cloud.connector import CloudConnector
pinned = cloud_config.load()
assert pinned is not None
successor = rsa.generate_private_key(public_exponent=65537, key_size=2048)
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(successor.public_key()))
jwk.update({"use": "sig", "alg": "RS256", "kid": "next"})
rotated = {"keys": [*jwks(portal_key)["keys"], jwk]}
claims = jwt.decode(portal_token(portal_key), options={"verify_signature": False})
pem = successor.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
signed_by_next = jwt.encode(claims, pem, algorithm="RS256", headers={"kid": "next"})
# Over cleartext to another machine, a key is not worth taking.
CloudConnector._adopt_jwks(rotated)
assert cloud_config.load().jwks == pinned.jwks
with pytest.raises(jwt.exceptions.InvalidTokenError):
decode_token(signed_by_next)
cloud_config.save(replace(pinned, ws_url="wss://hub.example.test/attach"))
CloudConnector._adopt_jwks({"keys": []})
CloudConnector._adopt_jwks(rotated)
assert decode_token(portal_token(portal_key))["portal_sub"] == "portal-user-1"
assert decode_token(signed_by_next)["portal_sub"] == "portal-user-1"