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
7 changes: 7 additions & 0 deletions .changes/immutable-release-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"rscrypto" = "patch"
---

Immutable releases now bind a protected tag and exact commit to a deterministic
source archive, crate, evidence, checksums, and pinned toolchain in an attested
manifest.
3 changes: 3 additions & 0 deletions .github/repository-settings/release-immutability.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"enabled": true
}
22 changes: 22 additions & 0 deletions .github/rulesets/protect-release-tags.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "protect-release-tags",
"target": "tag",
"enforcement": "active",
"conditions": {
"ref_name": {
"exclude": [],
"include": [
"refs/tags/v*"
]
}
},
"bypass_actors": [],
"rules": [
{
"type": "deletion"
},
{
"type": "update"
}
]
}
226 changes: 153 additions & 73 deletions .github/workflows/release.yaml

Large diffs are not rendered by default.

16 changes: 13 additions & 3 deletions docs/constant-time.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,11 @@ for audit-constrained builds, but it is not a proof by itself.

## Evidence

The source manifest is never sufficient to establish a release claim. The
matching signed GitHub release must contain all of:
Source inspection and `ct.toml` are not sufficient to establish a release claim. The matching signed GitHub release
must contain all of:

- `rscrypto-X.Y.Z.crate` and `SHA256SUMS`.
- The attested release manifest, source archive, crate, and `SHA256SUMS` binding the release tag, commit, toolchain,
and artifacts.
- An attested `rscrypto-X.Y.Z-ct-evidence.tar.gz` built from the same release
commit.
- `CT-EVIDENCE-BUNDLE.json`, naming the version, full commit, release profile,
Expand Down Expand Up @@ -184,11 +185,20 @@ exact GitHub release, then verify both attestations and hashes:
```bash
gh release download vX.Y.Z --repo loadingalias/rscrypto \
-p 'rscrypto-X.Y.Z.crate' \
-p 'rscrypto-X.Y.Z-source.tar.gz' \
-p 'rscrypto-X.Y.Z-ct-evidence.tar.gz' \
-p 'rscrypto-X.Y.Z-repository-controls.json' \
-p 'rscrypto-X.Y.Z-release-manifest.json' \
-p SHA256SUMS
sha256sum --check SHA256SUMS
gh release verify vX.Y.Z --repo loadingalias/rscrypto
gh release verify-asset vX.Y.Z rscrypto-X.Y.Z-ct-evidence.tar.gz --repo loadingalias/rscrypto
gh attestation verify rscrypto-X.Y.Z.crate --repo loadingalias/rscrypto
gh attestation verify rscrypto-X.Y.Z-source.tar.gz --repo loadingalias/rscrypto
gh attestation verify rscrypto-X.Y.Z-ct-evidence.tar.gz --repo loadingalias/rscrypto
gh attestation verify rscrypto-X.Y.Z-repository-controls.json --repo loadingalias/rscrypto
gh attestation verify rscrypto-X.Y.Z-release-manifest.json --repo loadingalias/rscrypto
gh attestation verify SHA256SUMS --repo loadingalias/rscrypto
mkdir ct-evidence && tar -xzf rscrypto-X.Y.Z-ct-evidence.tar.gz -C ct-evidence
(cd ct-evidence && sha256sum --check CT-EVIDENCE-MANIFEST.txt)
```
Expand Down
71 changes: 45 additions & 26 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,16 @@ Configure the GitHub repository:

1. Create the active `protect-main` branch ruleset described by
[`.github/rulesets/protect-main.json`](../.github/rulesets/protect-main.json), with no bypass actors.
2. Create an environment named `crates-io`.
3. Add required reviewers for that environment.
4. Keep long-lived crates.io publish tokens out of repository secrets.
2. Create the active `protect-release-tags` tag ruleset described by
[`.github/rulesets/protect-release-tags.json`](../.github/rulesets/protect-release-tags.json), with no bypass
actors. It permits a new `v*` tag but prevents updating or deleting an existing one.
3. In **Settings → General → Releases**, enable release immutability as described by
[`.github/repository-settings/release-immutability.json`](../.github/repository-settings/release-immutability.json).
It applies only to releases published after the setting is enabled.
4. Create an environment named `crates-io`.
5. Add the current maintainer as its required reviewer. While the project has one maintainer, permit self-review;
enable independent review only after a second trusted maintainer exists.
6. Keep long-lived crates.io publish tokens out of repository secrets.

The environment name must match both crates.io and
[`.github/workflows/release.yaml`](../.github/workflows/release.yaml). If it
Expand Down Expand Up @@ -154,16 +161,16 @@ Pushing a `vX.Y.Z` tag starts the `Release` workflow. The workflow:
against the finalized release version. Before tagging, cargo-rail's automatic
release plan uses compatibility analysis to select the required version bump;
the extended release check reports the advisory verdict.
6. Builds the `.crate` once with `cargo package --locked` and transfers that
validated artifact to the publish job.
7. Rejects dirty VCS metadata and private or local-only package contents.
6. Builds the `.crate` once with `cargo package --locked` and creates a deterministic source archive from the exact
tag commit, then transfers both validated artifacts to the publish job.
7. Rejects dirty or mismatched VCS metadata, private or local-only package contents, and source archives that cannot
be reproduced from the tag commit.
8. Waits for the `CI` workflow on the same commit to pass.
9. Verifies the transferred artifact's SHA-256, then attests it with GitHub
build provenance.
9. Verifies both transferred SHA-256 values, then attests the crate and source archive with GitHub build provenance.
10. Compares every rule visible to the restricted workflow token with the
checked-in policy and attests the resulting repository controls JSON. GitHub
redacts bypass actors from this token; the artifact labels that field as
redacted instead of pretending the workflow reverified the pre-tag gate.
checked-in default-branch and release-tag policies and attests the resulting repository controls JSON. GitHub
redacts bypass actors from this token; the artifact labels those fields as redacted instead of pretending the
workflow reverified the pre-tag gate.
11. Requires successful Weekly CT/RSA evidence and RISC-V native/CT evidence
from the exact tag commit or the same mechanically proven release-only
ancestor, then downloads raw CT artifacts from both runs. The tag workflow
Expand All @@ -172,22 +179,26 @@ Pushing a `vX.Y.Z` tag starts the `Release` workflow. The workflow:
tool versions, timing coverage, BINSEC coverage, and raw artifact hashes.
The bundle separately records the release commit and evidence commit.
13. Attests the CT evidence bundle with GitHub build provenance.
14. Uses crates.io Trusted Publishing to get a temporary publish token.
15. Publishes with `cargo publish -p rscrypto --locked`.
16. Downloads the crate from crates.io and verifies the SHA-256 against the
attested package.
17. Creates or updates the GitHub Release with the crate, CT evidence bundle,
repository controls JSON, and `SHA256SUMS`.
14. Writes and attests a release identity manifest binding the tag, commit, tree, pinned and active Rust toolchain,
Cargo lockfile, release workflow, source archive, crate, CT evidence, and repository controls.
15. Writes and attests `SHA256SUMS` for every published release asset.
16. Creates a draft GitHub Release, attaches every asset, publishes it as an immutable release, and verifies GitHub's
release attestation before any irreversible registry change. A rerun may repair an unpublished draft, but an
already-published release is verified instead of modified.
17. Uses crates.io Trusted Publishing to get a temporary publish token.
18. Publishes with `cargo publish -p rscrypto --locked`.
19. Downloads the crate from crates.io and verifies the SHA-256 against the attested package.

The publish job uses the `crates-io` GitHub environment. GitHub requests
approval only after preflight and CI have passed, and before the OIDC token is
issued.

## Recovery

Re-running the workflow is safe after a partial failure. If crates.io already
has the version, the workflow downloads it and compares SHA-256 before touching
the GitHub Release. A mismatch is a hard stop.
Re-running the workflow is safe after a partial failure. If crates.io already has the version, the workflow downloads
it and compares SHA-256 before touching the GitHub Release. A draft release can be repaired and then published. A
published immutable release is never overwritten; the workflow verifies its release attestation and stable crate and
source assets before publishing to crates.io. A mismatch is a hard stop.

If the signed-tag key changes, update `.github/allowed-signers` in a reviewed
commit before creating the next release tag.
Expand All @@ -199,20 +210,28 @@ Consumers can verify the GitHub Release artifact:
```bash
gh release download vX.Y.Z --repo loadingalias/rscrypto \
-p 'rscrypto-X.Y.Z.crate' \
-p 'rscrypto-X.Y.Z-source.tar.gz' \
-p 'rscrypto-X.Y.Z-ct-evidence.tar.gz' \
-p 'rscrypto-X.Y.Z-repository-controls.json' \
-p 'rscrypto-X.Y.Z-release-manifest.json' \
-p SHA256SUMS
sha256sum --check SHA256SUMS
gh release verify vX.Y.Z --repo loadingalias/rscrypto
gh release verify-asset vX.Y.Z rscrypto-X.Y.Z.crate --repo loadingalias/rscrypto
gh release verify-asset vX.Y.Z rscrypto-X.Y.Z-source.tar.gz --repo loadingalias/rscrypto
gh attestation verify rscrypto-X.Y.Z.crate --repo loadingalias/rscrypto
gh attestation verify rscrypto-X.Y.Z-source.tar.gz --repo loadingalias/rscrypto
gh attestation verify rscrypto-X.Y.Z-ct-evidence.tar.gz --repo loadingalias/rscrypto
gh attestation verify rscrypto-X.Y.Z-repository-controls.json --repo loadingalias/rscrypto
gh attestation verify rscrypto-X.Y.Z-release-manifest.json --repo loadingalias/rscrypto
gh attestation verify SHA256SUMS --repo loadingalias/rscrypto
mkdir ct-evidence && tar -xzf rscrypto-X.Y.Z-ct-evidence.tar.gz -C ct-evidence
(cd ct-evidence && sha256sum --check CT-EVIDENCE-MANIFEST.txt)
```

The crate downloaded from crates.io should have the same SHA-256 as the
attested release artifact. The repository controls JSON records the expected
policy, live ruleset, effective rules on the default branch, capture time, and
release commit. `validation.bypass_actors.status` states whether bypass actors
were verified or redacted by GitHub. The JSON is evidence of the release-time
configuration, not a claim that GitHub settings cannot change later.
The crate downloaded from crates.io should have the same SHA-256 as the attested release artifact. The release
identity manifest is the machine-readable join between the release's source, artifacts, evidence, and toolchain. The
repository controls JSON records the expected policies, immutable-release setting, live branch and tag rulesets,
effective rules on the default branch, capture time, and release commit. Its validation fields state whether each
bypass list and the immutable-release setting were visible to the capturing token. The JSON is evidence of the
release-time configuration, not a claim that GitHub settings cannot change later.
2 changes: 2 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,8 @@ check-actions:
@scripts/ci/release-ci-check-test.sh
@scripts/ci/release-evidence-check-test.sh
@scripts/ci/repository-controls-evidence-test.sh
@scripts/ci/release-identity-test.sh
@scripts/ci/publish-immutable-release-test.sh
@scripts/ci/release-recipes-test.sh
@actionlint
@zizmor .github/workflows .github/actions
Expand Down
5 changes: 5 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ its caller.
| `ci/release-evidence-check-test.sh` | `just check-actions` |
| `ci/repository-controls-evidence.sh` | `just check-repository-controls`, `release.yaml`, `ci/repository-controls-evidence-test.sh` |
| `ci/repository-controls-evidence-test.sh` | `just check-actions` |
| `ci/package-release-source.sh` | `release.yaml`, `ci/release-identity-test.sh` |
| `ci/write-release-manifest.sh` | `release.yaml`, `ci/release-identity-test.sh` |
| `ci/release-identity-test.sh` | `just check-actions` |
| `ci/publish-immutable-release.sh` | `release.yaml`, `ci/publish-immutable-release-test.sh` |
| `ci/publish-immutable-release-test.sh` | `just check-actions` |
| `ci/release-recipes-test.sh` | `just check-actions` |
| `ci/upgrade-actions.sh` | `scripts/update/update-all.sh` |
| `ci/upgrade-actions-test.sh` | `just check-actions` |
Expand Down
23 changes: 23 additions & 0 deletions scripts/ci/check-ci-ownership-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ make_fixture() {
mkdir -p "$fixture/.github" "$fixture/.config" "$fixture/scripts/check" "$fixture/scripts/test"
cp -R "$REPO_ROOT/.github/workflows" "$fixture/.github/workflows"
cp -R "$REPO_ROOT/.github/rulesets" "$fixture/.github/rulesets"
cp -R "$REPO_ROOT/.github/repository-settings" "$fixture/.github/repository-settings"
cp "$REPO_ROOT/.github/dependabot.yaml" "$fixture/.github/dependabot.yaml"
cp "$REPO_ROOT/.config/target-matrix.json" "$fixture/.config/target-matrix.json"
cp -R "$REPO_ROOT/scripts/ci" "$fixture/scripts/ci"
Expand Down Expand Up @@ -109,6 +110,28 @@ make_fixture "$missing_repository_policy"
rm "$missing_repository_policy/.github/rulesets/protect-main.json"
expect_failure "$missing_repository_policy" "missing repository ruleset policy"

missing_release_tag_policy="$TMP_ROOT/missing-release-tag-policy"
make_fixture "$missing_release_tag_policy"
rm "$missing_release_tag_policy/.github/rulesets/protect-release-tags.json"
expect_failure "$missing_release_tag_policy" "missing release-tag ruleset policy"

missing_release_immutability_policy="$TMP_ROOT/missing-release-immutability-policy"
make_fixture "$missing_release_immutability_policy"
rm "$missing_release_immutability_policy/.github/repository-settings/release-immutability.json"
expect_failure "$missing_release_immutability_policy" "missing release immutability policy"

missing_source_archive="$TMP_ROOT/missing-source-archive"
make_fixture "$missing_source_archive"
sed -i.bak '/package-release-source\.sh/d' "$missing_source_archive/scripts/ci/release-preflight.sh"
rm -f "$missing_source_archive/scripts/ci/release-preflight.sh.bak"
expect_failure "$missing_source_archive" "release without deterministic source archive"

missing_release_manifest="$TMP_ROOT/missing-release-manifest"
make_fixture "$missing_release_manifest"
sed -i.bak '/write-release-manifest\.sh/d' "$missing_release_manifest/.github/workflows/release.yaml"
rm -f "$missing_release_manifest/.github/workflows/release.yaml.bak"
expect_failure "$missing_release_manifest" "release without identity manifest"

missing_riscv_workflow="$TMP_ROOT/missing-riscv-workflow"
make_fixture "$missing_riscv_workflow"
rm "$missing_riscv_workflow/.github/workflows/riscv.yaml"
Expand Down
62 changes: 62 additions & 0 deletions scripts/ci/check-ci-ownership.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,16 @@ CI_CHECK="$ROOT/scripts/ci/ci-check.sh"
RELEASE_PREFLIGHT="$ROOT/scripts/ci/release-preflight.sh"
RELEASE_CI="$ROOT/scripts/ci/release-ci-check.sh"
RELEASE_EVIDENCE="$ROOT/scripts/ci/release-evidence-check.sh"
RELEASE_SOURCE="$ROOT/scripts/ci/package-release-source.sh"
RELEASE_MANIFEST="$ROOT/scripts/ci/write-release-manifest.sh"
RELEASE_IDENTITY_TEST="$ROOT/scripts/ci/release-identity-test.sh"
PUBLISH_RELEASE="$ROOT/scripts/ci/publish-immutable-release.sh"
PUBLISH_RELEASE_TEST="$ROOT/scripts/ci/publish-immutable-release-test.sh"
REPOSITORY_CONTROLS="$ROOT/scripts/ci/repository-controls-evidence.sh"
REPOSITORY_CONTROLS_TEST="$ROOT/scripts/ci/repository-controls-evidence-test.sh"
REPOSITORY_POLICY="$ROOT/.github/rulesets/protect-main.json"
RELEASE_TAG_POLICY="$ROOT/.github/rulesets/protect-release-tags.json"
RELEASE_IMMUTABILITY_POLICY="$ROOT/.github/repository-settings/release-immutability.json"
DEPENDABOT="$ROOT/.github/dependabot.yaml"

fail() {
Expand Down Expand Up @@ -91,9 +98,16 @@ require_file "$CI_CHECK"
require_file "$RELEASE_PREFLIGHT"
require_file "$RELEASE_CI"
require_file "$RELEASE_EVIDENCE"
require_file "$RELEASE_SOURCE"
require_file "$RELEASE_MANIFEST"
require_file "$RELEASE_IDENTITY_TEST"
require_file "$PUBLISH_RELEASE"
require_file "$PUBLISH_RELEASE_TEST"
require_file "$REPOSITORY_CONTROLS"
require_file "$REPOSITORY_CONTROLS_TEST"
require_file "$REPOSITORY_POLICY"
require_file "$RELEASE_TAG_POLICY"
require_file "$RELEASE_IMMUTABILITY_POLICY"
require_file "$DEPENDABOT"

[[ $(yq eval '.version' "$DEPENDABOT") == "2" ]] || fail "Dependabot config must use version 2"
Expand Down Expand Up @@ -177,6 +191,10 @@ grep -Fq 'scripts/ci/release-evidence-check.sh --commit "$GITHUB_SHA"' "$RELEASE
|| fail "release must require paired Weekly and RISC-V evidence from one valid commit"
grep -Fq 'scripts/ci/repository-controls-evidence.sh' "$RELEASE" \
|| fail "release must capture the live repository controls"
grep -Fq 'scripts/ci/package-release-source.sh' "$RELEASE_PREFLIGHT" \
|| fail "release preflight must build the exact-commit source archive"
grep -Fq 'scripts/ci/write-release-manifest.sh' "$RELEASE" \
|| fail "release must bind artifacts and toolchain metadata in one identity manifest"
grep -Fq -- '--allow-redacted-bypass' "$RELEASE" \
|| fail "release must explicitly acknowledge GitHub's workflow-token bypass redaction"
# shellcheck disable=SC2016 # GitHub expression is an intentional literal workflow contract.
Expand All @@ -187,8 +205,52 @@ grep -Fq 'REPOSITORY_CONTROLS_SHA256' "$RELEASE" \
# shellcheck disable=SC2016 # Workflow shell variable is an intentional literal contract.
grep -Fq '"$REPOSITORY_CONTROLS_PATH"' "$RELEASE" \
|| fail "release must publish the repository controls evidence"
# shellcheck disable=SC2016 # GitHub expression is an intentional literal workflow contract.
grep -Fq 'subject-path: ${{ steps.package.outputs.source_path }}' "$RELEASE" \
|| fail "release must attest the deterministic source archive"
# shellcheck disable=SC2016 # GitHub expression is an intentional literal workflow contract.
grep -Fq 'subject-path: ${{ steps.release_manifest.outputs.manifest_path }}' "$RELEASE" \
|| fail "release must attest the identity manifest"
grep -Fq 'subject-path: SHA256SUMS' "$RELEASE" \
|| fail "release must attest its checksum set"
grep -Fq 'SOURCE_SHA256' "$RELEASE" \
|| fail "release must checksum the deterministic source archive"
grep -Fq 'RELEASE_MANIFEST_SHA256' "$RELEASE" \
|| fail "release must checksum the identity manifest"
grep -Fq 'scripts/ci/publish-immutable-release.sh' "$RELEASE" \
|| fail "release workflow must use the tested immutable publication state machine"
immutable_release_line=$(grep -nF 'scripts/ci/publish-immutable-release.sh' "$RELEASE" | cut -d: -f1)
crates_publish_line=$(grep -nF 'cargo publish -p rscrypto --locked' "$RELEASE" | cut -d: -f1)
[[ "$immutable_release_line" -lt "$crates_publish_line" ]] \
|| fail "release immutability must be verified before crates.io publication"
grep -Fq 'gh release create "$tag"' "$PUBLISH_RELEASE" \
|| fail "immutable publication must create the GitHub release"
grep -Fq -- '--draft' "$PUBLISH_RELEASE" \
|| fail "release assets must be assembled in a draft before immutable publication"
grep -Fq 'gh release verify "$tag"' "$PUBLISH_RELEASE" \
|| fail "release workflow must verify GitHub's immutable release attestation"
grep -Fq 'gh release verify-asset "$tag"' "$PUBLISH_RELEASE" \
|| fail "release workflow must verify assets against the immutable release"
grep -Fq -- "--jq '.assets[].name'" "$PUBLISH_RELEASE" \
|| fail "release workflow must reject missing or unexpected release assets"
grep -Fq -- '--stable-asset "$CRATE_PATH"' "$RELEASE" \
|| fail "release reruns must verify the crates.io-bound package asset"
grep -Fq -- '--stable-asset "$SOURCE_PATH"' "$RELEASE" \
|| fail "release reruns must verify the deterministic source archive"
grep -Fq '.github/rulesets/protect-main.json' "$REPOSITORY_CONTROLS" \
|| fail "repository controls evidence must validate the checked-in policy"
grep -Fq '.github/rulesets/protect-release-tags.json' "$REPOSITORY_CONTROLS" \
|| fail "repository controls evidence must validate immutable release tags"
jq -e '
.target == "tag"
and .enforcement == "active"
and .bypass_actors == []
and ([.rules[].type] | sort) == ["deletion", "update"]
' "$RELEASE_TAG_POLICY" >/dev/null || fail "release tags must reject updates and deletion without bypass"
jq -e '.enabled == true and (keys == ["enabled"])' "$RELEASE_IMMUTABILITY_POLICY" >/dev/null \
|| fail "repository policy must require immutable releases"
grep -Fq 'repos/$repo/immutable-releases' "$REPOSITORY_CONTROLS" \
|| fail "repository controls evidence must validate immutable releases before tagging"
grep -Fq 'current_user_can_bypass == "never"' "$REPOSITORY_CONTROLS" \
|| fail "repository controls evidence must reject bypass access"
grep -Fq 'run-id: ${{ needs.evidence-gate.outputs.weekly_run_id }}' "$RELEASE" \
Expand Down
Loading