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:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user