From 42d1bfd9dfe2c24d4a449bd6a3009fd1a6aa8dce Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:10:28 +0200 Subject: [PATCH 1/2] Check weekly that release runs, files, Rekor entries and images match A new weekly workflow, release-integrity.yml, compares the runs of release.yml and container-image.yml with the published releases in both directions, checks every release file against SHA256SUMS, looks up every signed file in Rekor by its SHA-256 and checks that its certificate names release.yml at the release's tag, commit and run, and verifies each container image signature with cosign. A mismatch, or anything it cannot read, fails the run and opens or updates an issue labelled release-integrity; a pass closes it. docs/release-signing.md describes the check, what it cannot see and what to do when it alerts, and puts the Sigstore and SLSA item back in the list of who may change the signing identities. CONTRIBUTING and the risk assessment list the check. Refs #152 Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- .github/scripts/release-integrity-test.py | 152 ++++++ .github/scripts/release-integrity.py | 548 ++++++++++++++++++++++ .github/workflows/release-integrity.yml | 164 +++++++ CONTRIBUTING.md | 1 + docs/release-signing.md | 47 +- docs/risk-assessment.md | 2 +- 6 files changed, 910 insertions(+), 4 deletions(-) create mode 100644 .github/scripts/release-integrity-test.py create mode 100644 .github/scripts/release-integrity.py create mode 100644 .github/workflows/release-integrity.yml diff --git a/.github/scripts/release-integrity-test.py b/.github/scripts/release-integrity-test.py new file mode 100644 index 0000000..1765c92 --- /dev/null +++ b/.github/scripts/release-integrity-test.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Hodeitek S.L. +"""Tests of release-integrity.py that need no network: certificate parsing and the matching rules. + +Usage: python3 .github/scripts/release-integrity-test.py +""" + +import importlib.util +import pathlib +import unittest + +spec = importlib.util.spec_from_file_location( + "release_integrity", pathlib.Path(__file__).with_name("release-integrity.py") +) +ri = importlib.util.module_from_spec(spec) +spec.loader.exec_module(ri) + +REPO = "Hodeitek/hodeishield-cli" +COMMIT_041 = "b90bb412c45df7318e1300dfdb04f80e87ab77f5" +RUN_041 = 38235963722 + +# The certificate of the Rekor entry for SHA256SUMS of v0.4.1 (log index 3226122241), public data. +CERT_041 = b"""-----BEGIN CERTIFICATE----- +MIIHDzCCBpagAwIBAgIUWE8VP/HNaszjJPJvW74SgHLvfucwCgYIKoZIzj0EAwMw +NzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRl +cm1lZGlhdGUwHhcNMjYxMDEyMTAzMTU4WhcNMjYxMDEyMTA0MTU4WjAAMFkwEwYH +KoZIzj0CAQYIKoZIzj0DAQcDQgAEMqNSAObLo6cpTWUTKgPZip2AmZutq/BdQ7EI +GFM3ThzIef8qNB0zf9IVoQg7pXDetns1X/Gvvv6I50QWhd2ZJqOCBbUwggWxMA4G +A1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAfBgNVHSMEGDAWgBTf +0+nPViQRlvmo2OkoVaLGLhhkPzBoBgNVHREBAf8EXjBchlpodHRwczovL2dpdGh1 +Yi5jb20vSG9kZWl0ZWsvaG9kZWlzaGllbGQtY2xpLy5naXRodWIvd29ya2Zsb3dz +L3JlbGVhc2UueW1sQHJlZnMvdGFncy92MC40LjEwOQYKKwYBBAGDvzABAQQraHR0 +cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTASBgorBgEE +AYO/MAECBARwdXNoMDYGCisGAQQBg78wAQMEKGI5MGJiNDEyYzQ1ZGY3MzE4ZTEz +MDBkZmRiMDRmODBlODdhYjc3ZjUwFQYKKwYBBAGDvzABBAQHUmVsZWFzZTAmBgor +BgEEAYO/MAEFBBhIb2RlaXRlay9ob2RlaXNoaWVsZC1jbGkwHgYKKwYBBAGDvzAB +BgQQcmVmcy90YWdzL3YwLjQuMTA7BgorBgEEAYO/MAEIBC0MK2h0dHBzOi8vdG9r +ZW4uYWN0aW9ucy5naXRodWJ1c2VyY29udGVudC5jb20wagYKKwYBBAGDvzABCQRc +DFpodHRwczovL2dpdGh1Yi5jb20vSG9kZWl0ZWsvaG9kZWlzaGllbGQtY2xpLy5n +aXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sQHJlZnMvdGFncy92MC40LjEwOAYK +KwYBBAGDvzABCgQqDChiOTBiYjQxMmM0NWRmNzMxOGUxMzAwZGZkYjA0ZjgwZTg3 +YWI3N2Y1MB0GCisGAQQBg78wAQsEDwwNZ2l0aHViLWhvc3RlZDA7BgorBgEEAYO/ +MAEMBC0MK2h0dHBzOi8vZ2l0aHViLmNvbS9Ib2RlaXRlay9ob2RlaXNoaWVsZC1j +bGkwOAYKKwYBBAGDvzABDQQqDChiOTBiYjQxMmM0NWRmNzMxOGUxMzAwZGZkYjA0 +ZjgwZTg3YWI3N2Y1MCAGCisGAQQBg78wAQ4EEgwQcmVmcy90YWdzL3YwLjQuMTAa +BgorBgEEAYO/MAEPBAwMCjEzOTI0OTU3NTkwKwYKKwYBBAGDvzABEAQdDBtodHRw +czovL2dpdGh1Yi5jb20vSG9kZWl0ZWswGQYKKwYBBAGDvzABEQQLDAkxNDg0Mzgz +MjgwagYKKwYBBAGDvzABEgRcDFpodHRwczovL2dpdGh1Yi5jb20vSG9kZWl0ZWsv +aG9kZWlzaGllbGQtY2xpLy5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sQHJl +ZnMvdGFncy92MC40LjEwOAYKKwYBBAGDvzABEwQqDChiOTBiYjQxMmM0NWRmNzMx +OGUxMzAwZGZkYjA0ZjgwZTg3YWI3N2Y1MBQGCisGAQQBg78wARQEBgwEcHVzaDBf +BgorBgEEAYO/MAEVBFEMT2h0dHBzOi8vZ2l0aHViLmNvbS9Ib2RlaXRlay9ob2Rl +aXNoaWVsZC1jbGkvYWN0aW9ucy9ydW5zLzM4MjM1OTYzNzIyL2F0dGVtcHRzLzEw +FgYKKwYBBAGDvzABFgQIDAZwdWJsaWMwVwYKKwYBBAGDvzABGARJDEdyZXBvOkhv +ZGVpdGVrQDE0ODQzODMyOC9ob2RlaXNoaWVsZC1jbGlAMTM5MjQ5NTc1OTpyZWY6 +cmVmcy90YWdzL3YwLjQuMTCBiwYKKwYBBAHWeQIEAgR9BHsAeQB3AN09MGrGxxEy +YxkeHJlnNwKiSl643jyt/4eKcoAvKe6OAAABoS+rT98AAAQDAEgwRgIhANthYw/v +Cw9kgTo0isgfmp6O+YLofMuiq5pH5IOF0sZ+AiEAymmgynFJ5b3K625NWO6GcR2H +NzpYKdtUT9U1mxlzTOUwCgYIKoZIzj0EAwMDZwAwZAIwLpVXYbtm/e3BxGbXZooo +78R+2TfpZmEMySsqMcGmiz0o/SrBh/zyPFQ2pjIQrP3zAjBmeXHzGhn+2NiLw/l0 +HRDT5K2jj/SVAV+Ew477J6XEFQbuJ93C16d7eFGa6t9LbUo= +-----END CERTIFICATE----- +""" + + +def certificate(tag="v0.4.1", commit=COMMIT_041, run=RUN_041, issuer=ri.ISSUER, repo=REPO): + return { + "san": f"https://github.com/{repo}/.github/workflows/release.yml@refs/tags/{tag}", + "issuer": issuer, + "commit": commit, + "run": f"https://github.com/{repo}/actions/runs/{run}/attempts/1", + } + + +RELEASES = { + "v0.4.1": (COMMIT_041, {RUN_041}), + "v0.4.0": ("3405f04" + "0" * 33, {38194904546}), +} + + +class Certificate(unittest.TestCase): + def test_reads_the_identity_of_a_real_release_certificate(self): + self.assertEqual(ri.identity(CERT_041), certificate()) + + def test_a_truncated_certificate_is_an_error_not_an_identity(self): + der = ri.base64.b64decode(b"".join(CERT_041.strip().splitlines()[1:-1])) + with self.assertRaises(ValueError): + ri.identity(der[:200]) + + +class Rekor(unittest.TestCase): + def judge(self, *certificates, tag="v0.4.1"): + entries = [(index, c) for index, c in enumerate(certificates)] + return ri.judge_rekor(entries, repository=REPO, tag=tag, releases=RELEASES) + + def test_the_release_s_own_entry_matches(self): + self.assertEqual(self.judge(certificate()), (True, [])) + + def test_no_entry_of_the_release_is_not_a_match(self): + self.assertEqual(self.judge(), (False, [])) + + def test_an_entry_of_another_published_release_is_not_a_mismatch(self): + other = certificate("v0.4.0", RELEASES["v0.4.0"][0], 38194904546) + self.assertEqual(self.judge(certificate(), other), (True, [])) + + def test_entries_made_by_others_are_ignored(self): + self.assertEqual(self.judge(certificate(repo="someone/else"), None), (False, [])) + + def test_an_entry_with_this_identity_from_an_unknown_run_is_reported(self): + matched, problems = self.judge(certificate(), certificate(run=1)) + self.assertTrue(matched) + self.assertEqual(len(problems), 1) + self.assertIn("matches no published release", problems[0]) + self.assertIn("/actions/runs/1/", problems[0]) + + def test_a_tag_without_a_release_a_wrong_commit_or_issuer_is_reported(self): + for wrong in ( + certificate(tag="v9.9.9"), + certificate(commit="0" * 40), + certificate(issuer="https://example.test"), + ): + with self.subTest(wrong=wrong): + matched, problems = self.judge(wrong) + self.assertFalse(matched) + self.assertEqual(len(problems), 1) + + def test_the_identity_prefix_is_compared_without_case(self): + matched, problems = self.judge(certificate(repo="hodeitek/HodeiShield-CLI")) + self.assertFalse(matched) + self.assertEqual(len(problems), 1) + + +class Parsing(unittest.TestCase): + def test_sha256sums_in_text_and_binary_mode(self): + text = "a" * 64 + " one.tar.gz\n" + "b" * 64 + " *two.zip\n\n" + self.assertEqual( + ri.parse_sha256sums(text), {"one.tar.gz": "a" * 64, "two.zip": "b" * 64} + ) + + def test_a_line_that_is_not_a_checksum_is_unreadable(self): + with self.assertRaises(ri.Unreadable): + ri.parse_sha256sums("not a checksum line\n") + + def test_stable_versions_only(self): + self.assertEqual(ri.stable_version("v0.4.1"), (0, 4, 1)) + self.assertIsNone(ri.stable_version("v0.5.0-rc.1")) + self.assertIsNone(ri.stable_version("0.4.1")) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/release-integrity.py b/.github/scripts/release-integrity.py new file mode 100644 index 0000000..4cdbe0c --- /dev/null +++ b/.github/scripts/release-integrity.py @@ -0,0 +1,548 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Hodeitek S.L. +"""Checks that every release run, release file, Rekor entry and container image belongs together. + +Usage: release-integrity.py + + is "owner/name". is the cosign binary used to verify the container images. +GH_TOKEN, if set, is used for the GitHub API (read-only: releases, tags, workflow runs). + +What is checked, in both directions: + runs -> releases every run of release.yml started by a tag, and every run of container-image.yml, + belongs to a published release, at the commit of its tag; + releases -> runs every published release has a successful release.yml run for its tag at that + commit, and from 0.4.0 a successful container-image.yml run; + files every release file matches its line in the release's SHA256SUMS, and nothing + else is attached; + Rekor every file the release signs (it has a `.sigstore.json` bundle) is looked up in + Rekor by its SHA-256. At least one entry must carry a certificate for + release.yml at the release's tag, from the GitHub Actions issuer, with the tag's + commit and a run of that tag. An entry with this repository's identity that + names anything else is reported. Entries made by others are ignored: anyone can + sign a public file; + images every version tag of the container image is a published release, every release + from 0.4.0 has one, and the signature on its digest verifies with cosign against + container-image.yml at that tag. + +Rekor cannot be searched by certificate identity, so a signature made with the identity for a file +that was never published is not seen here (see #158). + +Prints the report on standard output. Exit status: 0 when everything matches, 1 when something does +not match or cannot be read. Nothing that cannot be read is ever treated as a pass. +""" + +import base64 +import hashlib +import json +import os +import re +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +API = "https://api.github.com" +REKOR = "https://rekor.sigstore.dev" +REGISTRY = "ghcr.io" +ISSUER = "https://token.actions.githubusercontent.com" +FIRST_IMAGE = (0, 4, 0) + +# Fulcio certificate extensions (https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md). +OID_SAN = "2.5.29.17" +OID_ISSUER_V1 = "1.3.6.1.4.1.57264.1.1" # raw string +OID_COMMIT = "1.3.6.1.4.1.57264.1.3" # raw string +OID_ISSUER = "1.3.6.1.4.1.57264.1.8" # DER UTF8String +OID_RUN = "1.3.6.1.4.1.57264.1.21" # DER UTF8String + +# Runs that belong to no published release but are known and accepted, by run id, each with its +# reason. Keep it empty unless a reviewed pull request adds an entry. +ACCEPTED_RUNS: dict[int, str] = {} + +VERSION_TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$") +IMAGE_SIGNATURE_TAG = re.compile(r"^sha256-[0-9a-f]{64}(\.sig|\.att|\.sbom)?$") + + +class Unreadable(Exception): + """Something the check needs could not be read.""" + + +# --- DER, just enough to read the extensions of an X.509 certificate -------------------------- + + +def der_items(data: bytes): + """Yields (tag, content) for each TLV at the top level of `data`.""" + i = 0 + while i < len(data): + tag = data[i] + i += 1 + if i >= len(data): + raise ValueError("truncated DER") + length = data[i] + i += 1 + if length & 0x80: + count = length & 0x7F + if count == 0 or count > 4 or i + count > len(data): + raise ValueError("bad DER length") + length = int.from_bytes(data[i : i + count], "big") + i += count + if i + length > len(data): + raise ValueError("truncated DER") + yield tag, data[i : i + length] + i += length + + +def der_oid(content: bytes) -> str: + first = content[0] + parts = [str(min(first // 40, 2)), str(first - 40 * min(first // 40, 2))] + value = 0 + for byte in content[1:]: + value = (value << 7) | (byte & 0x7F) + if not byte & 0x80: + parts.append(str(value)) + value = 0 + return ".".join(parts) + + +def certificate_extensions(der: bytes) -> dict[str, bytes]: + """The extensions of a certificate, as OID -> extension value (the OCTET STRING's content).""" + ((tag, certificate),) = der_items(der) + if tag != 0x30: + raise ValueError("not a certificate") + tbs = next(der_items(certificate))[1] + for tag, content in der_items(tbs): + if tag == 0xA3: # [3] extensions + extensions = next(der_items(content))[1] + found = {} + for _, extension in der_items(extensions): + parts = list(der_items(extension)) + oid = der_oid(parts[0][1]) + found[oid] = parts[-1][1] # skips the optional `critical` BOOLEAN + return found + return {} + + +def der_text(value: bytes) -> str: + """A Fulcio extension value: a DER UTF8String (newer OIDs) or the raw text (1.1 to 1.6).""" + if value[:1] == b"\x0c": + ((_, text),) = der_items(value) + return text.decode() + return value.decode() + + +def san_uris(value: bytes) -> list[str]: + ((_, names),) = der_items(value) + return [content.decode() for tag, content in der_items(names) if tag == 0x86] + + +def identity(pem_or_der: bytes) -> dict[str, str]: + """The signer identity a Fulcio certificate states.""" + der = pem_or_der + if pem_or_der.lstrip().startswith(b"-----BEGIN"): + body = b"".join( + line for line in pem_or_der.strip().splitlines() if not line.startswith(b"-----") + ) + der = base64.b64decode(body) + extensions = certificate_extensions(der) + issuer = extensions.get(OID_ISSUER) or extensions.get(OID_ISSUER_V1) + return { + "san": ", ".join(san_uris(extensions[OID_SAN])) if OID_SAN in extensions else "", + "issuer": der_text(issuer) if issuer else "", + "commit": der_text(extensions[OID_COMMIT]) if OID_COMMIT in extensions else "", + "run": der_text(extensions[OID_RUN]) if OID_RUN in extensions else "", + } + + +# --- HTTP --------------------------------------------------------------------------------------- + + +def http(url: str, *, data=None, headers=None, method=None, token=None): + """Returns (status, headers, body). Retries rate limits and server errors a few times.""" + headers = dict(headers or {}) + headers.setdefault("User-Agent", "hodeishield-release-integrity") + if token: + headers["Authorization"] = f"Bearer {token}" + for attempt in range(4): + request = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=60) as response: + return response.status, response.headers, response.read() + except urllib.error.HTTPError as error: + if error.code in (429, 500, 502, 503, 504) and attempt < 3: + time.sleep(2 ** (attempt + 1)) + continue + return error.code, error.headers, error.read() + except urllib.error.URLError as error: + if attempt < 3: + time.sleep(2 ** (attempt + 1)) + continue + raise Unreadable(f"{url}: {error.reason}") from error + raise AssertionError("unreachable") + + +class GitHub: + def __init__(self, repository: str, token: str | None): + self.repository = repository + self.token = token + + def get(self, path: str): + status, _, body = http( + f"{API}/repos/{self.repository}/{path}", + headers={"Accept": "application/vnd.github+json"}, + token=self.token, + ) + if status != 200: + raise Unreadable(f"GET {path}: HTTP {status}") + return json.loads(body) + + def pages(self, path: str, key: str | None = None): + items, page = [], 1 + separator = "&" if "?" in path else "?" + while True: + data = self.get(f"{path}{separator}per_page=100&page={page}") + batch = data[key] if key else data + items.extend(batch) + if len(batch) < 100: + return items + page += 1 + + def tag_commit(self, tag: str) -> str: + ref = self.get(f"git/ref/tags/{urllib.parse.quote(tag)}")["object"] + while ref["type"] == "tag": + ref = self.get(f"git/tags/{ref['sha']}")["object"] + if ref["type"] != "commit": + raise Unreadable(f"tag {tag} points to a {ref['type']}") + return ref["sha"] + + +def download(url: str) -> bytes: + status, _, body = http(url) + if status != 200: + raise Unreadable(f"{url}: HTTP {status}") + return body + + +def parse_sha256sums(text: str) -> dict[str, str]: + sums = {} + for line in text.splitlines(): + if not line.strip(): + continue + match = re.fullmatch(r"([0-9a-f]{64}) [ *](\S+)", line) + if not match: + raise Unreadable(f"SHA256SUMS line not understood: {line!r}") + sums[match.group(2)] = match.group(1) + return sums + + +# --- Rekor -------------------------------------------------------------------------------------- + + +def rekor_entries(digest: str): + """The Rekor entries for an artifact SHA-256, as (log index, certificate identity or None).""" + status, _, body = http( + f"{REKOR}/api/v1/index/retrieve", + data=json.dumps({"hash": f"sha256:{digest}"}).encode(), + headers={"Content-Type": "application/json", "Accept": "application/json"}, + method="POST", + ) + if status != 200: + raise Unreadable(f"Rekor index lookup for {digest}: HTTP {status}") + entries = [] + for uuid in json.loads(body): + status, _, body = http(f"{REKOR}/api/v1/log/entries/{uuid}") + if status != 200: + raise Unreadable(f"Rekor entry {uuid}: HTTP {status}") + entry = next(iter(json.loads(body).values())) + spec = json.loads(base64.b64decode(entry["body"])) + certificate = None + if spec.get("kind") == "hashedrekord": + content = spec["spec"]["signature"].get("publicKey", {}).get("content") + if content: + try: + certificate = identity(base64.b64decode(content)) + except (ValueError, KeyError, StopIteration, UnicodeDecodeError): + certificate = None # a public key, not a certificate: not this workflow's + entries.append((entry["logIndex"], certificate)) + return entries + + +def signed_by(certificate, *, repository, tag, commit, run_ids) -> bool: + """Whether a certificate is release.yml's at `tag`, from the GitHub Actions issuer, at `commit`, + in one of `run_ids`.""" + run = re.search(r"/actions/runs/(\d+)/", certificate["run"]) + return ( + certificate["san"] == f"https://github.com/{repository}/.github/workflows/release.yml@refs/tags/{tag}" + and certificate["issuer"] == ISSUER + and certificate["commit"] == commit + and bool(run) + and int(run.group(1)) in run_ids + ) + + +def judge_rekor(entries, *, repository, tag, releases) -> tuple[bool, list[str]]: + """Whether an entry was made by the release `tag`, and every entry with this repository's + identity that no published release made. + + `releases` maps each published tag to (commit, ids of its successful release.yml runs). A file + that two releases ship unchanged (install.sh, for one) is signed by both, so an entry of another + published release is not a mismatch. + """ + prefix = f"https://github.com/{repository}/".lower() + matched, problems = False, [] + for log_index, certificate in entries: + if not certificate or not certificate["san"].lower().startswith(prefix): + continue + made_by = [ + other + for other, (commit, run_ids) in releases.items() + if signed_by(certificate, repository=repository, tag=other, commit=commit, run_ids=run_ids) + ] + if tag in made_by: + matched = True + elif not made_by: + problems.append( + f"Rekor log index {log_index} matches no published release: identity " + f"{certificate['san']}, issuer {certificate['issuer'] or 'missing'}, commit " + f"{certificate['commit'] or 'missing'}, run {certificate['run'] or 'missing'}" + ) + return matched, problems + + +# --- Container images --------------------------------------------------------------------------- + + +def registry_token(image: str) -> str: + status, _, body = http(f"https://{REGISTRY}/token?scope=repository:{image}:pull") + if status != 200: + raise Unreadable(f"anonymous registry token: HTTP {status}") + return json.loads(body)["token"] + + +def image_tags(image: str, token: str) -> list[str]: + tags, url = [], f"https://{REGISTRY}/v2/{image}/tags/list?n=1000" + while url: + status, headers, body = http(url, token=token) + if status != 200: + raise Unreadable(f"tags of {image}: HTTP {status}") + tags.extend(json.loads(body).get("tags") or []) + link = re.search(r"<([^>]+)>;\s*rel=\"next\"", headers.get("Link", "")) + url = urllib.parse.urljoin(f"https://{REGISTRY}", link.group(1)) if link else None + return tags + + +def image_digest(image: str, tag: str, token: str) -> str: + accept = ", ".join( + [ + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", + ] + ) + status, headers, _ = http( + f"https://{REGISTRY}/v2/{image}/manifests/{tag}", + headers={"Accept": accept}, + method="HEAD", + token=token, + ) + digest = headers.get("Docker-Content-Digest", "") if status == 200 else "" + if not re.fullmatch(r"sha256:[0-9a-f]{64}", digest): + raise Unreadable(f"digest of {image}:{tag}: HTTP {status}") + return digest + + +def cosign_verifies(cosign: str, reference: str, identity_uri: str) -> tuple[bool, str]: + result = subprocess.run( + [ + cosign, + "verify", + reference, + "--certificate-identity", + identity_uri, + "--certificate-oidc-issuer", + ISSUER, + "--certificate-github-workflow-trigger", + "release", + ], + capture_output=True, + text=True, + timeout=300, + check=False, + ) + return result.returncode == 0, (result.stderr.strip().splitlines() or [""])[-1] + + +# --- The check ---------------------------------------------------------------------------------- + + +def stable_version(tag: str): + """(major, minor, patch) of a stable release tag such as v0.4.1; None for anything else.""" + match = VERSION_TAG.match(tag) + if not match or match.group(4): + return None + return tuple(int(part) for part in match.groups()[:3]) + + +def check(repository: str, cosign: str, github: GitHub, out) -> bool: + problems: list[str] = [] + + def problem(text: str): + problems.append(text) + print(f"MISMATCH: {text}", file=out) + + releases = [r for r in github.pages("releases") if not r["draft"]] + print(f"{len(releases)} published releases.", file=out) + commits = {r["tag_name"]: github.tag_commit(r["tag_name"]) for r in releases} + + release_runs = github.pages("actions/workflows/release.yml/runs", "workflow_runs") + image_runs = github.pages("actions/workflows/container-image.yml/runs", "workflow_runs") + print( + f"{len(release_runs)} runs of release.yml, {len(image_runs)} of container-image.yml.", + file=out, + ) + + # runs -> releases + for run in release_runs: + if run["event"] == "workflow_dispatch": + continue # a manual run is always a dry run: it signs and publishes nothing + where = f"release.yml run {run['id']} ({run['event']}, {run['head_branch']}, {run['conclusion']})" + if run["id"] in ACCEPTED_RUNS: + print(f"accepted: {where}: {ACCEPTED_RUNS[run['id']]}", file=out) + elif run["event"] != "push": + problem(f"{where}: unexpected event") + elif run["head_branch"] not in commits: + problem(f"{where}: no published release has this tag") + elif run["head_sha"] != commits[run["head_branch"]]: + problem(f"{where}: commit {run['head_sha']} is not the tag's {commits[run['head_branch']]}") + for run in image_runs: + where = f"container-image.yml run {run['id']} ({run['event']}, {run['head_branch']}, {run['conclusion']})" + if run["id"] in ACCEPTED_RUNS: + print(f"accepted: {where}: {ACCEPTED_RUNS[run['id']]}", file=out) + elif run["event"] != "release": + problem(f"{where}: unexpected event") + elif run["head_branch"] not in commits: + problem(f"{where}: no published release has this tag") + elif run["head_sha"] != commits[run["head_branch"]]: + problem(f"{where}: commit {run['head_sha']} is not the tag's {commits[run['head_branch']]}") + + image = repository.lower() + token = registry_token(image) + tags = image_tags(image, token) + + # Each published tag, its commit and its successful release.yml runs. + signers = { + tag: ( + commit, + { + run["id"] + for run in release_runs + if run["event"] == "push" + and run["head_branch"] == tag + and run["head_sha"] == commit + and run["conclusion"] == "success" + }, + ) + for tag, commit in commits.items() + } + + # releases -> runs, files, Rekor, image + for release in releases: + tag, commit = release["tag_name"], commits[release["tag_name"]] + version = stable_version(tag) + if not signers[tag][1]: + problem(f"{tag}: no successful release.yml run for the tag at {commit}") + + assets = {asset["name"]: asset for asset in release["assets"]} + if "SHA256SUMS" not in assets: + problem(f"{tag}: no SHA256SUMS") + continue + sums_bytes = download(assets["SHA256SUMS"]["browser_download_url"]) + sums = parse_sha256sums(sums_bytes.decode()) + digests = {"SHA256SUMS": hashlib.sha256(sums_bytes).hexdigest()} + for name, expected in sums.items(): + asset = assets.get(name) + if not asset: + problem(f"{tag}: {name} is in SHA256SUMS but not attached") + continue + digest = (asset.get("digest") or "").removeprefix("sha256:") + if not digest: + digest = hashlib.sha256(download(asset["browser_download_url"])).hexdigest() + digests[name] = digest + if digest != expected: + problem(f"{tag}: {name} has SHA-256 {digest}, SHA256SUMS says {expected}") + for name in assets: + if name not in sums and name != "SHA256SUMS" and not name.endswith( + (".sigstore.json", ".intoto.jsonl") + ): + problem(f"{tag}: {name} is attached but not in SHA256SUMS") + + signed = [name for name in digests if f"{name}.sigstore.json" in assets] + if not signed: + problem(f"{tag}: no file has a Sigstore bundle") + for name in signed: + matched, wrong = judge_rekor( + rekor_entries(digests[name]), repository=repository, tag=tag, releases=signers + ) + for text in wrong: + problem(f"{tag}: {name} (sha256:{digests[name]}): {text}") + if not matched: + problem( + f"{tag}: {name} (sha256:{digests[name]}): no Rekor entry from release.yml at " + f"this tag, commit and run" + ) + print(f"{tag}: {len(sums)} files, {len(signed)} signed, checked.", file=out) + + if version and version >= FIRST_IMAGE: + image_tag = tag[1:] + if not any( + run["event"] == "release" + and run["head_branch"] == tag + and run["head_sha"] == commit + and run["conclusion"] == "success" + for run in image_runs + ): + problem(f"{tag}: no successful container-image.yml run") + if image_tag not in tags: + problem(f"{tag}: the container image has no tag {image_tag}") + else: + digest = image_digest(image, image_tag, token) + ok, detail = cosign_verifies( + cosign, + f"{REGISTRY}/{image}@{digest}", + f"https://github.com/{repository}/.github/workflows/container-image.yml@refs/tags/{tag}", + ) + if ok: + print(f"{tag}: image {digest} verified.", file=out) + else: + problem(f"{tag}: image {digest} does not verify: {detail}") + + # image -> releases + for image_tag in tags: + if IMAGE_SIGNATURE_TAG.match(image_tag): + continue + if f"v{image_tag}" not in commits: + problem(f"image tag {image_tag}: no published release v{image_tag}") + + print( + f"{len(problems)} mismatch(es)." if problems else "Everything matches.", + file=out, + ) + return not problems + + +def main(argv: list[str]) -> int: + if len(argv) != 3 or "/" not in argv[1]: + print(__doc__.split("\n\n")[1], file=sys.stderr) + return 1 + github = GitHub(argv[1], os.environ.get("GH_TOKEN") or None) + try: + return 0 if check(argv[1], argv[2], github, sys.stdout) else 1 + except (Unreadable, ValueError, KeyError) as error: + print(f"UNREADABLE: {error}", file=sys.stdout) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.github/workflows/release-integrity.yml b/.github/workflows/release-integrity.yml new file mode 100644 index 0000000..4339665 --- /dev/null +++ b/.github/workflows/release-integrity.yml @@ -0,0 +1,164 @@ +name: Release integrity + +# Once a week (and on demand) checks that the release runs, the published releases, their files, the +# Rekor transparency log and the container images all belong together, in both directions (see +# .github/scripts/release-integrity.py and docs/release-signing.md). A run of the release workflow +# for a tag that is not a published release, a release file that does not match its SHA256SUMS, or a +# Rekor entry with this repository's identity that no published release made, is a mismatch. +# +# It reads public data and the run history only, uses no secret and no environment, and writes +# nothing but an issue labelled `release-integrity`: opened (or commented on) while the check fails, +# and closed when it passes again. It fails closed: anything that cannot be read is a failure. +# +# Rekor cannot be searched by certificate identity, so a signature made with the release identity +# for a file that was never published is not seen here (#158). +on: + schedule: + - cron: "23 5 * * 1" # Mondays, 05:23 UTC + workflow_dispatch: + pull_request: + paths: + - ".github/scripts/release-integrity*" + - ".github/workflows/release-integrity.yml" + +permissions: {} + +concurrency: + group: release-integrity-${{ github.ref }} + cancel-in-progress: false + +env: + LABEL: release-integrity + +jobs: + check: + name: check the releases + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read # the checkout, the releases and the tags + actions: read # the runs of release.yml and container-image.yml, and of this workflow + issues: write # the issue and its label; not used on a pull request + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Test the check + run: python3 -I .github/scripts/release-integrity-test.py + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + with: + cosign-release: "v3.1.3" + + # GitHub disables scheduled workflows of a repository without activity for 60 days. A long gap + # since the previous run is reported with the result, because a check that did not run cannot + # have noticed anything. + - name: Look for a gap since the previous run + if: github.event_name != 'pull_request' + id: gap + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + unreadable="The time of the check's previous run could not be read." + if ! last=$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/release-integrity.yml/runs?status=completed&per_page=30" \ + --jq '[.workflow_runs[] | select(.event != "pull_request") | select(.conclusion == "success" or .conclusion == "failure")] | sort_by(.created_at) | last | .created_at // empty'); then + echo "::warning::$unreadable" + echo "note=$unreadable" >> "$GITHUB_OUTPUT" + exit 0 + fi + [ -n "$last" ] || exit 0 + now=$(date -u +%Y-%m-%dT%H:%M:%SZ) + if ! last_epoch=$(date -d "$last" +%s); then + echo "::warning::$unreadable" + echo "note=$unreadable" >> "$GITHUB_OUTPUT" + exit 0 + fi + days=$(( ($(date +%s) - last_epoch) / 86400 )) + if [ "$days" -gt 9 ]; then + note="The check did not run between $last and $now ($days days). Scheduled workflows are disabled after 60 days without activity in the repository, so releases may have gone unchecked." + echo "::warning::$note" + echo "note=$note" >> "$GITHUB_OUTPUT" + fi + + - name: Check the releases + id: check + env: + GH_TOKEN: ${{ github.token }} + run: | + set -uo pipefail + report="$RUNNER_TEMP/report.txt" + status=0 + python3 -I .github/scripts/release-integrity.py "$GITHUB_REPOSITORY" "$(command -v cosign)" > "$report" 2>&1 || status=$? + cat "$report" + if [ "$status" = 0 ]; then result=pass; else result=fail; fi + echo "result=$result" >> "$GITHUB_OUTPUT" + + - name: Report the result + if: "!cancelled() && github.event_name != 'pull_request'" + env: + GH_TOKEN: ${{ github.token }} + RESULT: ${{ steps.check.outputs.result }} + GAP: ${{ steps.gap.outputs.note }} + run: | + set -euo pipefail + report="$RUNNER_TEMP/report.txt" + # An earlier step may have failed before the check wrote its report. + print_report() { + if [ -f "$report" ]; then + head -c 6000 "$report" + else + echo "No report: an earlier step failed before the check ran (see the run)." + fi + } + run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + { + echo "### Release integrity: ${RESULT:-fail}" + echo + echo '```' + print_report + echo '```' + [ -z "$GAP" ] || { echo; echo "$GAP"; } + } >> "$GITHUB_STEP_SUMMARY" + + issue=$(gh issue list -R "$GITHUB_REPOSITORY" --label "$LABEL" --state open --json number --jq '.[0].number // empty') + case ${RESULT:-fail} in + pass) + if [ -n "$issue" ]; then + gh issue close "$issue" -R "$GITHUB_REPOSITORY" --reason completed \ + --comment "The releases, their runs, their files, Rekor and the container images match again ($run_url)." + fi + ;; + fail) + { + echo "The weekly release integrity check found something that does not match, or could not read what it needs." + echo "Follow \"If the release workflow identity is compromised\" in docs/release-signing.md until the cause is known." + echo + echo '```' + print_report + echo '```' + [ -z "$GAP" ] || { echo; echo "$GAP"; } + echo + echo "Run: $run_url" + } > "$RUNNER_TEMP/issue.md" + if [ -n "$issue" ]; then + gh issue comment "$issue" -R "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/issue.md" + else + gh label list -R "$GITHUB_REPOSITORY" --search "$LABEL" --json name --jq '.[].name' | grep -qx "$LABEL" || + gh label create "$LABEL" -R "$GITHUB_REPOSITORY" --color B60205 \ + --description "A release run, file, Rekor entry or image that does not match a published release" + gh issue create -R "$GITHUB_REPOSITORY" --label "$LABEL" \ + --title "The release integrity check found a mismatch" \ + --body-file "$RUNNER_TEMP/issue.md" + fi + ;; + esac + + - name: Fail if the check failed + if: "!cancelled() && steps.check.outputs.result != 'pass'" + run: | + echo "::error::The release integrity check did not pass; see the report above and the issue labelled release-integrity." + exit 1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5adfc3a..420f905 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -238,6 +238,7 @@ Scheduled runs use `main`, the default branch, every Monday. | Dependabot alerts and security updates | Dependencies against the GitHub Advisory Database, which includes RustSec | When an advisory is published or a dependency changes | Security tab, and a pull request when a fixed version exists | | Repository builder tests (`repo-builder-tests.yml`) | The APT and DNF repository builder refuses forged packages and indexes | Every change to `packaging/repo`, and weekly | The run | | Repository freshness (`repo-freshness.yml`) | The published APT and DNF repositories are signed and current | Weekly, once `packaging/gpg.key` is committed | An issue labelled `repo-freshness` | +| Release integrity (`release-integrity.yml`) | Release runs, published releases, their files, their Rekor entries (looked up by SHA-256) and the container image signatures match, in both directions ([docs/release-signing.md](docs/release-signing.md#weekly-release-integrity-check)) | Weekly, on demand, and on every change to the check | An issue labelled `release-integrity` | | Proxy and TLS (`proxy-tls.yml`) | Proxy and TLS inspection behaviour against a TLS-inspecting proxy | Every change to the CLI, the API client or `Cargo.lock` | The run | | Dependency review (by hand) | The pins Dependabot does not cover, `cargo outdated`, actions pinned by commit | Monthly | A pull request | diff --git a/docs/release-signing.md b/docs/release-signing.md index 9bfb8f0..f9c37e1 100644 --- a/docs/release-signing.md +++ b/docs/release-signing.md @@ -121,6 +121,8 @@ separately in [#114](https://github.com/Hodeitek/hodeishield-cli/issues/114). Revoking a certificate needs that role and is done in the Azure portal. The app registration has no app owners, so its federated credentials can be changed only by holders of the directory roles Global Administrator, Application Administrator and Cloud Application Administrator. +- **Sigstore and SLSA.** No credential exists to hold or change. The identity follows from the + repository, the workflow file and the tag. ### Known gaps @@ -133,8 +135,45 @@ separately in [#114](https://github.com/Hodeitek/hodeishield-cli/issues/114). - **Shared app registration.** Deleting the app registration, or changing anything other than this repository's federated credential, also affects the other project's release signing. The steps below change only this repository's federated credential. -- **Sigstore and SLSA.** No credential exists to hold or change. The identity follows from the - repository, the workflow file and the tag. + +## Weekly release integrity check + +The `release-integrity.yml` workflow runs every Monday, and on demand +(`.github/scripts/release-integrity.py`). It compares, in both directions: + +- **Runs and releases.** Every run of `release.yml` started by a tag, and every run of + `container-image.yml`, must be for a published release, at the commit of its tag. Every published + release must have a successful `release.yml` run for its tag at that commit, and from 0.4.0 a + successful `container-image.yml` run. Manual runs of `release.yml` are dry runs and are skipped. +- **Files.** Every file of a release must match its line in the release's `SHA256SUMS`, and nothing + but the signature bundles and the provenance may be attached besides. +- **Rekor.** Every file a release signs is looked up in Rekor by its SHA-256. At least one entry must + carry the certificate of `release.yml` at the release's tag, from the GitHub Actions issuer, with + the tag's commit and one of that tag's runs. The certificate names the run, so each published file + is tied to the run that signed it. An entry with this repository's identity that no published + release made is a mismatch. A file that two releases ship unchanged, such as `install.sh`, is + signed by both, and that is not a mismatch. +- **Container images.** Every version tag of `ghcr.io/hodeitek/hodeishield-cli` must be a published + release, every release from 0.4.0 must have one, and the signature on its digest must verify with + cosign against `container-image.yml` at that tag. + +A mismatch, or anything the check cannot read, fails the run and opens an issue labelled +`release-integrity`, or comments on the open one. The next run that passes closes it. + +What the check cannot see: + +- A signature made with the release identity for a file that was never published. Rekor cannot be + searched by certificate identity + ([#158](https://github.com/Hodeitek/hodeishield-cli/issues/158)). +- A run deleted from the run history: repository administrators can delete runs. Each published + file is still tied to its run through its Rekor entry. +- A draft release: only published releases count. A draft made by a rogue tag still shows as a run + whose tag is not a published release. + +When it alerts, follow [If the release workflow identity is compromised](#if-the-release-workflow-identity-is-compromised) +until the cause is known. A mismatch that turns out to be legitimate, such as a tag run that failed +and whose tag was then moved before the release, is accepted by adding the run to `ACCEPTED_RUNS` in +the script, with its reason, through a reviewed pull request. ## If the Windows signing is compromised @@ -223,7 +262,9 @@ advisory, which names the tags not to trust. 2. **Find what is affected.** List the `v*` tags and the releases, drafts included (`gh release list`), the Release workflow runs with the event and the user that started them, and the changes to `.github/workflows/release.yml` in the tagged commits. For each release, record - its tag, commit and `SHA256SUMS`. A file found elsewhere can be looked up in the Rekor log by its + its tag, commit and `SHA256SUMS`. The report of the + [weekly release integrity check](#weekly-release-integrity-check) lists the runs, files and Rekor + entries that do not match. A file found elsewhere can be looked up in the Rekor log by its SHA-256 (`rekor-cli search --sha `, or [search.sigstore.dev](https://search.sigstore.dev)), which shows the identity and commit in its certificate. 3. **Remove or mark the releases.** diff --git a/docs/risk-assessment.md b/docs/risk-assessment.md index 39f3c39..cc9224e 100644 --- a/docs/risk-assessment.md +++ b/docs/risk-assessment.md @@ -59,7 +59,7 @@ or plug-in interface. | T6 | A script or a repository leaks the API key | The key is read from the environment, never from a flag, so it is not on a command line; the CLI never prints it, and `--verbose` logs no header ([README.md](../README.md#authenticate), `client.rs`, `RequestEvent`). [user-information.md](user-information.md#security-properties-and-secure-use) tells users to keep it in a secret store and to give it only the read scopes needed | | T7 | The CLI changes data in the user's organisation | The API client has only `GET` (`client.rs`; "Only `GET` requests exist in this client"). The only other requests are the sign-in, token refresh and revocation calls to the app's OAuth endpoints (`auth/oauth.rs`) and the update check | | T8 | A downloaded file is not the one the maintainers built | Releases carry `SHA256SUMS`, Sigstore signatures and SLSA provenance, and Windows files an Authenticode signature ([verifying-releases.md](verifying-releases.md)). `install.sh` checks the checksum always and the Sigstore signature unless `--no-verify` is given (`install.sh`). The container image is signed by digest and carries a signed SBOM ([README.md](../README.md#container-image)) | -| T9 | The release pipeline or a signing identity is compromised | `main` accepts changes only through pull requests with passing checks and signed commits; `v*` tags are restricted to the release team; signing jobs wait for a reviewer in the `release-signing` environment; Windows keys never leave the signing service; Sigstore uses no stored key ([release-signing.md](release-signing.md#who-may-change-them)). The response is described in [release-signing.md](release-signing.md#if-the-windows-signing-is-compromised) and [the workflow identity section](release-signing.md#if-the-release-workflow-identity-is-compromised). Workflows are checked by actionlint, zizmor and Scorecard, and actions are pinned by commit | +| T9 | The release pipeline or a signing identity is compromised | `main` accepts changes only through pull requests with passing checks and signed commits; `v*` tags are restricted to the release team; signing jobs wait for a reviewer in the `release-signing` environment; Windows keys never leave the signing service; Sigstore uses no stored key ([release-signing.md](release-signing.md#who-may-change-them)). The response is described in [release-signing.md](release-signing.md#if-the-windows-signing-is-compromised) and [the workflow identity section](release-signing.md#if-the-release-workflow-identity-is-compromised). Workflows are checked by actionlint, zizmor and Scorecard, and actions are pinned by commit. A weekly check compares the release runs, the published files, their Rekor entries and the container image signatures ([release-signing.md](release-signing.md#weekly-release-integrity-check)) | | T10 | A dependency is vulnerable or malicious | `Cargo.lock` is committed and builds use `--locked` (`.github/workflows/release.yml`). `cargo deny` (licences, sources, wildcards; `deny.toml`) and `cargo audit` run on every pull request; OSV-Scanner and CodeQL run on every change and weekly; Dependabot proposes updates only for versions at least seven days old and never major ones (`.github/dependabot.yml`; [CONTRIBUTING.md](../CONTRIBUTING.md#security-testing)) | | T11 | `install.sh` is altered or piped blindly into a shell | It is signed like the archives; the README tells users to download, verify and read it before running it, and not to pipe it ([README.md](../README.md#install-script-linux-and-macos)). It uses HTTPS with TLS 1.2 or later for every download (`install.sh`) | | T12 | Access stays alive after a person or machine no longer needs it | `logout` revokes the token at the app and deletes it; access can also be revoked in the app; [Decommissioning](user-information.md#decommissioning) covers the machine (`commands/session.rs`, `logout_with`) | From f1f0d1c5093731df7680f0cfc730621db77840e3 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:19:32 +0200 Subject: [PATCH 2/2] Count only Rekor entries that Sigstore's trust root vouches for Rekor accepts any certificate in a hashedrekord entry, so an entry with a certificate that claims the release identity could make a file look signed, or raise a false mismatch. The integrity check now counts an entry only if Rekor's signed entry timestamp verifies with Rekor's key and its certificate chains to Sigstore's certificate authority at the time it was logged, both from the trust root that cosign initialize fetches through TUF. Tests use a public fixture of that trust root and of the v0.4.1 entry. Refs #152 Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- .github/scripts/release-integrity-test.py | 43 ++++++ .../scripts/release-integrity-testdata.json | 106 +++++++++++++++ .github/scripts/release-integrity.py | 123 ++++++++++++++++-- .github/workflows/release-integrity.yml | 8 +- docs/release-signing.md | 9 +- 5 files changed, 273 insertions(+), 16 deletions(-) create mode 100644 .github/scripts/release-integrity-testdata.json diff --git a/.github/scripts/release-integrity-test.py b/.github/scripts/release-integrity-test.py index 1765c92..e5d1abb 100644 --- a/.github/scripts/release-integrity-test.py +++ b/.github/scripts/release-integrity-test.py @@ -4,10 +4,14 @@ """Tests of release-integrity.py that need no network: certificate parsing and the matching rules. Usage: python3 .github/scripts/release-integrity-test.py + +Tests whose names contain "negative" feed changed data on purpose; they run in CI. """ import importlib.util +import json import pathlib +import tempfile import unittest spec = importlib.util.spec_from_file_location( @@ -131,6 +135,45 @@ def test_the_identity_prefix_is_compared_without_case(self): self.assertEqual(len(problems), 1) +FIXTURE = json.loads(pathlib.Path(__file__).with_name("release-integrity-testdata.json").read_text()) + + +class TrustRoot(unittest.TestCase): + def setUp(self): + self.workdir = tempfile.TemporaryDirectory() + self.trust = ri.Trust(FIXTURE["trusted_root"], self.workdir.name) + self.entry = FIXTURE["entry"] + body = json.loads(ri.base64.b64decode(self.entry["body"])) + self.der = ri.base64.b64decode( + b"".join( + line + for line in ri.base64.b64decode(body["spec"]["signature"]["publicKey"]["content"]).splitlines() + if not line.startswith(b"-----") + ) + ) + + def tearDown(self): + self.workdir.cleanup() + + def test_rekor_signed_the_real_entry(self): + self.assertTrue(self.trust.timestamp_verifies(self.entry)) + + def test_the_real_certificate_chains_when_it_was_logged(self): + self.assertTrue(self.trust.certificate_chains(self.der, self.entry["integratedTime"])) + + def test_negative_a_changed_entry_is_not_signed_by_rekor(self): + for field, value in (("integratedTime", self.entry["integratedTime"] + 1), ("logIndex", 1)): + with self.subTest(field=field): + self.assertFalse(self.trust.timestamp_verifies({**self.entry, field: value})) + + def test_negative_the_certificate_does_not_chain_outside_its_ten_minutes(self): + self.assertFalse(self.trust.certificate_chains(self.der, self.entry["integratedTime"] + 3600)) + + def test_negative_a_certificate_whose_signature_does_not_verify_does_not_chain(self): + changed = self.der[:-1] + bytes([self.der[-1] ^ 1]) # the last byte of its signature + self.assertFalse(self.trust.certificate_chains(changed, self.entry["integratedTime"])) + + class Parsing(unittest.TestCase): def test_sha256sums_in_text_and_binary_mode(self): text = "a" * 64 + " one.tar.gz\n" + "b" * 64 + " *two.zip\n\n" diff --git a/.github/scripts/release-integrity-testdata.json b/.github/scripts/release-integrity-testdata.json new file mode 100644 index 0000000..f7cc6d8 --- /dev/null +++ b/.github/scripts/release-integrity-testdata.json @@ -0,0 +1,106 @@ +{ + "about": "Public data: Sigstore's Fulcio certificate authorities and Rekor key (from trusted_root.json, as cosign initialize fetched it on 2026-10-12), and the Rekor entry for SHA256SUMS of v0.4.1 (log index 3226122241).", + "trusted_root": { + "certificateAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ==" + } + ] + }, + "validFor": { + "start": "2021-03-07T03:20:29Z", + "end": "2022-12-31T23:59:59.999Z" + } + }, + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow=" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ" + } + ] + }, + "validFor": { + "start": "2022-04-13T20:06:15Z" + } + } + ], + "tlogs": [ + { + "baseUrl": "https://rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-01-12T11:53:27Z" + } + }, + "logId": { + "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" + } + } + ] + }, + "entry": { + "body": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiIxZjk5NTI0OWZlMjQ4ZjExMmQ2MjI4ZGQyMDU2MjlkYjA3NmFmM2VlODE3NDk5ZjA3MzEyMDA5NmJlNjY1MDYyIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJUURJWm4wR0ZYaFBPQmlFMXNQam5jVW1Qd1hLYStoZ1lIZXNGdjlXRTJJTXVBSWdURFdJejBLaVFkVjYvTmRRblcxR1pUUUFTa01EdVRyb0tnVEJCNjRRK2o4PSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVaEVla05EUW5CaFowRjNTVUpCWjBsVlYwVTRWbEF2U0U1aGMzcHFTbEJLZGxjM05GTm5TRXgyWm5WamQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcFplRTFFUlhsTlZFRjZUVlJWTkZkb1kwNU5hbGw0VFVSRmVVMVVRVEJOVkZVMFYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZOY1U1VFFVOWlURzgyWTNCVVYxVlVTMmRRV21sd01rRnRXblYwY1M5Q1pGRTNSVWtLUjBaTk0xUm9la2xsWmpoeFRrSXdlbVk1U1ZadlVXYzNjRmhFWlhSdWN6RllMMGQyZG5ZMlNUVXdVVmRvWkRKYVNuRlBRMEppVlhkbloxZDRUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXWkNaMDVXU0ZOTlJVZEVRVmRuUWxSbUNqQXJibEJXYVZGU2JIWnRiekpQYTI5V1lVeEhUR2hvYTFCNlFtOUNaMDVXU0ZKRlFrRm1PRVZZYWtKamFHeHdiMlJJVW5kamVtOTJUREprY0dSSGFERUtXV2sxYW1JeU1IWlRSemxyV2xkc01GcFhjM1poUnpscldsZHNlbUZIYkd4aVIxRjBXVEo0Y0V4NU5XNWhXRkp2WkZkSmRtUXlPWGxoTWxwellqTmtlZ3BNTTBwc1lrZFdhR015VlhWbFZ6RnpVVWhLYkZwdVRYWmtSMFp1WTNrNU1rMUROREJNYWtWM1QxRlpTMHQzV1VKQ1FVZEVkbnBCUWtGUlVYSmhTRkl3Q21OSVRUWk1lVGt3WWpKMGJHSnBOV2haTTFKd1lqSTFla3h0WkhCa1IyZ3hXVzVXZWxwWVNtcGlNalV3V2xjMU1FeHRUblppVkVGVFFtZHZja0puUlVVS1FWbFBMMDFCUlVOQ1FWSjNaRmhPYjAxRVdVZERhWE5IUVZGUlFtYzNPSGRCVVUxRlMwZEpOVTFIU21sT1JFVjVXWHBSTVZwSFdUTk5la1UwV2xSRmVncE5SRUpyV20xU2FVMUVVbTFQUkVKc1QwUmthRmxxWXpOYWFsVjNSbEZaUzB0M1dVSkNRVWRFZG5wQlFrSkJVVWhWYlZaeldsZEdlbHBVUVcxQ1oyOXlDa0puUlVWQldVOHZUVUZGUmtKQ2FFbGlNbEpzWVZoU2JHRjVPVzlpTWxKc1lWaE9iMkZYVm5OYVF6RnFZa2RyZDBobldVdExkMWxDUWtGSFJIWjZRVUlLUW1kUlVXTnRWbTFqZVRrd1dWZGtla3d6V1hkTWFsRjFUVlJCTjBKbmIzSkNaMFZGUVZsUEwwMUJSVWxDUXpCTlN6Sm9NR1JJUW5wUGFUaDJaRWM1Y2dwYVZ6UjFXVmRPTUdGWE9YVmplVFZ1WVZoU2IyUlhTakZqTWxaNVdUSTVkV1JIVm5Wa1F6VnFZakl3ZDJGbldVdExkMWxDUWtGSFJIWjZRVUpEVVZKakNrUkdjRzlrU0ZKM1kzcHZka3d5WkhCa1IyZ3hXV2sxYW1JeU1IWlRSemxyV2xkc01GcFhjM1poUnpscldsZHNlbUZIYkd4aVIxRjBXVEo0Y0V4NU5XNEtZVmhTYjJSWFNYWmtNamw1WVRKYWMySXpaSHBNTTBwc1lrZFdhR015VlhWbFZ6RnpVVWhLYkZwdVRYWmtSMFp1WTNrNU1rMUROREJNYWtWM1QwRlpTd3BMZDFsQ1FrRkhSSFo2UVVKRFoxRnhSRU5vYVU5VVFtbFphbEY0VFcxTk1FNVhVbTFPZWsxNFQwZFZlRTE2UVhkYVIxcHJXV3BCTUZwcVozZGFWR2N6Q2xsWFNUTk9NbGt4VFVJd1IwTnBjMGRCVVZGQ1p6YzRkMEZSYzBWRWQzZE9XakpzTUdGSVZtbE1WMmgyWXpOU2JGcEVRVGRDWjI5eVFtZEZSVUZaVHk4S1RVRkZUVUpETUUxTE1tZ3daRWhDZWs5cE9IWmFNbXd3WVVoV2FVeHRUblppVXpsSllqSlNiR0ZZVW14aGVUbHZZakpTYkdGWVRtOWhWMVp6V2tNeGFncGlSMnQzVDBGWlMwdDNXVUpDUVVkRWRucEJRa1JSVVhGRVEyaHBUMVJDYVZscVVYaE5iVTB3VGxkU2JVNTZUWGhQUjFWNFRYcEJkMXBIV210WmFrRXdDbHBxWjNkYVZHY3pXVmRKTTA0eVdURk5RMEZIUTJselIwRlJVVUpuTnpoM1FWRTBSVVZuZDFGamJWWnRZM2s1TUZsWFpIcE1NMWwzVEdwUmRVMVVRV0VLUW1kdmNrSm5SVVZCV1U4dlRVRkZVRUpCZDAxRGFrVjZUMVJKTUU5VVZUTk9WR3QzUzNkWlMwdDNXVUpDUVVkRWRucEJRa1ZCVVdSRVFuUnZaRWhTZHdwamVtOTJUREprY0dSSGFERlphVFZxWWpJd2RsTkhPV3RhVjJ3d1dsZHpkMGRSV1V0TGQxbENRa0ZIUkhaNlFVSkZVVkZNUkVGcmVFNUVaekJOZW1kNkNrMXFaM2RoWjFsTFMzZFpRa0pCUjBSMmVrRkNSV2RTWTBSR2NHOWtTRkozWTNwdmRrd3laSEJrUjJneFdXazFhbUl5TUhaVFJ6bHJXbGRzTUZwWGMzWUtZVWM1YTFwWGJIcGhSMnhzWWtkUmRGa3llSEJNZVRWdVlWaFNiMlJYU1haa01qbDVZVEphYzJJelpIcE1NMHBzWWtkV2FHTXlWWFZsVnpGelVVaEtiQXBhYmsxMlpFZEdibU41T1RKTlF6UXdUR3BGZDA5QldVdExkMWxDUWtGSFJIWjZRVUpGZDFGeFJFTm9hVTlVUW1sWmFsRjRUVzFOTUU1WFVtMU9lazE0Q2s5SFZYaE5la0YzV2tkYWExbHFRVEJhYW1kM1dsUm5NMWxYU1ROT01sa3hUVUpSUjBOcGMwZEJVVkZDWnpjNGQwRlNVVVZDWjNkRlkwaFdlbUZFUW1ZS1FtZHZja0puUlVWQldVOHZUVUZGVmtKR1JVMVVNbWd3WkVoQ2VrOXBPSFphTW13d1lVaFdhVXh0VG5aaVV6bEpZakpTYkdGWVVteGhlVGx2WWpKU2JBcGhXRTV2WVZkV2MxcERNV3BpUjJ0MldWZE9NR0ZYT1hWamVUbDVaRmMxZWt4NlRUUk5hazB4VDFSWmVrNTZTWGxNTWtZd1pFZFdkR05JVW5wTWVrVjNDa1puV1V0TGQxbENRa0ZIUkhaNlFVSkdaMUZKUkVGYWQyUlhTbk5oVjAxM1ZuZFpTMHQzV1VKQ1FVZEVkbnBCUWtkQlVrcEVSV1I1V2xoQ2RrOXJhSFlLV2tkV2NHUkhWbkpSUkVVd1QwUlJlazlFVFhsUFF6bHZZakpTYkdGWVRtOWhWMVp6V2tNeGFtSkhiRUZOVkUwMVRXcFJOVTVVWXpGUFZIQjVXbGRaTmdwamJWWnRZM2s1TUZsWFpIcE1NMWwzVEdwUmRVMVVRMEpwZDFsTFMzZFpRa0pCU0ZkbFVVbEZRV2RTT1VKSWMwRmxVVUl6UVU0d09VMUhja2Q0ZUVWNUNsbDRhMlZJU214dVRuZExhVk5zTmpRemFubDBMelJsUzJOdlFYWkxaVFpQUVVGQlFtOVRLM0pVT1RoQlFVRlJSRUZGWjNkU1owbG9RVTUwYUZsM0wzWUtRM2M1YTJkVWJ6QnBjMmRtYlhBMlR5dFpURzltVFhWcGNUVndTRFZKVDBZd2Mxb3JRV2xGUVhsdGJXZDVia1pLTldJelN6WXlOVTVYVHpaSFkxSXlTQXBPZW5CWlMyUjBWVlE1VlRGdGVHeDZWRTlWZDBObldVbExiMXBKZW1vd1JVRjNUVVJhZDBGM1drRkpkMHh3VmxoWlluUnRMMlV6UW5oSFlsaGFiMjl2Q2pjNFVpc3lWR1p3V20xRlRYbFRjM0ZOWTBkdGFYb3dieTlUY2tKb0wzcDVVRVpSTW5CcVNWRnlVRE42UVdwQ2JXVllTSHBIYUc0ck1rNXBUSGN2YkRBS1NGSkVWRFZMTW1wcUwxTldRVllyUlhjME56ZEtObGhGUmxGaWRVbzVNME14Tm1RM1pVWkhZVFowT1V4aVZXODlDaTB0TFMwdFJVNUVJRU5GVWxSSlJrbERRVlJGTFMwdExTMEsifX19fQ==", + "integratedTime": 1791801119, + "logID": "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d", + "logIndex": 3226122241, + "verification": { + "inclusionProof": { + "checkpoint": "rekor.sigstore.dev - 1193050959916656506\n3109757619\nbe1CaZRueKDmCSOb2a6rII5cokxVok+T0xHXECkG9EI=\n\n\u2014 rekor.sigstore.dev wNI9ajBFAiEAvE9IZPbrbChTIw2ebmXzJAjgI7Ez9wsgIf6kDQD30VoCIBUurDCAHr0/ye2Z2GGbH4XlVRh3YfOq0aqFZKiawGSU\n", + "hashes": [ + "a2562fb5088d0682277c11b14330326494967731f874283694835ed2aaf5e5b5", + "b604e44408687c19c12b9ed1aa66cbd0bc61b754730b02236f046028308a60e5", + "62ea0e237fe14033398a6cd540e7cfb157ce92929d871ddd357535e6fcb55bba", + "ddf5d9c20508d64108673d84257b346d6d7dda26b52b91ca0cbb4925ec08033d", + "92897a57d5e37a85cebd9e2a63c105fc03b2d00534bb4f517fcd7e5dd446ad9c", + "1267d6fa6b0c404150e812084cc0eb29d1237de0e31f246d631f55a2b480aef0", + "1dba676c2498e59cf7cf93cf7098b0be41987c8f743df89608a0cb0d9e16153d", + "8d72d71ef43b1fbd2d2a1e2d2f1d272522c8ee8954f856d22214a94fc56f2c4d", + "513dad835e653682b8214f20141001c55f9a35c972fcd6423755a0f3facd7723", + "e05f384eb9d45b5ac2e14cd1ceebcd27ae12ab1b0aa77fc022169ace6971fab9", + "bd73b38472166d260eb59ec0bd39f23d7c966ba4f4ff3f9c9f87e203883bc6a5", + "76bdef9c0133842a54aa2c11c758205ddb3c578c6a49b0ae0cb1f5a4b292e9bd", + "5ae1cca088a96d6b4be1d4f26c3bc580b22e753b694041555953544901c46cc6", + "dee7352de578eabfd016ba94b800146ecd6ac736bb67a4c56cfb0549558f209f", + "3b1d4d34c94a6beaa6af53617f7c3bcf7e5cfaac1898215f716c1c107d0ca2d6", + "8b5f672c617369e8e981c9c0b70c62ce76ebd5bf16327008c54a1431294e2844", + "099204e813e13e7c005ac6f054ecec3c6d2a15f536bafd40fe00d28cedebe4f6", + "57467ab00840ab5c371748dc97e538413017224014e9b8b412bf111bdd827670", + "c0bac3ac8576a1b0bd6d2c7915f6e5531d198f260b0e5d63e669877fb5d15d0e", + "386f1ecc64ad79c80edb9b24711fd532efff2313469a1aa3d0a0d8c18f61bac3", + "073fe5fabea764e56ac50251ddee7a471a0f707a57377c2df85f4b8d35f284b9", + "3fba07ebd071d49402710de13bd5970982beb5a687296b731291de1d6fde14eb", + "9eb78a52a5cbdb6653934411fecc7d3fd1b24ba6b0c7c10e6e1273149bf85cd4", + "6e97f1dc2bf12409d83a085df818b66ac2b70248223c8b41628086a0b55996b2", + "820f46340a4d91edfeccd865e2db16b6623270216b38c081de5cace97b9240d4", + "36539c96576628f035b4c288b24a87030b900d81e9df5630a4af8e38cacc71c7", + "ab1cc76a7033cf9ed20dd9897b407b6c92bbd8d4c805bc043060412af0ce44ec", + "c47fc30ac78b1ebf5e2a8613f2ab0e4592bbcd5744198587b95b6c56b0fde706" + ], + "logIndex": 3104217979, + "rootHash": "6ded4269946e78a0e609239bd9aeab208e5ca24c55a24f93d311d7102906f442", + "treeSize": 3109757619 + }, + "signedEntryTimestamp": "MEYCIQC60O/JNeSrxOnBkTt+kTUuxT8fOlWnjYG3GhVEZL2jdgIhAI+MwiEvGs8uUk5dndfAvSQsvkTtWQwka16ZFAAedLaI" + } + } +} diff --git a/.github/scripts/release-integrity.py b/.github/scripts/release-integrity.py index 4cdbe0c..cc15c6e 100644 --- a/.github/scripts/release-integrity.py +++ b/.github/scripts/release-integrity.py @@ -3,10 +3,12 @@ # Copyright 2026 Hodeitek S.L. """Checks that every release run, release file, Rekor entry and container image belongs together. -Usage: release-integrity.py +Usage: release-integrity.py is "owner/name". is the cosign binary used to verify the container images. -GH_TOKEN, if set, is used for the GitHub API (read-only: releases, tags, workflow runs). + is Sigstore's trust root as `cosign initialize` fetched and checked it through +TUF: the Fulcio certificate authorities and the Rekor public keys. GH_TOKEN, if set, is used for the +GitHub API (read-only: releases, tags, workflow runs). What is checked, in both directions: runs -> releases every run of release.yml started by a tag, and every run of container-image.yml, @@ -16,7 +18,11 @@ files every release file matches its line in the release's SHA256SUMS, and nothing else is attached; Rekor every file the release signs (it has a `.sigstore.json` bundle) is looked up in - Rekor by its SHA-256. At least one entry must carry a certificate for + Rekor by its SHA-256. An entry counts only if its signed entry timestamp + verifies with Rekor's key and its certificate chains to Sigstore's + certificate authority at the time it was logged: Rekor accepts any + certificate, so one that does not chain could claim any identity. At least + one entry must carry a certificate for release.yml at the release's tag, from the GitHub Actions issuer, with the tag's commit and a run of that tag. An entry with this repository's identity that names anything else is reported. Entries made by others are ignored: anyone can @@ -39,6 +45,8 @@ import re import subprocess import sys +import tempfile +import textwrap import time import urllib.error import urllib.parse @@ -236,11 +244,91 @@ def parse_sha256sums(text: str) -> dict[str, str]: return sums +# --- Sigstore's trust root ---------------------------------------------------------------------- + + +def pem(der: bytes, label: str = "CERTIFICATE") -> str: + body = "\n".join(textwrap.wrap(base64.b64encode(der).decode(), 64)) + return f"-----BEGIN {label}-----\n{body}\n-----END {label}-----\n" + + +class Trust: + """Checks a Rekor entry against Sigstore's trust root, with openssl.""" + + def __init__(self, trusted_root: dict, workdir: str): + self.workdir = workdir + roots, intermediates = [], [] + for authority in trusted_root.get("certificateAuthorities", []): + chain = [base64.b64decode(c["rawBytes"]) for c in authority["certChain"]["certificates"]] + roots.append(pem(chain[-1])) + intermediates.extend(pem(der) for der in chain[:-1]) + if not roots: + raise Unreadable("the trust root lists no certificate authority") + self.roots = self._write("roots.pem", "".join(roots)) + self.intermediates = ( + self._write("intermediates.pem", "".join(intermediates)) if intermediates else None + ) + self.rekor_keys = {} + for log in trusted_root.get("tlogs", []): + if log.get("baseUrl", "").rstrip("/") == REKOR: + log_id = base64.b64decode(log["logId"]["keyId"]).hex() + self.rekor_keys[log_id] = self._write( + f"rekor-{log_id}.pem", pem(base64.b64decode(log["publicKey"]["rawBytes"]), "PUBLIC KEY") + ) + if not self.rekor_keys: + raise Unreadable(f"the trust root has no key for {REKOR}") + + def _write(self, name: str, content) -> str: + path = f"{self.workdir}/{name}" + with open(path, "wb" if isinstance(content, bytes) else "w") as file: + file.write(content) + return path + + def timestamp_verifies(self, entry: dict) -> bool: + """Whether Rekor signed this entry, its log index and the time it was logged.""" + key = self.rekor_keys.get(entry.get("logID", "")) + signature = entry.get("verification", {}).get("signedEntryTimestamp") + if not key or not signature: + return False + payload = json.dumps( + { + "body": entry["body"], + "integratedTime": entry["integratedTime"], + "logID": entry["logID"], + "logIndex": entry["logIndex"], + }, + sort_keys=True, + separators=(",", ":"), + ) + payload_path = self._write("set-payload", payload.encode()) + signature_path = self._write("set-signature", base64.b64decode(signature)) + result = subprocess.run( + ["openssl", "dgst", "-sha256", "-verify", key, "-signature", signature_path, payload_path], + capture_output=True, + check=False, + ) + return result.returncode == 0 + + def certificate_chains(self, der: bytes, when: int) -> bool: + """Whether a certificate was issued by Sigstore's certificate authority and valid at `when`.""" + leaf = self._write("leaf.pem", pem(der)) + command = ["openssl", "verify", "-attime", str(when), "-CAfile", self.roots] + if self.intermediates: + command += ["-untrusted", self.intermediates] + result = subprocess.run([*command, leaf], capture_output=True, check=False) + return result.returncode == 0 + + # --- Rekor -------------------------------------------------------------------------------------- -def rekor_entries(digest: str): - """The Rekor entries for an artifact SHA-256, as (log index, certificate identity or None).""" +def rekor_entries(digest: str, trust: Trust): + """The Rekor entries for an artifact SHA-256, as (log index, certificate identity or None). + + The identity is None for an entry that is not a certificate signature, whose timestamp Rekor did + not sign, or whose certificate Sigstore's certificate authority did not issue: such an entry can + claim any identity, so it says nothing about who signed. + """ status, _, body = http( f"{REKOR}/api/v1/index/retrieve", data=json.dumps({"hash": f"sha256:{digest}"}).encode(), @@ -261,9 +349,18 @@ def rekor_entries(digest: str): content = spec["spec"]["signature"].get("publicKey", {}).get("content") if content: try: - certificate = identity(base64.b64decode(content)) + der = base64.b64decode(content) + if der.lstrip().startswith(b"-----BEGIN"): + der = base64.b64decode( + b"".join(l for l in der.strip().splitlines() if not l.startswith(b"-----")) + ) + claimed = identity(der) except (ValueError, KeyError, StopIteration, UnicodeDecodeError): - certificate = None # a public key, not a certificate: not this workflow's + claimed = None # a public key, not a certificate: not this workflow's + if claimed and trust.timestamp_verifies(entry) and trust.certificate_chains( + der, entry["integratedTime"] + ): + certificate = claimed entries.append((entry["logIndex"], certificate)) return entries @@ -385,7 +482,7 @@ def stable_version(tag: str): return tuple(int(part) for part in match.groups()[:3]) -def check(repository: str, cosign: str, github: GitHub, out) -> bool: +def check(repository: str, cosign: str, trust: Trust, github: GitHub, out) -> bool: problems: list[str] = [] def problem(text: str): @@ -483,7 +580,7 @@ def problem(text: str): problem(f"{tag}: no file has a Sigstore bundle") for name in signed: matched, wrong = judge_rekor( - rekor_entries(digests[name]), repository=repository, tag=tag, releases=signers + rekor_entries(digests[name], trust), repository=repository, tag=tag, releases=signers ) for text in wrong: problem(f"{tag}: {name} (sha256:{digests[name]}): {text}") @@ -533,13 +630,15 @@ def problem(text: str): def main(argv: list[str]) -> int: - if len(argv) != 3 or "/" not in argv[1]: + if len(argv) != 4 or "/" not in argv[1]: print(__doc__.split("\n\n")[1], file=sys.stderr) return 1 github = GitHub(argv[1], os.environ.get("GH_TOKEN") or None) try: - return 0 if check(argv[1], argv[2], github, sys.stdout) else 1 - except (Unreadable, ValueError, KeyError) as error: + with open(argv[3], encoding="utf-8") as file, tempfile.TemporaryDirectory() as workdir: + trust = Trust(json.load(file), workdir) + return 0 if check(argv[1], argv[2], trust, github, sys.stdout) else 1 + except (Unreadable, ValueError, KeyError, OSError) as error: print(f"UNREADABLE: {error}", file=sys.stdout) return 1 diff --git a/.github/workflows/release-integrity.yml b/.github/workflows/release-integrity.yml index 4339665..a12edbd 100644 --- a/.github/workflows/release-integrity.yml +++ b/.github/workflows/release-integrity.yml @@ -84,6 +84,8 @@ jobs: echo "note=$note" >> "$GITHUB_OUTPUT" fi + # Sigstore's trust root (Fulcio's certificate authorities and Rekor's keys), fetched and checked + # through TUF by cosign. The check accepts a Rekor entry only if it verifies against it. - name: Check the releases id: check env: @@ -92,7 +94,11 @@ jobs: set -uo pipefail report="$RUNNER_TEMP/report.txt" status=0 - python3 -I .github/scripts/release-integrity.py "$GITHUB_REPOSITORY" "$(command -v cosign)" > "$report" 2>&1 || status=$? + { + cosign initialize && + python3 -I .github/scripts/release-integrity.py "$GITHUB_REPOSITORY" "$(command -v cosign)" \ + "$HOME/.sigstore/root/tuf-repo-cdn.sigstore.dev/targets/trusted_root.json" + } > "$report" 2>&1 || status=$? cat "$report" if [ "$status" = 0 ]; then result=pass; else result=fail; fi echo "result=$result" >> "$GITHUB_OUTPUT" diff --git a/docs/release-signing.md b/docs/release-signing.md index f9c37e1..cd835fd 100644 --- a/docs/release-signing.md +++ b/docs/release-signing.md @@ -147,9 +147,12 @@ The `release-integrity.yml` workflow runs every Monday, and on demand successful `container-image.yml` run. Manual runs of `release.yml` are dry runs and are skipped. - **Files.** Every file of a release must match its line in the release's `SHA256SUMS`, and nothing but the signature bundles and the provenance may be attached besides. -- **Rekor.** Every file a release signs is looked up in Rekor by its SHA-256. At least one entry must - carry the certificate of `release.yml` at the release's tag, from the GitHub Actions issuer, with - the tag's commit and one of that tag's runs. The certificate names the run, so each published file +- **Rekor.** Every file a release signs is looked up in Rekor by its SHA-256. An entry counts only if + Rekor's signature over it verifies and its certificate was issued by Sigstore's certificate + authority, valid when the entry was logged, both checked against Sigstore's trust root as cosign + fetches it: Rekor itself accepts any certificate, so one that does not chain could claim any + identity. At least one entry must carry the certificate of `release.yml` at the release's tag, + from the GitHub Actions issuer, with the tag's commit and one of that tag's runs. The certificate names the run, so each published file is tied to the run that signed it. An entry with this repository's identity that no published release made is a mismatch. A file that two releases ship unchanged, such as `install.sh`, is signed by both, and that is not a mismatch.