From 24661b9e095d6da63753cc009cdd1440f56d8f3c Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:36:40 -0400 Subject: [PATCH] feat(signing): minisign content verification + KATELLO_PASSWORD_FILE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContentViewSyncer: when signing_public_key is set, prepends three steps before nix copy — curl nix-cache-info, curl .minisig, minisign -V. The public key is embedded in the NixOS image so an unauthenticated Katello cannot deliver arbitrary closures. SyncDaemon: accepts signing_public_key constructor arg; wired through to ContentViewSyncer on each poll. daemon_from_env() reads SOURCEOS_SIGNING_PUBLIC_KEY. _resolve_password(): reads KATELLO_PASSWORD_FILE before KATELLO_PASSWORD, supporting systemd LoadCredential pattern used by the NixOS module. 95 tests passing. --- src/sourceos_syncd/cli.py | 21 ++++++++++++++------- src/sourceos_syncd/content_sync.py | 28 ++++++++++++++++++++++++++-- src/sourceos_syncd/daemon.py | 30 +++++++++++++++++++++++------- tests/test_katello_client.py | 23 +++++++++++++++++++++++ 4 files changed, 86 insertions(+), 16 deletions(-) diff --git a/src/sourceos_syncd/cli.py b/src/sourceos_syncd/cli.py index a9f65c5..fde5ca6 100644 --- a/src/sourceos_syncd/cli.py +++ b/src/sourceos_syncd/cli.py @@ -157,7 +157,7 @@ def build_parser() -> argparse.ArgumentParser: def add_katello_args(p: argparse.ArgumentParser) -> None: p.add_argument("--katello-url", default="https://127.0.0.1:8443", help="Foreman+Katello base URL") p.add_argument("--katello-user", default="admin", help="Katello admin username") - p.add_argument("--katello-password", default=None, help="Katello admin password (or set KATELLO_PASSWORD env)") + p.add_argument("--katello-password", default=None, help="Katello admin password (or set KATELLO_PASSWORD / KATELLO_PASSWORD_FILE env)") p.add_argument("--org", default="SocioProphet", help="Katello organization name") p.add_argument("--content-view", default="sourceos-builder-aarch64", help="content view name") p.add_argument("--lifecycle-env", default="dev", help="lifecycle environment (dev/candidate/stable)") @@ -165,6 +165,7 @@ def add_katello_args(p: argparse.ArgumentParser) -> None: p.add_argument("--flake-ref", default="github:SociOS-Linux/source-os#builder-aarch64", help="NixOS flake ref") p.add_argument("--current-version", default=None, help="current content view version (skip if up to date)") p.add_argument("--no-verify-ssl", action="store_true", help="skip TLS verification (local dev only)") + p.add_argument("--signing-public-key", default=None, help="minisign public key (RWS...) to verify nix-cache-info before applying") sync_plan = sync_sub.add_parser("plan", help="query Katello and emit a ContentSyncPlan (no changes)") add_katello_args(sync_plan) @@ -317,9 +318,11 @@ def main(argv: list[str] | None = None) -> int: if args.area == "sync" and args.command in ("plan", "apply"): import os - password = args.katello_password or os.environ.get("KATELLO_PASSWORD", "") - if not password: - sys.stderr.write(pretty_json({"error": "missing password", "message": "pass --katello-password or set KATELLO_PASSWORD"}, pretty=pretty)) + from .daemon import _resolve_password + try: + password = args.katello_password or _resolve_password() + except RuntimeError as exc: + sys.stderr.write(pretty_json({"error": "missing password", "message": str(exc)}, pretty=pretty)) return 1 client = KatelloContentClient( base_url=args.katello_url, @@ -333,6 +336,7 @@ def main(argv: list[str] | None = None) -> int: flake_ref=args.flake_ref, locus=args.locus, current_version=args.current_version, + signing_public_key=getattr(args, "signing_public_key", None), ) plan = syncer.plan(manifest) if args.command == "plan": @@ -359,9 +363,11 @@ def main(argv: list[str] | None = None) -> int: if getattr(args, "from_env", False): daemon = daemon_from_env() else: - password = args.katello_password or os.environ.get("KATELLO_PASSWORD", "") - if not password: - sys.stderr.write(pretty_json({"error": "missing password", "message": "pass --katello-password or set KATELLO_PASSWORD"}, pretty=pretty)) + from .daemon import _resolve_password + try: + password = args.katello_password or _resolve_password() + except RuntimeError as exc: + sys.stderr.write(pretty_json({"error": "missing password", "message": str(exc)}, pretty=pretty)) return 1 daemon = SyncDaemon( katello_url=args.katello_url, @@ -375,6 +381,7 @@ def main(argv: list[str] | None = None) -> int: poll_interval_s=args.poll_interval, store_root=getattr(args, "store_root", None), verify_ssl=not args.no_verify_ssl, + signing_public_key=getattr(args, "signing_public_key", None), ) return daemon.run() diff --git a/src/sourceos_syncd/content_sync.py b/src/sourceos_syncd/content_sync.py index 0669585..2b89bde 100644 --- a/src/sourceos_syncd/content_sync.py +++ b/src/sourceos_syncd/content_sync.py @@ -68,6 +68,12 @@ class ContentViewSyncer: The syncer enforces the locus gate: only local locus is permitted for Phase 0. burst_cloud and attested_fog require explicit policy elevation (not yet implemented). + + When signing_public_key is set (a minisign public key string starting + with "RWS..."), the plan includes a `minisign -V` step before nix copy. + This ensures the nix-cache-info served by Pulp was signed by the key + embedded in the NixOS image, preventing an unauthenticated Katello from + delivering arbitrary closures. """ ALLOWED_LOCI = {"local", "trusted_private"} @@ -77,10 +83,12 @@ def __init__( flake_ref: str = "github:SociOS-Linux/source-os#builder-aarch64", locus: str = "local", current_version: str | None = None, + signing_public_key: str | None = None, ) -> None: self._flake_ref = flake_ref self._locus = locus self._current_version = current_version + self._signing_public_key = signing_public_key def plan(self, manifest: ContentViewManifest) -> ContentSyncPlan: """Return a non-mutating ContentSyncPlan. No I/O performed.""" @@ -115,8 +123,24 @@ def plan(self, manifest: ContentViewManifest) -> ContentSyncPlan: steps=[], ) - steps = [ - f"nix copy --from '{manifest.nix_cache_url}' --no-check-sigs '{self._flake_ref}'", + steps = [] + + # Verify nix-cache-info signature before pulling any closures. + # The public key is the one baked into the NixOS image — an + # unauthenticated Katello cannot deliver closures without knowing the + # private key held by the build pipeline. + if self._signing_public_key: + cache_info_url = f"{manifest.nix_cache_url}/nix-cache-info" + steps += [ + f"curl -fsSL '{cache_info_url}' -o /tmp/sourceos-nix-cache-info", + f"curl -fsSL '{cache_info_url}.minisig' -o /tmp/sourceos-nix-cache-info.minisig", + f"minisign -V -P '{self._signing_public_key}'" + f" -m /tmp/sourceos-nix-cache-info" + f" -x /tmp/sourceos-nix-cache-info.minisig", + ] + + steps += [ + f"nix copy --from '{manifest.nix_cache_url}' '{self._flake_ref}'", f"nixos-rebuild switch --flake '{self._flake_ref}'", ] diff --git a/src/sourceos_syncd/daemon.py b/src/sourceos_syncd/daemon.py index d88fbf1..278ca2c 100644 --- a/src/sourceos_syncd/daemon.py +++ b/src/sourceos_syncd/daemon.py @@ -51,6 +51,7 @@ def __init__( poll_interval_s: int = DEFAULT_POLL_INTERVAL_S, store_root: str | None = None, verify_ssl: bool = True, + signing_public_key: str | None = None, ) -> None: self._client = KatelloContentClient( base_url=katello_url, @@ -63,6 +64,7 @@ def __init__( self._lifecycle_env = lifecycle_env self._locus = locus self._flake_ref = flake_ref + self._signing_public_key = signing_public_key self._poll_interval_s = poll_interval_s self._store = ReceiptStore(root=store_root or "/var/lib/sourceos-syncd") self._running = True @@ -115,6 +117,7 @@ def _poll_once(self) -> None: flake_ref=self._flake_ref, locus=self._locus, current_version=current_version, + signing_public_key=self._signing_public_key, ) plan = syncer.plan(manifest) @@ -155,18 +158,30 @@ def _interruptible_sleep(self, seconds: int) -> None: time.sleep(min(5, deadline - time.monotonic())) +def _resolve_password() -> str: + """Read password from KATELLO_PASSWORD or KATELLO_PASSWORD_FILE. + + KATELLO_PASSWORD_FILE wins when both are set. This supports systemd + LoadCredential which writes the secret to a file, not an env var. + """ + pw_file = os.environ.get("KATELLO_PASSWORD_FILE", "") + if pw_file: + try: + return open(pw_file, encoding="utf-8").read().strip() + except OSError as exc: + raise RuntimeError(f"cannot read KATELLO_PASSWORD_FILE {pw_file}: {exc}") from exc + pw = os.environ.get("KATELLO_PASSWORD", "") + if not pw: + raise RuntimeError("KATELLO_PASSWORD or KATELLO_PASSWORD_FILE must be set") + return pw + + def daemon_from_env() -> SyncDaemon: """Construct a SyncDaemon entirely from environment variables.""" - def require(name: str) -> str: - val = os.environ.get(name, "") - if not val: - raise RuntimeError(f"required env var {name} is not set") - return val - return SyncDaemon( katello_url=os.environ.get("KATELLO_URL", "https://127.0.0.1:8443"), katello_user=os.environ.get("KATELLO_USER", "admin"), - katello_password=require("KATELLO_PASSWORD"), + katello_password=_resolve_password(), org=os.environ.get("KATELLO_ORG", "SocioProphet"), content_view=os.environ.get("KATELLO_CONTENT_VIEW", "sourceos-builder-aarch64"), lifecycle_env=os.environ.get("KATELLO_LIFECYCLE_ENV", "stable"), @@ -174,6 +189,7 @@ def require(name: str) -> str: flake_ref=os.environ.get( "SOURCEOS_FLAKE_REF", "github:SociOS-Linux/source-os#builder-aarch64" ), + signing_public_key=os.environ.get("SOURCEOS_SIGNING_PUBLIC_KEY", "") or None, poll_interval_s=int(os.environ.get("SOURCEOS_POLL_INTERVAL", str(DEFAULT_POLL_INTERVAL_S))), store_root=os.environ.get("SOURCEOS_STORE_ROOT", "/var/lib/sourceos-syncd"), verify_ssl=os.environ.get("SOURCEOS_NO_VERIFY_SSL", "").lower() not in ("1", "true", "yes"), diff --git a/tests/test_katello_client.py b/tests/test_katello_client.py index a087b7d..370de87 100644 --- a/tests/test_katello_client.py +++ b/tests/test_katello_client.py @@ -50,6 +50,29 @@ def test_plan_allowed_new_version(): assert plan.allowed assert any("nix copy" in s for s in plan.steps) assert any("nixos-rebuild" in s for s in plan.steps) + # no signing steps when key not configured + assert not any("minisign" in s for s in plan.steps) + + +def test_plan_with_signing_key_prepends_verify_steps(): + pub_key = "RWSxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + syncer = ContentViewSyncer(locus="local", current_version="0.9", signing_public_key=pub_key) + plan = syncer.plan(make_manifest(version="1.0")) + assert plan.allowed + step_cmds = " ".join(plan.steps) + assert "minisign" in step_cmds + assert "nix-cache-info" in step_cmds + # verify step must come before nix copy + minisign_idx = next(i for i, s in enumerate(plan.steps) if "minisign" in s) + nix_copy_idx = next(i for i, s in enumerate(plan.steps) if "nix copy" in s) + assert minisign_idx < nix_copy_idx + + +def test_plan_with_signing_key_embeds_public_key(): + pub_key = "RWSxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + syncer = ContentViewSyncer(locus="local", signing_public_key=pub_key) + plan = syncer.plan(make_manifest(version="1.0")) + assert any(pub_key in s for s in plan.steps) def test_plan_noop_same_version():