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.
This commit is contained in:
2026-10-15 10:31:40 +02:00
parent 1d66b30d0c
commit 44223ab168
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"
+3 -2
View File
@@ -76,8 +76,9 @@ status reads **Removed at the portal** and the instance stops dialling. Connect
with a new code to restore remote access.
Local sign-in and flows continue after disconnecting. Portal requests follow
the instance's normal account permissions, and the instance verifies portal
tokens against the public signing key pinned during enrollment.
the instance's normal account permissions. The instance verifies portal tokens
against the portal's signing keys, which it pins during enrollment and takes
again from the portal each time it connects.
## Custom portals