"""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: 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. """ 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") 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 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}