Skip to content

feat(sync): deploy gate must refuse — bind switch to a signed release attestation (SP-GATE-001) - #53

Merged
mdheller merged 1 commit into
mainfrom
feat/sp-gate-001-deploy-attestation
Aug 2, 2026
Merged

feat(sync): deploy gate must refuse — bind switch to a signed release attestation (SP-GATE-001)#53
mdheller merged 1 commit into
mainfrom
feat/sp-gate-001-deploy-attestation

Conversation

@mdheller

@mdheller mdheller commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

The hole (SP-GATE-001, from the SourceOS gate-gap audit)

sourceos-syncd planned a nixos-rebuild switch for any newer content-view version 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 ("Hell's Keychain, in the estate").

Verified two fail-open bugs, both instances of a gate that cannot fail is not a gate:

  1. plan() never bound the switch to an attestation of the version.
  2. execute() didn't abort on failure — if the minisign -V step returned non-zero, the loop kept going and still ran the switch, reporting outcome=failed only after the box already rebuilt.

The fix (fail closed)

  • New release_attestation module: binds the switch to a SignedArtifact-shaped attestation of the exact (org, content_view, version) (urn:srcos:content-view:…). require_attestation defaults True:
    • absent → refuse (absence returns Speculative, not Proved);
    • attestation for another version → refuse (possession of an attestation is not authorization for this switch — the anti-retroactive-legalization property sourceos-boot's digest pin has, applied to promotion);
    • unsigned / signature doesn't verify / verifier can't run → refuse. The default verifier does real minisign and returns False (never assumes) when key or binary is absent.
    • Opting out is explicit (--no-require-attestation), recorded in the receipt — never a silent default.
  • execute() now aborts on the first failed/timed-out step — a failed verification prevents the switch instead of being reported after it.
  • SyncCycleReceipt carries epistemicLevel (Proved only when attested, else Speculative) + the attestation decision, so unattested switches are visibly Speculative downstream (partial SP-GATE-003).

Wired through daemon + cli (--attestation-file, --attestation-public-key).

Tests — 129 pass

New test_content_sync_attestation.py: blocked fixtures for no / wrong-version / unsigned / unverifiable attestation; allowed with a valid one; execute-aborts-the-switch when a prior step fails; default-verifier-fails-closed. Existing locus/signing tests opt out of the gate explicitly (orthogonal concern).

Operational note (intended)

Fail-closed by default means enrolled devices refuse to switch until release attestations are produced — that is the point ("possession of the promote credential does not grant runtime authority"). The paired producer (the build/promote pipeline emitting these attestations, alongside SP-GATE-002) is the follow-up. Not auto-merging — for review.

…signed release attestation (SP-GATE-001)

The hole: sourceos-syncd planned a nixos-rebuild switch for any newer content-view
version Katello advertised. The only signature check was over nix-cache-info (cache
transport), so possession of the promote credential was effectively runtime authority
over every enrolled device.

Two fail-open bugs closed, both instances of 'a gate that cannot fail is not a gate':
1. plan(): new module release_attestation binds the switch to a SignedArtifact-shaped
   attestation of the EXACT (org, content_view, version). require_attestation defaults
   True (fail closed): absent / wrong-version / unsigned / unverifiable attestation ->
   policy_gate='blocked', NO switch step. The verifier is injectable; the default does
   real minisign verification and returns False (never assumes) when key or binary is
   absent. Opting out is explicit (--no-require-attestation), never silent.
2. execute(): the step loop now ABORTS on the first failed/timed-out step, so a failed
   verification prevents the switch instead of being reported after the box rebuilt.

SyncCycleReceipt now carries epistemicLevel (Proved only when attested; Speculative
otherwise) + the attestation decision, so unattested switches are visibly Speculative
downstream and never laundered to Proved (partial SP-GATE-003).

Wired through daemon + cli (--attestation-file / --attestation-public-key). 129 tests
pass incl. blocked fixtures (no/wrong-version/unsigned/unverifiable) and execute-abort.
Copilot AI review requested due to automatic review settings August 2, 2026 18:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements SP-GATE-001 by adding a fail-closed deploy gate that requires a signed release attestation bound to the exact (org, content_view, version) before planning/executing a nixos-rebuild switch, and updates execution to abort on the first failed/timed-out step.

Changes:

  • Add release_attestation module and wire attestation verification into ContentViewSyncer.plan() (default: require attestation).
  • Make execute() fail closed by aborting subsequent steps after a failure/timeout (preventing an unintended switch).
  • Extend daemon/CLI wiring and add targeted test coverage for the new gate + abort-on-failure behavior.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/test_katello_client.py Opts existing plan/execute tests out of the new attestation gate where orthogonal.
tests/test_daemon.py Adds daemon-level assertion that unattested upgrades are blocked and version does not advance.
tests/test_content_sync_attestation.py New tests covering gate refusal cases, allowed attested case, receipt epistemic level, and execute abort behavior.
src/sourceos_syncd/release_attestation.py New attestation schema helpers + default minisign verifier (fail-closed) and decision logic.
src/sourceos_syncd/daemon.py Wires attestation loading from disk into daemon planning path.
src/sourceos_syncd/content_sync.py Adds attestation gating + receipt epistemic/attestation fields and abort-on-failure execution semantics.
src/sourceos_syncd/cli.py Adds CLI flags for attestation inputs and require/opt-out toggle; wires into plan/apply.
Suppressed comments (2)

src/sourceos_syncd/daemon.py:136

  • _load_attestation() currently swallows all OSError / ValueError and returns None. That makes malformed JSON or permission errors indistinguishable from "no attestation", which can significantly hinder debugging while still failing closed. Consider only treating missing files as None silently and logging other load/parse errors.
        try:
            with open(path, encoding="utf-8") as fh:
                return json.load(fh)
        except (OSError, ValueError):
            return None

src/sourceos_syncd/content_sync.py:228

  • When require_attestation=False, the code sets attestation=None, but the PR description and the nearby comment claim the opt-out is explicitly recorded in the receipt. As-is, downstream consumers cannot distinguish "gate explicitly disabled" from "gate not evaluated / older engine". Record an explicit opt-out marker in plan.attestation so receipts always show whether the switch was attested.
            epistemic_level=(decision.epistemic_level if decision else "Speculative"),
            attestation=(decision.to_dict() if decision else None),
        )

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +129 to +131
if not self._attestation_dir:
return None
path = os.path.join(self._attestation_dir, f"{manifest.content_view}-{manifest.version}.json")
Comment on lines +56 to +58
# 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
@mdheller
mdheller merged commit f734d9a into main Aug 2, 2026
5 checks passed
@mdheller
mdheller deleted the feat/sp-gate-001-deploy-attestation branch August 2, 2026 18:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants