feat(sync): deploy gate must refuse — bind switch to a signed release attestation (SP-GATE-001) - #53
Merged
Conversation
…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.
There was a problem hiding this comment.
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_attestationmodule and wire attestation verification intoContentViewSyncer.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/ValueErrorand 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 setsattestation=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 inplan.attestationso 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The hole (SP-GATE-001, from the SourceOS gate-gap audit)
sourceos-syncdplanned anixos-rebuild switchfor any newer content-view version Katello advertised. The only signature check was overnix-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:
plan()never bound the switch to an attestation of the version.execute()didn't abort on failure — if theminisign -Vstep returned non-zero, the loop kept going and still ran the switch, reportingoutcome=failedonly after the box already rebuilt.The fix (fail closed)
release_attestationmodule: binds the switch to aSignedArtifact-shaped attestation of the exact(org, content_view, version)(urn:srcos:content-view:…).require_attestationdefaults True:sourceos-boot's digest pin has, applied to promotion);minisignand returnsFalse(never assumes) when key or binary is absent.--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.SyncCycleReceiptcarriesepistemicLevel(Provedonly when attested, elseSpeculative) + 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.