diff --git a/src/sourceos_syncd/cli.py b/src/sourceos_syncd/cli.py index 8ebe0fb..9ebf23f 100644 --- a/src/sourceos_syncd/cli.py +++ b/src/sourceos_syncd/cli.py @@ -173,8 +173,19 @@ def add_katello_args(p: argparse.ArgumentParser) -> None: 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") + def add_attestation_args(p: argparse.ArgumentParser) -> None: + # The deploy gate defaults to fail-closed: without a verified release + # attestation binding the target version, plan() refuses to switch. + p.add_argument("--attestation-file", default=None, + help="path to a signed release attestation (SignedArtifact JSON) for the target version") + p.add_argument("--attestation-public-key", default=None, + help="minisign public key the release attestation is verified against") + p.add_argument("--no-require-attestation", dest="require_attestation", action="store_false", default=True, + help="EXPLICIT opt-out of the release-attestation gate (recorded in the receipt; unsafe)") + sync_plan = sync_sub.add_parser("plan", help="query Katello and emit a ContentSyncPlan (no changes)") add_katello_args(sync_plan) + add_attestation_args(sync_plan) add_compact(sync_plan) sync_apply = sync_sub.add_parser("apply", help="apply a ContentSyncPlan (dry-run unless --execute)") @@ -182,6 +193,7 @@ def add_katello_args(p: argparse.ArgumentParser) -> None: sync_apply.add_argument("--execute", action="store_true", help="actually run nix copy + nixos-rebuild (default: dry-run)") sync_apply.add_argument("--store-root", default=None, help="persist receipt to this store root") sync_apply.add_argument("--agentplane-run-ref", default=None, help="agentplane RunArtifact URN that triggered this sync cycle (optional)") + add_attestation_args(sync_apply) add_compact(sync_apply) sync_daemon = sync_sub.add_parser("daemon", help="run the sync daemon (polls Katello; applies on new version)") @@ -401,14 +413,25 @@ def main(argv: list[str] | None = None) -> int: verify_ssl=not args.no_verify_ssl, ) manifest = client.get_latest_version(args.content_view, args.lifecycle_env) + attestation = None + attestation_file = getattr(args, "attestation_file", None) + if attestation_file: + try: + with open(attestation_file, encoding="utf-8") as fh: + attestation = json.load(fh) + except (OSError, ValueError) as exc: + sys.stderr.write(f"error: could not read --attestation-file {attestation_file!r}: {exc}\n") + return 1 syncer = ContentViewSyncer( flake_ref=args.flake_ref, locus=args.locus, current_version=args.current_version, signing_public_key=getattr(args, "signing_public_key", None), agentplane_run_ref=getattr(args, "agentplane_run_ref", None), + require_attestation=getattr(args, "require_attestation", True), + attestation_public_key=getattr(args, "attestation_public_key", None), ) - plan = syncer.plan(manifest) + plan = syncer.plan(manifest, attestation=attestation) if args.command == "plan": sys.stdout.write(pretty_json(plan.to_dict(), pretty=pretty)) return 0 if plan.policy_gate in ("allowed", "no-op") else 2 diff --git a/src/sourceos_syncd/content_sync.py b/src/sourceos_syncd/content_sync.py index 2402e0f..b0c49aa 100644 --- a/src/sourceos_syncd/content_sync.py +++ b/src/sourceos_syncd/content_sync.py @@ -7,6 +7,14 @@ Boundary invariant: plan() is pure and side-effect-free. execute() is the only method that shells out — it requires an explicit caller opt-in and will refuse to run if the plan's policy_gate is not 'allowed'. + +Deploy gate (SP-GATE-001): when require_attestation is True (the default), +plan() will only emit a `nixos-rebuild switch` when a signed release attestation +binds the *specific* content-view version being switched to. Absent, mismatched, +or unverifiable attestation → policy_gate="blocked" and NO switch step. This is +what makes possession of the promote credential stop being runtime authority. +execute() additionally aborts on the first failed step, so a verification that +returns non-zero prevents the switch instead of being reported after the fact. """ from __future__ import annotations @@ -20,6 +28,7 @@ from typing import Any from .katello_client import ContentViewManifest +from .release_attestation import AttestationDecision, Verifier, verify_release_attestation SYNC_SCHEMA = "sourceos.content-sync-plan/v0.1" RECEIPT_SPEC_VERSION = "0.1.0" @@ -41,6 +50,12 @@ class ContentSyncPlan: policy_gate: str policy_reason: str steps: list[str] = field(default_factory=list) + # Epistemic level of this plan's authority to switch: "Proved" only when a + # release attestation verified and bound this version; "Speculative" otherwise. + epistemic_level: str = "Speculative" + # The attestation decision (as_dict) when the gate ran; None when the gate was + # not required (an explicit, recorded opt-out — not a silent skip). + attestation: dict[str, Any] | None = None def to_dict(self) -> dict[str, Any]: return { @@ -55,6 +70,8 @@ def to_dict(self) -> dict[str, Any]: "policy_gate": self.policy_gate, "policy_reason": self.policy_reason, "steps": self.steps, + "epistemic_level": self.epistemic_level, + "attestation": self.attestation, } @property @@ -74,6 +91,13 @@ class ContentViewSyncer: 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. + + When require_attestation is True (default), the plan additionally REFUSES to + switch unless a signed release attestation binds the exact version being + applied — see release_attestation.verify_release_attestation. attestation_public_key + is the minisign key that attestation is checked against; attestation_verifier + is injectable for testing (default performs real minisign verification and + fails closed). """ ALLOWED_LOCI = {"local", "trusted_private"} @@ -85,14 +109,24 @@ def __init__( current_version: str | None = None, signing_public_key: str | None = None, agentplane_run_ref: str | None = None, + require_attestation: bool = True, + attestation_public_key: str | None = None, + attestation_verifier: Verifier | None = None, ) -> None: self._flake_ref = flake_ref self._locus = locus self._current_version = current_version self._signing_public_key = signing_public_key self._agentplane_run_ref = agentplane_run_ref + self._require_attestation = require_attestation + self._attestation_public_key = attestation_public_key + self._attestation_verifier = attestation_verifier - def plan(self, manifest: ContentViewManifest) -> ContentSyncPlan: + def plan( + self, + manifest: ContentViewManifest, + attestation: dict[str, Any] | None = None, + ) -> ContentSyncPlan: """Return a non-mutating ContentSyncPlan. No I/O performed.""" if self._locus not in self.ALLOWED_LOCI: @@ -125,6 +159,37 @@ def plan(self, manifest: ContentViewManifest) -> ContentSyncPlan: steps=[], ) + # ── deploy gate: bind the switch to a signed attestation of THIS version ── + # A newer version being *available* is not authority to switch to it. The + # switch is authorized only by a release attestation that verifies and + # binds this exact (org, content_view, version). Fail closed. + decision: AttestationDecision | None = None + if self._require_attestation: + decision = verify_release_attestation( + attestation, + org=manifest.org, + content_view=manifest.content_view, + version=manifest.version, + trusted_key=self._attestation_public_key, + verifier=self._attestation_verifier, + ) + if not decision.ok: + return ContentSyncPlan( + schema=SYNC_SCHEMA, + org=manifest.org, + content_view=manifest.content_view, + from_version=self._current_version, + to_version=manifest.version, + lifecycle_env=manifest.lifecycle_env, + nix_cache_url=manifest.nix_cache_url, + flake_ref=self._flake_ref, + policy_gate="blocked", + policy_reason=f"release attestation refused: {decision.reason}", + steps=[], + epistemic_level=decision.epistemic_level, + attestation=decision.to_dict(), + ) + steps = [] # Verify nix-cache-info signature before pulling any closures. @@ -158,18 +223,26 @@ def plan(self, manifest: ContentViewManifest) -> ContentSyncPlan: policy_gate="allowed", policy_reason=f"locus '{self._locus}' permitted; new version available", steps=steps, + epistemic_level=(decision.epistemic_level if decision else "Speculative"), + attestation=(decision.to_dict() if decision else None), ) def execute(self, plan: ContentSyncPlan, dry_run: bool = True) -> dict[str, Any]: """Execute the sync plan. dry_run=True (default) only prints steps. + Steps run in order and the loop ABORTS on the first failed or timed-out + step: a verification (or nix copy) that fails must PREVENT the subsequent + `nixos-rebuild switch`, not merely be reported after it already ran. + Always emits a SyncCycleReceipt in the return dict under 'receipt'. """ cycle_id = str(uuid.uuid4()) t_start = time.monotonic() if not plan.allowed: - outcome = "denied" if plan.policy_gate == "denied" else "skipped" + outcome = "denied" if plan.policy_gate == "denied" else ( + "blocked" if plan.policy_gate == "blocked" else "skipped" + ) receipt = self._build_receipt( cycle_id=cycle_id, plan=plan, @@ -185,7 +258,18 @@ def execute(self, plan: ContentSyncPlan, dry_run: bool = True) -> dict[str, Any] } results = [] + aborted = False for step in plan.steps: + if aborted: + # Fail closed: once a step fails we refuse to run anything after + # it — above all the nixos-rebuild switch. + results.append({ + "step": step, + "status": "not-run", + "reason": "aborted: a prior step failed — refusing to proceed to nixos-rebuild", + }) + continue + if dry_run: results.append({"step": step, "status": "dry_run", "reason": "dry_run=True"}) continue @@ -201,19 +285,23 @@ def execute(self, plan: ContentSyncPlan, dry_run: bool = True) -> dict[str, Any] proc = subprocess.run( step, shell=True, capture_output=True, text=True, timeout=600 ) + status = "ok" if proc.returncode == 0 else "failed" results.append({ "step": step, - "status": "ok" if proc.returncode == 0 else "failed", + "status": status, "returncode": proc.returncode, "stdout": proc.stdout.strip()[:500], "stderr": proc.stderr.strip()[:500], }) + if status == "failed": + aborted = True except subprocess.TimeoutExpired: results.append({"step": step, "status": "timeout"}) + aborted = True duration_ms = int((time.monotonic() - t_start) * 1000) outcome = "dry_run" if dry_run else ( - "applied" if all(r.get("status") in ("ok", "dry_run", "skipped") for r in results) + "applied" if (not aborted and all(r.get("status") in ("ok", "dry_run", "skipped") for r in results)) else "failed" ) receipt = self._build_receipt( @@ -256,6 +344,10 @@ def _build_receipt( "outcome": outcome, "policyGate": plan.policy_gate, "policyReason": plan.policy_reason, + # The switch's epistemic level travels with the receipt: unattested + # switches are visibly Speculative downstream, never laundered to Proved. + "epistemicLevel": plan.epistemic_level, + "attestation": plan.attestation, "steps": steps, "nixCacheUrl": plan.nix_cache_url, "flakeRef": plan.flake_ref, diff --git a/src/sourceos_syncd/daemon.py b/src/sourceos_syncd/daemon.py index 4536d2f..c1efcfa 100644 --- a/src/sourceos_syncd/daemon.py +++ b/src/sourceos_syncd/daemon.py @@ -53,6 +53,9 @@ def __init__( verify_ssl: bool = True, signing_public_key: str | None = None, agentplane_run_ref: str | None = None, + require_attestation: bool = True, + attestation_public_key: str | None = None, + attestation_dir: str | None = None, ) -> None: self._client = KatelloContentClient( base_url=katello_url, @@ -67,6 +70,10 @@ def __init__( self._flake_ref = flake_ref self._signing_public_key = signing_public_key self._agentplane_run_ref = agentplane_run_ref + self._require_attestation = require_attestation + self._attestation_public_key = attestation_public_key + self._attestation_dir = attestation_dir + self._attestation_verifier = None # injectable in tests; default = real minisign self._poll_interval_s = poll_interval_s self._store = ReceiptStore(root=store_root or "/var/lib/sourceos-syncd") self._running = True @@ -111,6 +118,23 @@ def run(self) -> int: log.info("daemon stopped") return 0 + def _load_attestation(self, manifest: ContentViewManifest) -> dict[str, Any] | None: + """Load the release attestation for this version, if one is available. + + Reads ``{attestation_dir}/{content_view}-{version}.json``. Returns None when + no attestation_dir is configured or no file exists — and None means the + gate refuses (fail closed), which is the intended posture until the paired + producer WO publishes attestations. + """ + if not self._attestation_dir: + return None + path = os.path.join(self._attestation_dir, f"{manifest.content_view}-{manifest.version}.json") + try: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + except (OSError, ValueError): + return None + def _poll_once(self) -> None: current_version = self._store.read_current_version() manifest = self._client.get_latest_version(self._content_view, self._lifecycle_env) @@ -121,8 +145,11 @@ def _poll_once(self) -> None: current_version=current_version, signing_public_key=self._signing_public_key, agentplane_run_ref=self._agentplane_run_ref, + require_attestation=self._require_attestation, + attestation_public_key=self._attestation_public_key, + attestation_verifier=self._attestation_verifier, ) - plan = syncer.plan(manifest) + plan = syncer.plan(manifest, attestation=self._load_attestation(manifest)) if plan.policy_gate == "no-op": log.debug("already at %s — no sync needed", manifest.version) diff --git a/src/sourceos_syncd/release_attestation.py b/src/sourceos_syncd/release_attestation.py new file mode 100644 index 0000000..a71c1f0 --- /dev/null +++ b/src/sourceos_syncd/release_attestation.py @@ -0,0 +1,193 @@ +"""Release attestation for the content-sync deploy gate (SP-GATE-001). + +The failure this closes: sourceos-syncd would plan a `nixos-rebuild switch` for +any newer content-view version a (possibly unauthenticated) Katello advertised. +The only signature check was over nix-cache-info — cache *transport*, not the +promotion *decision*. So possession of the promote credential was, in effect, +runtime authority over every enrolled device. + +This module binds the switch to a signed attestation of the specific version, +and — the whole point — FAILS CLOSED: + + * absence of an attestation returns Speculative, not Proved; + * an attestation for a different artifact/version does not authorize this one + (possession of *an* attestation is not authorization for *this* switch); + * if the verifier cannot run (no key, no minisign), the answer is refuse, not + assume-valid — a gate that cannot fail is not a gate. + +The attestation record follows the sourceos-spec `SignedArtifact` shape +(artifactId, signer, algorithm, timestamp, issuer?, signatureRef). The cryptographic +step is injectable so tests are deterministic; the default performs a real +minisign verification and returns False on any failure or missing dependency. +""" + +from __future__ import annotations + +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + +ATTESTATION_SCHEMA = "sourceos.release-attestation/v0.1" + +# The fields a usable SignedArtifact release attestation must carry. signatureRef +# is required here (a SignedArtifact with no signature is not evidence of signing). +REQUIRED_FIELDS = ("artifactId", "signer", "algorithm", "timestamp", "signatureRef") + +Verifier = Callable[[dict[str, Any], "str | None"], bool] + + +def utc_now() -> str: + return datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def expected_artifact_id(org: str, content_view: str, version: str) -> str: + """The artifactId an attestation MUST carry to authorize this exact version. + + Binding on (org, content_view, version) is what makes an attestation for + v1.2 unusable to legalize a switch to v1.3 — the anti-retroactive-legalization + property the sourceos-boot digest pin has, applied to promotion. + """ + return f"urn:srcos:content-view:{org}:{content_view}:{version}" + + +@dataclass(frozen=True) +class AttestationDecision: + ok: bool + reason: str + epistemic_level: str # "Proved" when ok, "Speculative" when refused + signer: str | None = None + artifact_id: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "ok": self.ok, + "reason": self.reason, + "epistemicLevel": self.epistemic_level, + "signer": self.signer, + "artifactId": self.artifact_id, + } + + +def default_attestation_verifier(attestation: dict[str, Any], trusted_key: str | None) -> bool: + """Real minisign verification of the attestation's signatureRef. FAIL CLOSED. + + Returns False — never raises, never assumes — when the trusted key is absent, + the minisign binary is unavailable, the signature is missing, or verification + does not pass. The signed message is the canonical binding line + ``\\n`` so a signature is valid only for the exact artifact it names. + """ + if not trusted_key: + return False + signature = attestation.get("signatureRef") + artifact_id = attestation.get("artifactId") + if not signature or not artifact_id: + return False + if shutil.which("minisign") is None: + return False + try: + with tempfile.TemporaryDirectory() as d: + msg = Path(d) / "subject" + sig = Path(d) / "subject.minisig" + msg.write_text(f"{artifact_id}\n", encoding="utf-8") + sig.write_text(signature if signature.endswith("\n") else signature + "\n", encoding="utf-8") + proc = subprocess.run( + ["minisign", "-V", "-P", trusted_key, "-m", str(msg), "-x", str(sig)], + capture_output=True, + text=True, + timeout=30, + ) + return proc.returncode == 0 + except Exception: + return False + + +def verify_release_attestation( + attestation: dict[str, Any] | None, + *, + org: str, + content_view: str, + version: str, + trusted_key: str | None, + verifier: Verifier | None = None, +) -> AttestationDecision: + """Decide whether `attestation` authorizes a switch to `version`. Fail closed.""" + verify = verifier or default_attestation_verifier + + if attestation is None: + return AttestationDecision( + ok=False, + reason=f"no release attestation for version {version!r} — absence returns Speculative, not Proved", + epistemic_level="Speculative", + ) + + missing = [k for k in REQUIRED_FIELDS if not attestation.get(k)] + if missing: + return AttestationDecision( + ok=False, + reason=f"attestation missing required SignedArtifact field(s) {missing} — unsigned or malformed", + epistemic_level="Speculative", + signer=attestation.get("signer"), + ) + + expected = expected_artifact_id(org, content_view, version) + if attestation["artifactId"] != expected: + return AttestationDecision( + ok=False, + reason=( + f"attestation artifactId {attestation['artifactId']!r} does not bind version " + f"{version!r} (expected {expected!r}) — an attestation for another artifact is " + f"not authorization for this switch" + ), + epistemic_level="Speculative", + signer=attestation.get("signer"), + ) + + if not verify(attestation, trusted_key): + return AttestationDecision( + ok=False, + reason=( + "signature did not verify against the trusted key (or the verifier could not run — " + "fail closed: a gate that cannot fail is not a gate)" + ), + epistemic_level="Speculative", + signer=attestation.get("signer"), + artifact_id=expected, + ) + + return AttestationDecision( + ok=True, + reason="release attestation verified and bound to this exact version", + epistemic_level="Proved", + signer=attestation.get("signer"), + artifact_id=expected, + ) + + +def make_release_attestation( + *, + org: str, + content_view: str, + version: str, + signer: str, + signature_ref: str, + algorithm: str = "minisign-ed25519", + issuer: str | None = None, + timestamp: str | None = None, +) -> dict[str, Any]: + """Construct a SignedArtifact-shaped release attestation bound to a version. + + Used by tests and by the build/promote pipeline (the paired producer WO). + """ + return { + "schema": ATTESTATION_SCHEMA, + "artifactId": expected_artifact_id(org, content_view, version), + "signer": signer, + "algorithm": algorithm, + "timestamp": timestamp or utc_now(), + "issuer": issuer, + "signatureRef": signature_ref, + } diff --git a/tests/test_content_sync_attestation.py b/tests/test_content_sync_attestation.py new file mode 100644 index 0000000..b02da88 --- /dev/null +++ b/tests/test_content_sync_attestation.py @@ -0,0 +1,139 @@ +"""SP-GATE-001: the content-sync deploy gate must refuse. + +Covers (a) the fail-closed release-attestation gate in ContentViewSyncer.plan and +(b) execute()'s abort-on-failure, so a failed verification step prevents the +nixos-rebuild switch instead of being reported after it already ran. +""" + +from __future__ import annotations + +from sourceos_syncd.content_sync import ContentSyncPlan, ContentViewSyncer +from sourceos_syncd.katello_client import ContentViewManifest +from sourceos_syncd.release_attestation import ( + default_attestation_verifier, + make_release_attestation, + verify_release_attestation, +) + +ORG = "SocioProphet" +CV = "sourceos-builder-aarch64" + + +def manifest(version: str = "1.0") -> ContentViewManifest: + return ContentViewManifest( + org=ORG, + content_view=CV, + version=version, + lifecycle_env="stable", + katello_url="https://127.0.0.1:8443", + pulp_content_url="http://127.0.0.1:8101", + nix_cache_url="http://127.0.0.1:8101", + ) + + +def valid_attestation(version: str = "1.0") -> dict: + return make_release_attestation( + org=ORG, content_view=CV, version=version, + signer="urn:srcos:build-pipeline:sourceos-ci", + signature_ref="RUSxxxxxsignaturexxxxx", + ) + + +ALWAYS_TRUE = lambda att, key: True # noqa: E731 (test stub verifier) +ALWAYS_FALSE = lambda att, key: False # noqa: E731 + + +# ── the gate refuses (fail closed) ────────────────────────────────────────── + +def test_plan_blocked_without_attestation(): + syncer = ContentViewSyncer(locus="local", current_version="0.9") # require_attestation defaults True + plan = syncer.plan(manifest("1.0"), attestation=None) + assert plan.policy_gate == "blocked" + assert plan.steps == [] + assert not any("nixos-rebuild" in s for s in plan.steps) + assert plan.epistemic_level == "Speculative" + assert plan.attestation and plan.attestation["ok"] is False + + +def test_plan_blocked_attestation_for_wrong_version(): + syncer = ContentViewSyncer(locus="local", current_version="0.9", + attestation_public_key="RWSkey", attestation_verifier=ALWAYS_TRUE) + # attestation binds 1.0 but Katello advertised 2.0 — must not authorize + plan = syncer.plan(manifest("2.0"), attestation=valid_attestation("1.0")) + assert plan.policy_gate == "blocked" + assert "does not bind version" in plan.policy_reason + + +def test_plan_blocked_unsigned_attestation(): + syncer = ContentViewSyncer(locus="local", current_version="0.9", attestation_verifier=ALWAYS_TRUE) + att = valid_attestation("1.0") + del att["signatureRef"] # unsigned + plan = syncer.plan(manifest("1.0"), attestation=att) + assert plan.policy_gate == "blocked" + assert "missing required" in plan.policy_reason + + +def test_plan_blocked_when_signature_does_not_verify(): + syncer = ContentViewSyncer(locus="local", current_version="0.9", + attestation_public_key="RWSkey", attestation_verifier=ALWAYS_FALSE) + plan = syncer.plan(manifest("1.0"), attestation=valid_attestation("1.0")) + assert plan.policy_gate == "blocked" + assert "did not verify" in plan.policy_reason + + +def test_plan_allowed_with_valid_attestation(): + syncer = ContentViewSyncer(locus="local", current_version="0.9", + attestation_public_key="RWSkey", attestation_verifier=ALWAYS_TRUE) + plan = syncer.plan(manifest("1.0"), attestation=valid_attestation("1.0")) + assert plan.policy_gate == "allowed" + assert any("nixos-rebuild switch" in s for s in plan.steps) + assert plan.epistemic_level == "Proved" + assert plan.attestation and plan.attestation["ok"] is True + + +def test_receipt_carries_epistemic_level_from_attestation(): + syncer = ContentViewSyncer(locus="local", current_version="0.9", + attestation_public_key="RWSkey", attestation_verifier=ALWAYS_TRUE) + plan = syncer.plan(manifest("1.0"), attestation=valid_attestation("1.0")) + result = syncer.execute(plan, dry_run=True) + assert result["receipt"]["epistemicLevel"] == "Proved" + assert result["receipt"]["attestation"]["signer"].startswith("urn:srcos:build-pipeline") + + +# ── execute() aborts on failure: a failed step must stop the switch ───────── + +def test_execute_aborts_switch_when_prior_step_fails(): + # A plan whose first step fails; the subsequent nixos-rebuild switch must NOT run. + plan = ContentSyncPlan( + schema="sourceos.content-sync-plan/v0.1", + org=ORG, content_view=CV, from_version="0.9", to_version="1.0", + lifecycle_env="stable", nix_cache_url="http://127.0.0.1:8101", + flake_ref="github:x#y", policy_gate="allowed", policy_reason="test", + steps=["exit 1", "nixos-rebuild switch --flake 'github:x#y'"], + epistemic_level="Proved", + ) + syncer = ContentViewSyncer(locus="local") + result = syncer.execute(plan, dry_run=False) + statuses = {r["step"]: r["status"] for r in result["results"]} + assert statuses["exit 1"] == "failed" + assert statuses["nixos-rebuild switch --flake 'github:x#y'"] == "not-run" + assert result["status"] == "failed" + + +# ── the default verifier is fail-closed ───────────────────────────────────── + +def test_default_verifier_fails_closed_without_key(): + assert default_attestation_verifier(valid_attestation("1.0"), None) is False + + +def test_default_verifier_fails_closed_without_signature(): + att = valid_attestation("1.0") + del att["signatureRef"] + assert default_attestation_verifier(att, "RWSkey") is False + + +def test_verify_decision_absent_is_speculative(): + d = verify_release_attestation(None, org=ORG, content_view=CV, version="1.0", + trusted_key="RWSkey", verifier=ALWAYS_TRUE) + assert d.ok is False + assert d.epistemic_level == "Speculative" diff --git a/tests/test_daemon.py b/tests/test_daemon.py index d685fc1..105bdc9 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -118,6 +118,9 @@ def test_poll_once_noop_when_current(tmp_path): def test_poll_once_writes_receipt_on_apply(tmp_path): daemon = _make_daemon(str(tmp_path)) + # This test exercises the receipt-writing / version-advance mechanics; the + # attestation gate is covered separately, so opt out of it here explicitly. + daemon._require_attestation = False with patch.object(daemon._client, "get_latest_version", return_value=_manifest("1.0")): with patch("sourceos_syncd.content_sync.subprocess.run") as mock_run: mock_proc = MagicMock() @@ -135,6 +138,22 @@ def test_poll_once_writes_receipt_on_apply(tmp_path): assert daemon._store.read_current_version() == "1.0" +def test_poll_once_blocked_without_attestation_does_not_update_version(tmp_path): + # The deploy gate: with require_attestation on (default) and no attestation + # available, a new version must NOT be applied and the current version must + # not advance — possession of a promote credential is not runtime authority. + daemon = _make_daemon(str(tmp_path)) + assert daemon._require_attestation is True + with patch.object(daemon._client, "get_latest_version", return_value=_manifest("1.0")): + daemon._poll_once() + receipt = daemon._store.last_receipt() + assert receipt is not None + assert receipt["policyGate"] == "blocked" + assert receipt["outcome"] == "blocked" + assert receipt["epistemicLevel"] == "Speculative" + assert daemon._store.read_current_version() is None + + def test_poll_once_denied_does_not_update_version(tmp_path): daemon = _make_daemon(str(tmp_path)) # change locus to denied diff --git a/tests/test_katello_client.py b/tests/test_katello_client.py index 370de87..2905632 100644 --- a/tests/test_katello_client.py +++ b/tests/test_katello_client.py @@ -43,8 +43,11 @@ def test_manifest_to_dict(): # ── ContentViewSyncer.plan ───────────────────────────────────────────────── +# These plan tests exercise the locus / nix-cache-signing paths, which are +# orthogonal to the release-attestation gate; they opt out of it explicitly. +# The gate itself is covered in test_content_sync_attestation.py. def test_plan_allowed_new_version(): - syncer = ContentViewSyncer(locus="local", current_version="0.9") + syncer = ContentViewSyncer(locus="local", current_version="0.9", require_attestation=False) plan = syncer.plan(make_manifest(version="1.0")) assert plan.policy_gate == "allowed" assert plan.allowed @@ -56,7 +59,7 @@ def test_plan_allowed_new_version(): 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) + syncer = ContentViewSyncer(locus="local", current_version="0.9", signing_public_key=pub_key, require_attestation=False) plan = syncer.plan(make_manifest(version="1.0")) assert plan.allowed step_cmds = " ".join(plan.steps) @@ -70,7 +73,7 @@ def test_plan_with_signing_key_prepends_verify_steps(): def test_plan_with_signing_key_embeds_public_key(): pub_key = "RWSxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - syncer = ContentViewSyncer(locus="local", signing_public_key=pub_key) + syncer = ContentViewSyncer(locus="local", signing_public_key=pub_key, require_attestation=False) plan = syncer.plan(make_manifest(version="1.0")) assert any(pub_key in s for s in plan.steps) @@ -91,20 +94,20 @@ def test_plan_denied_invalid_locus(): def test_plan_allowed_trusted_private(): - syncer = ContentViewSyncer(locus="trusted_private", current_version=None) + syncer = ContentViewSyncer(locus="trusted_private", current_version=None, require_attestation=False) plan = syncer.plan(make_manifest(version="2.0")) assert plan.policy_gate == "allowed" def test_plan_no_current_version_always_syncs(): - syncer = ContentViewSyncer(locus="local", current_version=None) + syncer = ContentViewSyncer(locus="local", current_version=None, require_attestation=False) plan = syncer.plan(make_manifest(version="1.0")) assert plan.policy_gate == "allowed" assert len(plan.steps) == 2 def test_plan_to_dict_roundtrip(): - syncer = ContentViewSyncer(locus="local") + syncer = ContentViewSyncer(locus="local", require_attestation=False) plan = syncer.plan(make_manifest(version="1.0")) d = plan.to_dict() assert d["schema"].startswith("sourceos.content-sync-plan") @@ -115,7 +118,7 @@ def test_plan_to_dict_roundtrip(): # ── ContentViewSyncer.execute dry-run ───────────────────────────────────── def test_execute_dry_run_allowed(): - syncer = ContentViewSyncer(locus="local") + syncer = ContentViewSyncer(locus="local", require_attestation=False) plan = syncer.plan(make_manifest(version="1.0")) result = syncer.execute(plan, dry_run=True) assert result["status"] == "dry_run"