diff --git a/.circleci/config.yml b/.circleci/config.yml index 111dae83d7..1373e6a794 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -230,6 +230,10 @@ jobs: name: Check changelog fragments command: python3 scripts/changelog-test.py && python3 scripts/changelog.py check + - run: + name: Check the release provenance writer + command: python3 scripts/release-provenance-test.py + # The compliance census is counted when the site is built, never committed. - run: name: Check the compliance census hook @@ -1650,6 +1654,46 @@ jobs: | base64 -d | openssl x509 -inform DER -noout -text \ | grep -A1 -E '1\.3\.6\.1\.4\.1\.57264\.1\.8:|URI:' || true + # SLSA provenance over every artifact the manifest lists, signed under the + # same CircleCI identity as the manifest; the bundle carries the statement. + - run: + name: Attest the release's SLSA provenance with cosign keyless + command: | + export PATH="$(go env GOPATH)/bin:$PATH" + python3 scripts/release-provenance.py \ + --manifest dist/SHA256SUMS.txt --out dist/provenance.intoto.json + SIGSTORE_ID_TOKEN="$(circleci run oidc get --claims '{"aud": "sigstore"}')" + export SIGSTORE_ID_TOKEN + + cd dist + cosign attest-blob SHA256SUMS.txt \ + --statement provenance.intoto.json \ + --type slsaprovenance1 \ + --oidc-issuer "https://oidc.circleci.com/org/${CIRCLE_ORGANIZATION_ID}" \ + --bundle provenance.intoto.json.bundle \ + --use-signing-config=false \ + --yes + + # Verifies the attestation against a published artifact, so a statement + # whose subjects do not name the release's bytes fails the release here. + - run: + name: Verify the provenance names the artifacts under the identity clients pin + command: | + export PATH="$(go env GOPATH)/bin:$PATH" + cd dist + for artifact in opensysml-linux-amd64.tar.gz grpc/sysml-grpc-linux-amd64 opensysml-*-py3-none-any.whl; do + cosign verify-blob-attestation "$artifact" \ + --bundle provenance.intoto.json.bundle \ + --type slsaprovenance1 \ + --certificate-oidc-issuer "https://oidc.circleci.com/org/${CIRCLE_ORGANIZATION_ID}" \ + --certificate-identity-regexp "^https://circleci\\.com/api/v2/projects/${CIRCLE_PROJECT_ID}/pipeline-definitions/[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$" + echo "ok: provenance names $artifact" + done + # Every manifest line must be a subject, and nothing else may be. + diff <(sed 's/^\([0-9a-f]*\) \(.*\)$/\2 \1/' SHA256SUMS.txt | sort) \ + <(jq -r '.dsseEnvelope.payload' provenance.intoto.json.bundle | base64 -d \ + | jq -r '.subject[] | "\(.name) \(.digest.sha256)"' | sort) + # An artifact whose version disagrees with its tag looks identical on the # release page. The host-platform builds are asked what they report; the @@ -1865,6 +1909,8 @@ jobs: # The signature over that manifest, which the Python client verifies # before it trusts a digest from it. mv dist/SHA256SUMS.txt.bundle dist/release/ + # The SLSA provenance statement over those assets and its signature. + mv dist/provenance.intoto.json dist/provenance.intoto.json.bundle dist/release/ echo "Release artifacts:" ls -la dist/release/ diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c5a372af4b..4c5d56b558 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -863,6 +863,9 @@ jobs: - name: Check changelog fragments run: python3 scripts/changelog-test.py && python3 scripts/changelog.py check + - name: Check the release provenance writer + run: python3 scripts/release-provenance-test.py + # The compliance census is counted when the site is built, never committed. - name: Check the compliance census hook run: python3 scripts/mkdocs_census-test.py diff --git a/changes/unreleased/release-provenance.security.md b/changes/unreleased/release-provenance.security.md new file mode 100644 index 0000000000..f2bd430d85 --- /dev/null +++ b/changes/unreleased/release-provenance.security.md @@ -0,0 +1 @@ +- **Releases carry signed SLSA provenance.** `build-release` writes an in-toto statement whose subjects are every artifact `SHA256SUMS.txt` lists and whose SLSA Provenance v1 predicate records the repository, tag, commit and CircleCI job that built them (`scripts/release-provenance.py`), attests it with cosign keyless under the same CircleCI identity that signs the manifest, verifies the attestation against the built artifacts before anything is stored, and publishes `provenance.intoto.json` and `provenance.intoto.json.bundle` beside the manifest. A download is checked with `cosign verify-blob-attestation --bundle provenance.intoto.json.bundle --type slsaprovenance1` and the pipeline's identity; see "The release provenance" in `docs/project/releasing.md` for what the statement does and does not claim. The clients keep verifying the signed manifest and are unchanged. diff --git a/docs/project/releasing.md b/docs/project/releasing.md index 5af497b573..ee0380501b 100644 --- a/docs/project/releasing.md +++ b/docs/project/releasing.md @@ -167,7 +167,12 @@ does a tag whose version `client/python/opensysml/_version.py` does not declare. - the Python client's distribution, `opensysml--py3-none-any.whl` and `opensysml-.tar.gz`, built by `build-python-package` and the same files `publish-pypi` uploads (see [Releasing opensysml to PyPI](#releasing-opensysml-to-pypi)); -- `SHA256SUMS.txt` over every archive, the wheel and every `sysml-grpc` binary. +- `SHA256SUMS.txt` over every archive, the wheel and every `sysml-grpc` binary, + with its cosign signature `SHA256SUMS.txt.bundle` (see + [The signed checksum manifest](#the-signed-checksum-manifest)); +- `provenance.intoto.json`, the SLSA provenance statement naming every artifact + the manifest lists, and `provenance.intoto.json.bundle`, its cosign + attestation (see [The release provenance](#the-release-provenance)). Platforms: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64. @@ -241,6 +246,17 @@ one alongside it). A missing bundle means `build-release` did not sign — re-run the tag's workflow rather than pinning around it. + The provenance is checked the same way, against the downloaded archive + rather than the manifest: + + ```bash + curl -fLO https://github.com/Open-MBEE/OpenSysML/releases/download/v0.0.5/provenance.intoto.json.bundle + cosign verify-blob-attestation opensysml-linux-amd64.tar.gz \ + --bundle provenance.intoto.json.bundle --type slsaprovenance1 \ + --certificate-oidc-issuer https://oidc.circleci.com/org/1169df8b-0b59-400f-82d2-c9d8e98bdb62 \ + --certificate-identity-regexp '^https://circleci\.com/api/v2/projects/eeb0dddd-237f-4f02-9e51-8e24caef589d/pipeline-definitions/[0-9a-f-]+$' + ``` + Then install the Python client the release published, from the index rather than the source tree, and run it against the release's own `sysml-grpc` — the pairing a user who pins one version gets (see @@ -345,6 +361,57 @@ installed. The `.sha256` served beside a binary is still never a reason to trust it — same origin as the binary — and remains behind `$OPENSYSML_ALLOW_UNPINNED_DOWNLOAD`. +### The release provenance + +`build-release` also writes a [SLSA provenance](https://slsa.dev/spec/v1.0/provenance) +statement over the same artifacts and signs it under the same identity. +`scripts/release-provenance.py` reads `dist/SHA256SUMS.txt` and writes +`dist/provenance.intoto.json`: an in-toto Statement v1 whose subjects are every +artifact the manifest lists, with the manifest's digest, and whose predicate +(`https://slsa.dev/provenance/v1`) records what built them — the repository +and tag (`externalParameters`), the commit the tag resolved to +(`resolvedDependencies`), the CircleCI organization, project and workflow +(`internalParameters`), the project as the builder (`runDetails.builder.id`) +and the job's URL as the invocation. The build type, +`https://github.com/Open-MBEE/OpenSysML/.circleci/build-release/v1`, names this +repository's own job; its version moves when what the job does changes. The +script refuses to write a statement from an empty or malformed manifest, or +without every one of the CircleCI variables it describes the build from, so a +vaguer statement is never published in place of the intended one. + +`cosign attest-blob --statement` then signs that statement as it stands — every +subject kept, nothing re-derived — into a DSSE envelope in a sigstore bundle, +`provenance.intoto.json.bundle`, keylessly under the job's CircleCI OIDC +identity, exactly as the manifest is signed. The job verifies its own +attestation against three published artifacts (a bundle archive, a `sysml-grpc` +binary and the wheel) under the identity the clients pin, and checks that the +subjects are the manifest's lines, no more and no fewer, before anything is +stored; `publish-github-release` uploads the statement and the bundle beside +`SHA256SUMS.txt`. + +What this is, and is not. The statement is produced by the build that produced +the artifacts, on CircleCI's hosted runners, and signed with an identity only +that pipeline can hold, so a verifier learns which repository, tag and commit a +downloaded file was built from and which job built it — SLSA Build L2. It is not +Build L3: CircleCI does not itself issue provenance, so the statement is +generated by the job it describes rather than by the platform outside it, and +nothing stops a change to `.circleci/config.yml` from changing what is written. +That is why the buildType is versioned and why the trust anchor stays the +certificate identity: a statement signed by anything but this project's +pipeline verifies as nothing. A provenance workflow that hashes downloaded +assets on another platform would attest that platform's download, not this +build, and is not what this is. + +The unsigned `provenance.intoto.json` is a convenience for reading; the +authoritative statement is the bundle's payload: + +```bash +jq -r '.dsseEnvelope.payload' provenance.intoto.json.bundle | base64 -d | jq . +``` + +The Python and Node clients keep reading the signed manifest, not the +provenance; nothing in them changes. + ### Windows Authenticode signing The policy users see is the [Code signing policy](../../README.md#code-signing-policy) diff --git a/scripts/release-provenance-test.py b/scripts/release-provenance-test.py new file mode 100755 index 0000000000..c25846d534 --- /dev/null +++ b/scripts/release-provenance-test.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Tests for scripts/release-provenance.py. Run: python3 scripts/release-provenance-test.py""" + +from __future__ import annotations + +import importlib.util +import json +import pathlib +import tempfile +import unittest +import unittest.mock + +HERE = pathlib.Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location("release_provenance", HERE / "release-provenance.py") +provenance = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(provenance) + +A = "a" * 64 +B = "b" * 64 +COMMIT = "0123456789abcdef0123456789abcdef01234567" + +MANIFEST = f"""{A} sysml-linux-amd64.tar.gz +{B} opensysml-0.9.0-py3-none-any.whl +""" + +ENV = { + "CIRCLE_TAG": "v0.9.0", + "CIRCLE_SHA1": COMMIT, + "CIRCLE_BUILD_URL": "https://circleci.com/gh/Open-MBEE/OpenSysML/1234", + "CIRCLE_PROJECT_ID": "eeb0dddd-237f-4f02-9e51-8e24caef589d", + "CIRCLE_ORGANIZATION_ID": "1169df8b-0b59-400f-82d2-c9d8e98bdb62", + "CIRCLE_WORKFLOW_ID": "wf-1", + "CIRCLE_JOB": "build-release", + "CIRCLE_PROJECT_USERNAME": "Open-MBEE", + "CIRCLE_PROJECT_REPONAME": "OpenSysML", +} + + +def build(**overrides): + env = dict(ENV) + env.update(overrides) + return provenance.Build.from_env(env) + + +class SubjectsTest(unittest.TestCase): + def test_every_manifest_line_is_a_subject_with_its_digest(self): + self.assertEqual( + provenance.subjects(MANIFEST), + [ + {"name": "sysml-linux-amd64.tar.gz", "digest": {"sha256": A}}, + {"name": "opensysml-0.9.0-py3-none-any.whl", "digest": {"sha256": B}}, + ], + ) + + def test_blank_lines_and_binary_mode_markers_are_accepted(self): + self.assertEqual( + [s["name"] for s in provenance.subjects(f"\n{A} *x.zip\n\n")], ["x.zip"] + ) + + def test_names_with_spaces_are_kept_whole(self): + self.assertEqual(provenance.subjects(f"{A} a b.tar.gz")[0]["name"], "a b.tar.gz") + + def test_an_empty_manifest_is_refused(self): + with self.assertRaisesRegex(provenance.ProvenanceError, "no artifacts"): + provenance.subjects("\n") + + def test_a_malformed_line_is_refused(self): + for line in (f"{A[:63]} short.tar.gz", f"{A.upper()} upper.tar.gz", "x.tar.gz", f"{A}"): + with self.subTest(line=line): + with self.assertRaisesRegex(provenance.ProvenanceError, "line 1"): + provenance.subjects(line) + + def test_a_name_listed_twice_is_refused(self): + with self.assertRaisesRegex(provenance.ProvenanceError, "twice"): + provenance.subjects(f"{A} x\n{B} x\n") + + +class BuildTest(unittest.TestCase): + def test_every_variable_is_required(self): + for name in provenance.REQUIRED_ENV: + with self.subTest(name=name): + with self.assertRaisesRegex(provenance.ProvenanceError, name): + build(**{name: ""}) + + def test_a_tag_that_is_not_a_release_is_refused(self): + with self.assertRaisesRegex(provenance.ProvenanceError, "release tag"): + build(CIRCLE_TAG="develop") + + def test_a_commit_that_is_not_a_full_hash_is_refused(self): + with self.assertRaisesRegex(provenance.ProvenanceError, "commit hash"): + build(CIRCLE_SHA1=COMMIT[:7]) + + +class StatementTest(unittest.TestCase): + def test_the_statement_describes_the_tag_commit_and_job(self): + got = provenance.statement(MANIFEST, build()) + self.assertEqual(got["_type"], "https://in-toto.io/Statement/v1") + self.assertEqual(got["predicateType"], "https://slsa.dev/provenance/v1") + self.assertEqual(len(got["subject"]), 2) + definition = got["predicate"]["buildDefinition"] + self.assertEqual(definition["buildType"], provenance.BUILD_TYPE) + self.assertEqual( + definition["externalParameters"], + { + "repository": "https://github.com/Open-MBEE/OpenSysML", + "ref": "refs/tags/v0.9.0", + "job": "build-release", + }, + ) + self.assertEqual( + definition["resolvedDependencies"], + [ + { + "uri": "git+https://github.com/Open-MBEE/OpenSysML@refs/tags/v0.9.0", + "digest": {"gitCommit": COMMIT}, + } + ], + ) + self.assertEqual( + definition["internalParameters"], + { + "organization": ENV["CIRCLE_ORGANIZATION_ID"], + "project": ENV["CIRCLE_PROJECT_ID"], + "workflow": "wf-1", + }, + ) + run = got["predicate"]["runDetails"] + self.assertEqual( + run["builder"]["id"], + "https://circleci.com/api/v2/projects/eeb0dddd-237f-4f02-9e51-8e24caef589d", + ) + self.assertEqual( + run["metadata"], + {"invocationId": ENV["CIRCLE_BUILD_URL"]}, + ) + + def test_render_is_json_ending_in_a_newline(self): + text = provenance.render(MANIFEST, build()) + self.assertTrue(text.endswith("}\n")) + self.assertEqual(json.loads(text), provenance.statement(MANIFEST, build())) + + +class MainTest(unittest.TestCase): + def test_writes_the_statement_and_refuses_without_a_build(self): + with tempfile.TemporaryDirectory() as tmp: + manifest = pathlib.Path(tmp, "SHA256SUMS.txt") + manifest.write_text(MANIFEST) + out = pathlib.Path(tmp, "provenance.intoto.json") + with unittest.mock.patch.dict("os.environ", ENV, clear=True): + self.assertEqual( + provenance.main(["--manifest", str(manifest), "--out", str(out)]), 0 + ) + self.assertEqual(len(json.loads(out.read_text())["subject"]), 2) + out.unlink() + with unittest.mock.patch.dict("os.environ", {}, clear=True): + self.assertEqual( + provenance.main(["--manifest", str(manifest), "--out", str(out)]), 1 + ) + self.assertFalse(out.exists()) + + def test_a_missing_manifest_is_an_error_not_a_traceback(self): + with tempfile.TemporaryDirectory() as tmp: + out = pathlib.Path(tmp, "out.json") + with unittest.mock.patch.dict("os.environ", ENV, clear=True): + self.assertEqual( + provenance.main(["--manifest", f"{tmp}/missing", "--out", str(out)]), 1 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release-provenance.py b/scripts/release-provenance.py new file mode 100755 index 0000000000..a8fa0d9977 --- /dev/null +++ b/scripts/release-provenance.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Write the SLSA provenance statement for a release's checksum manifest. + +Usage: scripts/release-provenance.py --manifest dist/SHA256SUMS.txt --out dist/provenance.intoto.json + +The statement is an in-toto Statement v1 whose subjects are every artifact the +manifest lists, with the digest the manifest records, and whose predicate is +SLSA Provenance v1 describing the build that produced them: the repository and +tag built, the commit resolved, and the CircleCI job that ran. The release +pipeline signs the file with cosign keyless, so the statement itself carries no +signature and names no secret. + +The build is described from CircleCI's environment: CIRCLE_TAG, CIRCLE_SHA1, +CIRCLE_BUILD_URL, CIRCLE_PROJECT_ID, CIRCLE_ORGANIZATION_ID, CIRCLE_WORKFLOW_ID, +CIRCLE_JOB, CIRCLE_PROJECT_USERNAME and CIRCLE_PROJECT_REPONAME. Every one is +required: a statement missing any of them would describe a build that cannot be +told apart from another, so the script refuses rather than write a vaguer one. +""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import re +import sys + +STATEMENT_TYPE = "https://in-toto.io/Statement/v1" +PREDICATE_TYPE = "https://slsa.dev/provenance/v1" + +# Names this repository's build-release job; bump the version when the job's meaning changes. +BUILD_TYPE = "https://github.com/Open-MBEE/OpenSysML/.circleci/build-release/v1" + +_MANIFEST_LINE = re.compile(r"^([0-9a-f]{64}) [ *](\S.*)$") +_TAG = re.compile(r"^v[0-9]") +_SHA1 = re.compile(r"^[0-9a-f]{40}$") + +REQUIRED_ENV = ( + "CIRCLE_TAG", + "CIRCLE_SHA1", + "CIRCLE_BUILD_URL", + "CIRCLE_PROJECT_ID", + "CIRCLE_ORGANIZATION_ID", + "CIRCLE_WORKFLOW_ID", + "CIRCLE_JOB", + "CIRCLE_PROJECT_USERNAME", + "CIRCLE_PROJECT_REPONAME", +) + + +class ProvenanceError(Exception): + """The manifest or the build description cannot make a truthful statement.""" + + +class Build: + """What the provenance says about the build that produced the artifacts.""" + + def __init__(self, tag, commit, build_url, project_id, organization_id, workflow_id, + job, owner, repository): + self.tag = tag + self.commit = commit + self.build_url = build_url + self.project_id = project_id + self.organization_id = organization_id + self.workflow_id = workflow_id + self.job = job + self.owner = owner + self.repository = repository + + @classmethod + def from_env(cls, env): + missing = [name for name in REQUIRED_ENV if not env.get(name)] + if missing: + raise ProvenanceError( + "cannot describe the build: " + ", ".join(missing) + " not set" + ) + if not _TAG.match(env["CIRCLE_TAG"]): + raise ProvenanceError( + f"CIRCLE_TAG {env['CIRCLE_TAG']!r} is not a release tag (v)" + ) + if not _SHA1.match(env["CIRCLE_SHA1"]): + raise ProvenanceError(f"CIRCLE_SHA1 {env['CIRCLE_SHA1']!r} is not a commit hash") + return cls( + tag=env["CIRCLE_TAG"], + commit=env["CIRCLE_SHA1"], + build_url=env["CIRCLE_BUILD_URL"], + project_id=env["CIRCLE_PROJECT_ID"], + organization_id=env["CIRCLE_ORGANIZATION_ID"], + workflow_id=env["CIRCLE_WORKFLOW_ID"], + job=env["CIRCLE_JOB"], + owner=env["CIRCLE_PROJECT_USERNAME"], + repository=env["CIRCLE_PROJECT_REPONAME"], + ) + + @property + def source(self): + return f"https://github.com/{self.owner}/{self.repository}" + + +def subjects(manifest_text): + """The artifacts a `sha256sum` manifest lists, as in-toto subjects. + + Every line must be `<64 hex digits> `; a name may appear once. An + empty manifest is refused, since provenance over nothing is not provenance. + """ + seen = set() + result = [] + for number, line in enumerate(manifest_text.splitlines(), start=1): + if not line.strip(): + continue + match = _MANIFEST_LINE.match(line) + if not match: + raise ProvenanceError(f"manifest line {number} is not a sha256sum line: {line!r}") + digest, name = match.group(1), match.group(2) + if name in seen: + raise ProvenanceError(f"manifest lists {name!r} twice") + seen.add(name) + result.append({"name": name, "digest": {"sha256": digest}}) + if not result: + raise ProvenanceError("manifest lists no artifacts") + return result + + +def statement(manifest_text, build): + """The in-toto statement over the manifest's artifacts for this build.""" + ref = f"refs/tags/{build.tag}" + return { + "_type": STATEMENT_TYPE, + "subject": subjects(manifest_text), + "predicateType": PREDICATE_TYPE, + "predicate": { + "buildDefinition": { + "buildType": BUILD_TYPE, + "externalParameters": { + "repository": build.source, + "ref": ref, + "job": build.job, + }, + "internalParameters": { + "organization": build.organization_id, + "project": build.project_id, + "workflow": build.workflow_id, + }, + "resolvedDependencies": [ + { + "uri": f"git+{build.source}@{ref}", + "digest": {"gitCommit": build.commit}, + } + ], + }, + "runDetails": { + "builder": { + "id": f"https://circleci.com/api/v2/projects/{build.project_id}", + }, + "metadata": { + "invocationId": build.build_url, + }, + }, + }, + } + + +def render(manifest_text, build): + return json.dumps(statement(manifest_text, build), indent=2) + "\n" + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--manifest", required=True, type=pathlib.Path, + help="the sha256sum manifest over the release artifacts") + parser.add_argument("--out", required=True, type=pathlib.Path, + help="where to write the statement") + args = parser.parse_args(argv) + try: + build = Build.from_env(os.environ) + text = render(args.manifest.read_text(encoding="utf-8"), build) + except (ProvenanceError, OSError) as err: + print(f"release-provenance: {err}", file=sys.stderr) + return 1 + args.out.write_text(text, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + sys.exit(main())