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
171 lines
6.5 KiB
Python
171 lines
6.5 KiB
Python
"""What this instance knows about the portal it is enrolled with.
|
|
|
|
One file on the data volume, beside the OAuth keypair and for the same reason:
|
|
it is a credential, it must survive a rebuild, and it must never be in the
|
|
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 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
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import secrets
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
import jwt
|
|
from jwt.exceptions import InvalidTokenError
|
|
|
|
from fluksio.core.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
#: Marks a request the connector replayed off the tunnel. The value is minted
|
|
#: per process and never leaves it, because the header itself is not evidence:
|
|
#: anything that can reach this API directly can set one, and the difference
|
|
#: decides whether a pairing device is handed a credential the whole internet
|
|
#: can present. The connector overwrites it on every frame, so a browser
|
|
#: sending its own gets nowhere from either direction.
|
|
VIA_HEADER = "x-fluksio-via"
|
|
VIA_PORTAL = secrets.token_urlsafe(16)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CloudConfig:
|
|
portal_url: str
|
|
ws_url: str
|
|
instance_id: str
|
|
token: str
|
|
issuer: str
|
|
jwks: dict[str, Any]
|
|
#: The local account this instance acts as on its own behalf: what the
|
|
#: health summary is collected as, and what a screen paired through the
|
|
#: portal borrows for want of a person. Recorded at enrolment from whoever
|
|
#: performed it. Portal *sessions* no longer come through here — they name
|
|
#: a person, and are resolved to the local account mapped to them.
|
|
local_user_id: str
|
|
enrolled_at: str
|
|
portal_account: str | None = None
|
|
|
|
|
|
_cache: tuple[float, CloudConfig | None] | None = None
|
|
|
|
|
|
def _read() -> CloudConfig | None:
|
|
"""Load the config, re-reading only when the file has changed."""
|
|
global _cache
|
|
path = settings.CLOUD_CONFIG_FILE
|
|
try:
|
|
mtime = path.stat().st_mtime
|
|
except OSError:
|
|
_cache = (0.0, None)
|
|
return None
|
|
if _cache is not None and _cache[0] == mtime:
|
|
return _cache[1]
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
# A file written before the installation/instance rename. Adopt it
|
|
# rather than failing to parse, which would read as "never enrolled".
|
|
if "installation_id" in data:
|
|
data["instance_id"] = data.pop("installation_id")
|
|
config = CloudConfig(**data)
|
|
except (OSError, ValueError, TypeError):
|
|
logger.exception("Could not read %s; remote access stays off", path)
|
|
_cache = (mtime, None)
|
|
return None
|
|
_cache = (mtime, config)
|
|
return config
|
|
|
|
|
|
def load() -> CloudConfig | None:
|
|
return _read()
|
|
|
|
|
|
def exists() -> bool:
|
|
return settings.CLOUD_CONFIG_FILE.exists()
|
|
|
|
|
|
def save(config: CloudConfig) -> None:
|
|
path = settings.CLOUD_CONFIG_FILE
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
# Written through a temporary file so a crash mid-write cannot leave a
|
|
# half-parsed credential behind, and never group- or world-readable.
|
|
tmp = path.with_suffix(".tmp")
|
|
tmp.write_text(json.dumps(config.__dict__, indent=2))
|
|
os.chmod(tmp, 0o600)
|
|
tmp.replace(path)
|
|
global _cache
|
|
_cache = None
|
|
|
|
|
|
def delete() -> None:
|
|
settings.CLOUD_CONFIG_FILE.unlink(missing_ok=True)
|
|
global _cache
|
|
_cache = None
|
|
|
|
|
|
def decode_portal_token(token: str) -> dict[str, Any]:
|
|
"""Validate a token the portal minted for this instance.
|
|
|
|
Raises ``InvalidTokenError`` for everything else, including the ordinary
|
|
case of not being enrolled at all — which is what makes this branch free
|
|
for an instance nobody connected.
|
|
"""
|
|
config = _read()
|
|
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:
|
|
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,
|
|
key,
|
|
algorithms=["RS256"],
|
|
# The audience is this instance's own id, so a token the portal
|
|
# minted for somebody else's machine fails here.
|
|
audience=config.instance_id,
|
|
issuer=config.issuer,
|
|
)
|
|
scope = claims.get("scope")
|
|
if scope == "panel":
|
|
# A screen that paired through the portal. The portal named the panel
|
|
# and nothing else; what that panel may read is decided here, by the
|
|
# same check a panel paired on this network passes. It acts as the
|
|
# enrolling account for want of any other, but the scope check is what
|
|
# actually bounds it — so a token of this scope that names no panel is
|
|
# refused rather than left holding the account it borrows.
|
|
panel = str(claims.get("sub") or "")
|
|
if not panel:
|
|
raise InvalidTokenError("a panel token must name its panel")
|
|
return {"sub": config.local_user_id, "panel": panel}
|
|
if scope != "proxy":
|
|
raise InvalidTokenError("not a proxy token")
|
|
# A portal session names the person holding it, and that name is the whole
|
|
# of their identity here: the caller resolves it to the local account it
|
|
# was mapped to, and a portal identity nobody mapped resolves to nothing.
|
|
# Deliberately no local account by default — the failure of a mapping must
|
|
# be a refusal, not a fallback onto whoever enrolled.
|
|
portal_sub = str(claims.get("sub") or "")
|
|
if not portal_sub:
|
|
raise InvalidTokenError("a proxy token must name its user")
|
|
return {"portal_sub": portal_sub}
|