diff --git a/docs/CODE_SIGNING_POLICY.md b/docs/CODE_SIGNING_POLICY.md index f3b416bed..c5619347a 100644 --- a/docs/CODE_SIGNING_POLICY.md +++ b/docs/CODE_SIGNING_POLICY.md @@ -79,8 +79,10 @@ Normal stable publication and stable dry runs select `signpath` directly. asset set from replacement. - Customer-facing image aliases, Helm indexes, paid-runtime pointers, and demo environments are not promoted until `gh release verify --repo - rcourtman/Pulse` validates GitHub's signed release attestation. Operators can - use the same command to verify the packet independently. + rcourtman/Pulse` validates GitHub's signed release attestation and `gh release + verify-asset --repo rcourtman/Pulse` binds the + downloaded activation marker to that attestation. Operators can use the same + commands to verify the packet and any downloaded release asset independently. ## Project roles diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 48123b40f..1557f3961 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -4418,9 +4418,13 @@ fail and compensate the still-mutable publication back to a marker-free draft. `scripts/verify-github-release-integrity.sh` is the shared post-publication check. It binds the release database ID, tag, exact source SHA, immutable state, and single digest-bearing activation marker, then requires `gh release verify` -to validate GitHub's signed release attestation. The source release verdict, -activation-only recovery, and `release-convergence.yml` must all use that -check. Convergence must not acquire the customer-promotion lease or mutate a -floating image tag, Helm index, paid-runtime pointer, or live environment until -the check passes. Repository release immutability must therefore be enabled -before merging or running this activation path. +to validate GitHub's signed release attestation. It must then download the +activation marker from that release and require `gh release verify-asset` to +bind the exact consumed bytes to the signed release attestation. Filename, +stored digest presence, and marker JSON identity are not substitutes for this +asset proof. The source release verdict, activation-only recovery, and +`release-convergence.yml` must all use that check. Convergence must not acquire +the customer-promotion lease or mutate a floating image tag, Helm index, +paid-runtime pointer, or live environment until the check passes. Repository +release immutability must therefore be enabled before merging or running this +activation path. diff --git a/frontend-modern/public/docs/CODE_SIGNING_POLICY.md b/frontend-modern/public/docs/CODE_SIGNING_POLICY.md index f3b416bed..c5619347a 100644 --- a/frontend-modern/public/docs/CODE_SIGNING_POLICY.md +++ b/frontend-modern/public/docs/CODE_SIGNING_POLICY.md @@ -79,8 +79,10 @@ Normal stable publication and stable dry runs select `signpath` directly. asset set from replacement. - Customer-facing image aliases, Helm indexes, paid-runtime pointers, and demo environments are not promoted until `gh release verify --repo - rcourtman/Pulse` validates GitHub's signed release attestation. Operators can - use the same command to verify the packet independently. + rcourtman/Pulse` validates GitHub's signed release attestation and `gh release + verify-asset --repo rcourtman/Pulse` binds the + downloaded activation marker to that attestation. Operators can use the same + commands to verify the packet and any downloaded release asset independently. ## Project roles diff --git a/scripts/release_control/verify_github_release_integrity_test.py b/scripts/release_control/verify_github_release_integrity_test.py index c597b54bb..f7544e1b6 100644 --- a/scripts/release_control/verify_github_release_integrity_test.py +++ b/scripts/release_control/verify_github_release_integrity_test.py @@ -17,7 +17,13 @@ SOURCE_SHA = "a" * 40 class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): - def run_verifier(self, release: dict, *, verification_succeeds: bool = True): + def run_verifier( + self, + release: dict, + *, + verification_succeeds: bool = True, + asset_verification_succeeds: bool = True, + ): with tempfile.TemporaryDirectory() as directory: root = Path(directory) calls = root / "calls" @@ -38,6 +44,21 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): printf '%s\\n' '{{"verified": true}}' exit {0 if verification_succeeds else 1} fi + if [ "$1 $2" = "release download" ]; then + while [ "$#" -gt 0 ]; do + if [ "$1" = --dir ]; then + mkdir -p "$2" + printf '%s\\n' '{{"schema_version": 1}}' > "$2/release-activation.json" + exit 0 + fi + shift + done + exit 65 + fi + if [ "$1 $2" = "release verify-asset" ]; then + printf '%s\\n' '{{"verified": true}}' + exit {0 if asset_verification_succeeds else 1} + fi exit 64 """ ), @@ -86,8 +107,9 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): def test_accepts_immutable_release_with_verified_attestation(self) -> None: result, calls = self.run_verifier(self.release()) self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("is immutable and attested", result.stdout) + self.assertIn("is immutable, attested, and activation-asset-bound", result.stdout) self.assertIn("release verify v6.5.0 --repo rcourtman/Pulse --format json", calls) + self.assertIn("release verify-asset v6.5.0", calls) def test_rejects_mutable_release_before_attestation(self) -> None: result, calls = self.run_verifier(self.release(immutable=False)) @@ -109,6 +131,14 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): self.assertNotEqual(result.returncode, 0) self.assertIn("attestation verification failed", result.stderr) + def test_rejects_activation_asset_outside_release_attestation(self) -> None: + result, calls = self.run_verifier( + self.release(), asset_verification_succeeds=False + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("activation asset verification failed", result.stderr) + self.assertIn("release verify-asset v6.5.0", calls) + if __name__ == "__main__": unittest.main() diff --git a/scripts/verify-github-release-integrity.sh b/scripts/verify-github-release-integrity.sh index 5d20b5c66..a5b2df31a 100755 --- a/scripts/verify-github-release-integrity.sh +++ b/scripts/verify-github-release-integrity.sh @@ -53,7 +53,12 @@ done release_json="$(mktemp)" attestation_json="$(mktemp)" -cleanup() { rm -f "$release_json" "$attestation_json"; } +activation_dir="$(mktemp -d)" +activation_asset="${activation_dir}/release-activation.json" +cleanup() { + rm -f "$release_json" "$attestation_json" + rm -rf "$activation_dir" +} trap cleanup EXIT gh api \ @@ -108,7 +113,42 @@ if ! jq -e 'type == "object" or type == "array"' "$attestation_json" >/dev/null; exit 1 fi +# Release verification proves that GitHub signed the immutable packet. Bind the +# activation marker that convergence consumes to that packet as a separate +# proof: verify-asset checks the downloaded bytes' digest against the signed +# release attestation rather than trusting filename and JSON identity alone. +downloaded=false +for attempt in $(seq 1 "$ATTESTATION_ATTEMPTS"); do + rm -f "$activation_asset" + if gh release download "$TAG" \ + --repo "$REPO" \ + --pattern release-activation.json \ + --dir "$activation_dir" && \ + [ -s "$activation_asset" ]; then + downloaded=true + break + fi + if [ "$attempt" -lt "$ATTESTATION_ATTEMPTS" ]; then + echo "Activation asset for ${TAG} is not downloadable yet (${attempt}/${ATTESTATION_ATTEMPTS}); retrying." >&2 + sleep "$ATTESTATION_RETRY_DELAY" + fi +done +if [ "$downloaded" != true ]; then + echo "GitHub release activation asset download failed for ${TAG}." >&2 + exit 1 +fi + +if ! gh release verify-asset "$TAG" "$activation_asset" \ + --repo "$REPO" --format json > "$attestation_json"; then + echo "GitHub release activation asset verification failed for ${TAG}." >&2 + exit 1 +fi +if ! jq -e 'type == "object" or type == "array"' "$attestation_json" >/dev/null; then + echo "GitHub release activation asset verification returned malformed JSON for ${TAG}." >&2 + exit 1 +fi + release_id="$(jq -r '.id' "$release_json")" source_sha="$(jq -r '.target_commitish' "$release_json")" asset_count="$(jq -r '.assets | length' "$release_json")" -echo "[OK] GitHub release ${TAG} is immutable and attested: release_id=${release_id} source_sha=${source_sha} assets=${asset_count}." +echo "[OK] GitHub release ${TAG} is immutable, attested, and activation-asset-bound: release_id=${release_id} source_sha=${source_sha} assets=${asset_count}."