From 44223ab168a92b58ee7490f9e3469488adc9981c Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 15 Oct 2026 10:31:40 +0200 Subject: [PATCH] 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. --- backend/fluksio/cloud/config.py | 22 +++++++++++------ backend/fluksio/cloud/connector.py | 24 +++++++++++++++++++ backend/fluksio/cloud/enroll.py | 6 ++--- backend/tests/test_cloud.py | 38 ++++++++++++++++++++++++++++++ docs/interface/portal.md | 5 ++-- 5 files changed, 83 insertions(+), 12 deletions(-) diff --git a/backend/fluksio/cloud/config.py b/backend/fluksio/cloud/config.py index e8abf47..34d0dce 100644 --- a/backend/fluksio/cloud/config.py +++ b/backend/fluksio/cloud/config.py @@ -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, diff --git a/backend/fluksio/cloud/connector.py b/backend/fluksio/cloud/connector.py index 2aa4ceb..10473c9 100644 --- a/backend/fluksio/cloud/connector.py +++ b/backend/fluksio/cloud/connector.py @@ -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. diff --git a/backend/fluksio/cloud/enroll.py b/backend/fluksio/cloud/enroll.py index 468d1b3..5962b7e 100644 --- a/backend/fluksio/cloud/enroll.py +++ b/backend/fluksio/cloud/enroll.py @@ -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(), diff --git a/backend/tests/test_cloud.py b/backend/tests/test_cloud.py index 2e9384a..095479e 100644 --- a/backend/tests/test_cloud.py +++ b/backend/tests/test_cloud.py @@ -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" diff --git a/docs/interface/portal.md b/docs/interface/portal.md index b4896c0..67e4c58 100644 --- a/docs/interface/portal.md +++ b/docs/interface/portal.md @@ -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