Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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/

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions changes/unreleased/release-provenance.security.md
Original file line number Diff line number Diff line change
@@ -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 <asset> --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.
69 changes: 68 additions & 1 deletion docs/project/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,12 @@ does a tag whose version `client/python/opensysml/_version.py` does not declare.
- the Python client's distribution, `opensysml-<x.y.z>-py3-none-any.whl` and
`opensysml-<x.y.z>.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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
172 changes: 172 additions & 0 deletions scripts/release-provenance-test.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading