diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59a535f..0d036e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,10 @@ on: description: Digest of the repository-owner authorization record; required only for promotion type: string required: false + owner_authorization_base64: + description: Canonical owner-authorization JSON encoded as one-line base64; required only for promotion + type: string + required: false permissions: contents: read @@ -176,7 +180,7 @@ jobs: echo "Candidate tag: $CANDIDATE_TAG" echo "Manifest SHA-256: $MANIFEST_SHA256" echo "Preparation is not publication authorization." - echo "Promotion remains blocked pending SLSA runtime assessment and exact owner authorization." + echo "Promotion remains blocked pending exact owner authorization." } >> "$GITHUB_STEP_SUMMARY" promotion-entry-gate: @@ -203,13 +207,17 @@ jobs: CANDIDATE_TAG: ${{ inputs.candidate_tag }} MANIFEST_SHA256: ${{ inputs.manifest_sha256 }} OWNER_AUTHORIZATION_SHA256: ${{ inputs.owner_authorization_sha256 }} + OWNER_AUTHORIZATION_BASE64: ${{ inputs.owner_authorization_base64 }} PREPARATION_RUN_ID: ${{ inputs.preparation_run_id }} + DISPATCH_ACTOR_ID: ${{ github.actor_id }} run: | set -euo pipefail [[ "$CANDIDATE_COMMIT" =~ ^[0-9a-f]{40}$ ]] [[ "$MANIFEST_SHA256" =~ ^[0-9a-f]{64}$ ]] [[ "$OWNER_AUTHORIZATION_SHA256" =~ ^[0-9a-f]{64}$ ]] + [[ -n "$OWNER_AUTHORIZATION_BASE64" ]] [[ "$PREPARATION_RUN_ID" =~ ^[0-9]+$ ]] + [[ "$DISPATCH_ACTOR_ID" == 3743841 ]] [[ "$(git rev-parse HEAD)" == "$CANDIDATE_COMMIT" ]] [[ "$CANDIDATE_TAG" == "auths-v$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)" ]] - name: Refuse absent or unprotected promotion environment @@ -237,8 +245,14 @@ jobs: CANDIDATE_TAG: ${{ inputs.candidate_tag }} MANIFEST_SHA256: ${{ inputs.manifest_sha256 }} OWNER_AUTHORIZATION_SHA256: ${{ inputs.owner_authorization_sha256 }} + OWNER_AUTHORIZATION_BASE64: ${{ inputs.owner_authorization_base64 }} PREPARATION_RUN_ID: ${{ inputs.preparation_run_id }} run: | + set -euo pipefail + printf '%s' "$OWNER_AUTHORIZATION_BASE64" \ + | base64 --decode > target/owner-authorization.json + [[ "$(sha256sum target/owner-authorization.json | cut -d' ' -f1)" == \ + "$OWNER_AUTHORIZATION_SHA256" ]] jq -n \ --arg commit "$CANDIDATE_COMMIT" \ --arg tag "$CANDIDATE_TAG" \ @@ -252,6 +266,7 @@ jobs: cargo xtask release-control verify-promotion target/staged target/promotion-request.json + target/owner-authorization.json - name: Preserve verified promotion input for protected job uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -259,6 +274,7 @@ jobs: path: | target/staged/ target/promotion-request.json + target/owner-authorization.json if-no-files-found: error retention-days: 7 compression-level: 0 @@ -284,12 +300,19 @@ jobs: CANDIDATE_TAG: ${{ inputs.candidate_tag }} GH_TOKEN: ${{ github.token }} MANIFEST_SHA256: ${{ inputs.manifest_sha256 }} + OWNER_AUTHORIZATION_SHA256: ${{ inputs.owner_authorization_sha256 }} run: | set -euo pipefail MANIFEST=target/promotion/staged/target/release-evidence/release-manifest.json [[ "$(sha256sum "$MANIFEST" | cut -d' ' -f1)" == "$MANIFEST_SHA256" ]] [[ "$(jq -r '.source.commit' "$MANIFEST")" == "$CANDIDATE_COMMIT" ]] [[ "$(jq -r '.release.tag' "$MANIFEST")" == "$CANDIDATE_TAG" ]] + AUTHORIZATION=target/promotion/owner-authorization.json + PROMOTION_REQUEST=target/promotion/promotion-request.json + [[ "$(sha256sum "$AUTHORIZATION" | cut -d' ' -f1)" == \ + "$OWNER_AUTHORIZATION_SHA256" ]] + [[ "$(jq -r '.ownerAuthorizationSha256' "$PROMOTION_REQUEST")" == \ + "$OWNER_AUTHORIZATION_SHA256" ]] EXISTING="$(gh api "repos/auths-dev/auths-proof/git/ref/tags/$CANDIDATE_TAG" --jq '.object.sha' 2>/dev/null || true)" [[ -z "$EXISTING" || "$EXISTING" == "$CANDIDATE_COMMIT" ]] if [[ -z "$EXISTING" ]]; then @@ -304,6 +327,9 @@ jobs: run: | set -euo pipefail MANIFEST=target/promotion/staged/target/release-evidence/release-manifest.json + NOTES=target/promotion/staged/target/release-evidence/RELEASE_CANDIDATE_NOTES.md + AUTHORIZATION=target/promotion/owner-authorization.json + PROMOTION_REQUEST=target/promotion/promotion-request.json mapfile -t SUBJECTS < <(jq -r '.subjects[].name' "$MANIFEST" | sed 's#^#target/promotion/staged/#') mapfile -t EVIDENCE < <(find target/promotion/staged/target/release-evidence -type f -print | sort) gh release create "$CANDIDATE_TAG" \ @@ -311,6 +337,8 @@ jobs: --verify-tag \ --prerelease \ --title "Auths $CANDIDATE_TAG" \ - --notes "Release candidate. Not stable v1. No independent security audit is claimed. Registry publication remains separately gated by issue #50." \ + --notes-file "$NOTES" \ "${SUBJECTS[@]}" \ - "${EVIDENCE[@]}" + "${EVIDENCE[@]}" \ + "$AUTHORIZATION" \ + "$PROMOTION_REQUEST" diff --git a/release/CANDIDATE_CLOSURE.md b/release/CANDIDATE_CLOSURE.md index 1a5f430..20ca568 100644 --- a/release/CANDIDATE_CLOSURE.md +++ b/release/CANDIDATE_CLOSURE.md @@ -2,10 +2,13 @@ ## Scope -This record closes the repository-local portion of AP32-PR5. The commit that -merges this bounded change is the proposed candidate revision. It becomes the -final candidate revision only after all required hosted checks and the two -isolated release preparations complete successfully against that exact commit. +This record closes the repository-local portion of AP32-PR5. The initial +candidate revision completed two isolated preparations successfully in +[run 30849197798](https://github.com/auths-dev/auths-proof/actions/runs/30849197798). +That run exposed no remaining reproducibility defect and supplied the runtime +evidence for the builder assessment. The commit that merges the assessment is +the new proposed candidate revision and must complete the same preparation +again because its release metadata changed. The change intentionally contains no semantic refactor. It advances release metadata to the first approved RC coordinate, regenerates the semantic-freeze @@ -19,8 +22,8 @@ behavior remain unchanged and domain-owned. - Python distribution `auths`: `1.0.0rc1`, the PEP 440 spelling equivalent to the SemVer RC. - Candidate tag contract: `auths-v1.0.0-rc.1`. -- Release public-surface semantic identity: version 4. -- Semantic-freeze inventory: version 4. +- Release public-surface semantic identity: version 15. +- Semantic-freeze inventory: version 15. The release manifest, rather than this prose, owns the eventual full commit and artifact digests. @@ -37,8 +40,8 @@ The following are hard gates and are not represented as passed by this PR: 4. their deterministic subjects match according to the frozen reproducibility classes; 5. every manifest subject has verified signed provenance and SPDX coverage; -6. an evidence-backed assessment establishes every applicable SLSA 1.2 Build - Level 3 producer and build-platform requirement; and +6. the checked-in SLSA 1.2 Build Level 3 assessment remains valid for the exact + reusable-builder bytes; and 7. offline verification succeeds from the staged, digest-bound bundle. If a hosted preparation exposes any defect or drift, this proposed candidate @@ -50,9 +53,10 @@ two preparations must start again from its new merged revision. This repository change does not configure GitHub settings, upload a secret, run a preparation, approve a manifest, create or move a tag, create a GitHub prerelease, publish a registry package, engage an external reviewer, or make a -public assurance claim. The SLSA runtime assessment and independent security -review remain pending. Issue #50 continues to govern separately authorized -publication. +public assurance claim. The SLSA build assessment has passed, but the exact +candidate must be prepared again; owner promotion authorization and the Phase +9 independent security review remain pending. Issue #50 continues to govern +separately authorized publication. ## Rollback and withdrawal diff --git a/release/README.md b/release/README.md new file mode 100644 index 0000000..78711cc --- /dev/null +++ b/release/README.md @@ -0,0 +1,22 @@ +# Auths release directory + +This directory owns the frozen release contract and the evidence needed to +prepare, authorize, promote, verify, or withdraw an Auths release candidate. +Preparation is not authorization, and GitHub prerelease promotion is not +registry publication. + +Start with: + +- [`RELEASE_RUNBOOK.md`](RELEASE_RUNBOOK.md) for the exact operator procedure; +- [`RELEASE_CONTROL.md`](RELEASE_CONTROL.md) for the security architecture and + trust boundaries; +- [`CANDIDATE_CLOSURE.md`](CANDIDATE_CLOSURE.md) for the current candidate and + remaining gates; +- [`RELEASE_CANDIDATE_NOTES.md`](RELEASE_CANDIDATE_NOTES.md) for the text that + will become the GitHub prerelease description; and +- [`SLSA_BUILD_LEVEL_3_ASSESSMENT.md`](SLSA_BUILD_LEVEL_3_ASSESSMENT.md) for + the assessed build-platform boundary. + +The JSON schemas, fixtures, subject catalogue, naming authority, and semantic +freeze are machine-enforced inputs. Do not hand-edit generated evidence merely +to make a release pass. diff --git a/release/RELEASE_CONTROL.md b/release/RELEASE_CONTROL.md index ede84f4..0557865 100644 --- a/release/RELEASE_CONTROL.md +++ b/release/RELEASE_CONTROL.md @@ -52,18 +52,33 @@ Promotion requires a second workflow dispatch naming: - exact release-manifest digest; and - SHA-256 digest of the repository owner's separate authorization record. +The dispatch also carries the exact canonical authorization record as one-line +base64. The entry gate decodes it, requires the immutable repository-owner +identity, rejects unknown or non-canonical fields, verifies its digest, and +requires every candidate, run, manifest, destination, and statement field to +match the promotion request. The protected job receives and rechecks that +record. Both the authorization and promotion request are preserved with the +GitHub prerelease evidence. The schema is +[`owner-authorization.schema.json`](owner-authorization.schema.json); the +operator procedure is [`RELEASE_RUNBOOK.md`](RELEASE_RUNBOOK.md). + The unprivileged entry job downloads and validates the staged artifact. The protected `release-promotion` job receives only verified staged bytes. Static repository policy rejects checkout, compilation, packaging, evidence generation, or overwriting in that job. It may create the tag only when the tag is absent, or resume when the existing tag already targets the exact candidate. -It then creates a GitHub prerelease from the staged subjects and evidence. - -The first-RC manifest deliberately records the SLSA 1.2 Build Level 3 runtime -assessment as pending. Promotion remains terminally blocked until an -independent assessment of the implemented builder and an actual preparation -run changes that exact manifest field to `passed` through the candidate-closure -process. No label or ordinary successful workflow is treated as that evidence. +It then creates a GitHub prerelease from the staged subjects and evidence. The +prerelease description comes from the digest-bound staged copy of +[`RELEASE_CANDIDATE_NOTES.md`](RELEASE_CANDIDATE_NOTES.md), not mutable or +hardcoded workflow prose. + +The SLSA 1.2 Build Level 3 assessment is recorded in +[`SLSA_BUILD_LEVEL_3_ASSESSMENT.md`](SLSA_BUILD_LEVEL_3_ASSESSMENT.md) and its +machine-readable companion. It is bound to an observed successful preparation +and the exact reusable-builder SHA-256. Finalization and offline promotion +verification reject a stale workflow digest, an incomplete requirement set, +or a status other than `passed`. The assessment is not an independent security +audit and does not itself authorize promotion. Publication to crates.io, npm, and PyPI remains separately gated by [issue #50](https://github.com/auths-dev/auths-proof/issues/50). This workflow @@ -90,6 +105,11 @@ semantic freeze, SPDX subject coverage, and `preparation-comparison.json`. Verification requires neither repository write access nor an Auths-hosted service. +After promotion, also verify that `promotion-request.json` names the same +manifest, run, tag, and authorization SHA-256, and that the published canonical +`owner-authorization.json` hashes to that value. These two post-preparation +records authorize distribution; they do not retroactively become build inputs. + ## Withdrawal Never move or delete an issued candidate tag. Mark a defective candidate as diff --git a/release/RELEASE_RUNBOOK.md b/release/RELEASE_RUNBOOK.md new file mode 100644 index 0000000..f084af3 --- /dev/null +++ b/release/RELEASE_RUNBOOK.md @@ -0,0 +1,185 @@ +# Auths release-candidate operator runbook + +This runbook is the exact operational sequence for an Auths release candidate. +It does not grant authority to skip a gate. The repository owner must authorize +promotion of one exact prepared manifest; crates.io, npm, and PyPI publication +remain separately authorized operations. + +## State sequence + +```text +candidate PR merged + | + v +required CI passes on exact main commit + | + v +prepare twice -> verify provenance -> compare -> stage official bytes + | + v +owner creates exact canonical authorization record + | + v +protected promotion verifies staged bytes and authorization + | + v +immutable tag + GitHub prerelease + | + +----> Phase 8 exact claims -> Phase 9 independent review + | + +----> separately authorized registry publication +``` + +## 1. Establish the candidate + +Merge the final bounded candidate-closure PR. Wait for every required check on +the resulting `main` commit to finish successfully. Record: + +- the full 40-character `main` commit; +- the exact tag coordinate, such as `auths-v1.0.0-rc.1`; and +- the successful required-check run URLs. + +Do not prepare a branch head, local commit, dirty worktree, shortened commit, +or tag that differs from the workspace version. + +## 2. Prepare twice + +Open **Actions → Auths release control → Run workflow** from `main` and enter: + +| Input | Value | +| --- | --- | +| `operation` | `prepare` | +| `candidate_commit` | Exact 40-character `main` commit | +| `candidate_tag` | Exact immutable RC tag | +| promotion-only inputs | Leave empty | + +The workflow verifies the protected `release-candidate` environment and +immutable OIDC configuration, then invokes the same reusable builder on two +separately provisioned GitHub-hosted runners. It verifies provenance, compares +the declared reproducibility classes, and stages the official bytes without +overwriting an existing artifact. + +The preparation is successful only when every job is terminal and successful. +From the run summary, record: + +- the numeric preparation run ID from the run URL; +- the exact candidate commit and tag; and +- the 64-character release-manifest SHA-256. + +The staged artifact is named +`auths-staged--` and is retained for 90 days. Its +existence is not publication authorization. + +## 3. Inspect the prepared evidence + +Before authorization, confirm that: + +- the official and reproduction builders both succeeded; +- the preparation comparison passed; +- every subject has verified signed provenance and SPDX coverage; +- the manifest names the expected commit, tag, semantic freeze, subjects, and + SLSA assessment; +- `RELEASE_CANDIDATE_NOTES.md` is digest-bound in the manifest; and +- offline verification succeeds from the staged artifact. + +Do not authorize a run with a warning, skipped required job, mismatched digest, +expired artifact, or unresolved question about the exact bytes. + +## 4. Create the exact owner authorization + +Create the record only after Step 3 passes. The canonical representation is +UTF-8 compact JSON in the field order below with exactly one trailing newline. +The workflow rejects unknown fields, reordered or pretty-printed bytes, a +different owner, and any mismatch with the staged candidate. + +```console +jq -cn \ + --arg issued_at "" \ + --arg commit "" \ + --arg tag "" \ + --arg manifest "" \ + --arg run "" \ + '{schema:"auths.owner-release-authorization/1",operation:"promote-prepared-candidate",repository:"auths-dev/auths-proof",authorizedBy:"bordumb",authorizedById:"3743841",issuedAt:$issued_at,candidateCommit:$commit,tag:$tag,manifestSha256:$manifest,preparationRunId:$run,destinations:["github-prerelease"],statement:"I authorize promotion of these exact prepared bytes to a GitHub prerelease."}' \ + > owner-authorization.json +``` + +Inspect the record, then calculate its digest and one-line base64 value: + +```console +shasum -a 256 owner-authorization.json +base64 < owner-authorization.json | tr -d '\n' +``` + +The record is not a secret. It is an auditable authorization artifact. Do not +reuse it for another commit, manifest, run, tag, destination, or RC ordinal. + +## 5. Promote the prepared bytes + +Run **Auths release control** again from `main` with: + +| Input | Value | +| --- | --- | +| `operation` | `promote-github-prerelease` | +| `candidate_commit` | Authorized 40-character commit | +| `candidate_tag` | Authorized RC tag | +| `preparation_run_id` | Numeric ID recorded in Step 2 | +| `manifest_sha256` | Authorized manifest digest | +| `owner_authorization_sha256` | Digest from Step 4 | +| `owner_authorization_base64` | One-line base64 from Step 4 | + +Only the immutable repository owner identity may dispatch this operation. The +entry gate decodes and validates the canonical authorization, downloads the +exact staged artifact from the named run, and verifies the complete promotion +request without rebuilding. The protected `release-promotion` environment +then requires its configured approval. + +The protected job may create the tag only if it is absent. If the tag already +exists, it must point to the exact authorized commit. The prerelease is created +from staged subjects and evidence, uses the digest-bound candidate notes, and +publishes the owner authorization and promotion request as evidence. + +## 6. Verify promotion + +After the workflow succeeds: + +1. confirm the tag points to the authorized full commit; +2. confirm the GitHub release is marked **prerelease**; +3. confirm its description matches the staged + `RELEASE_CANDIDATE_NOTES.md`; +4. download the manifest, subjects, evidence, owner authorization, and + promotion request; +5. verify their SHA-256 relationships and every manifest digest reference; +6. verify every subject's attestation using the command in + [`RELEASE_CONTROL.md`](RELEASE_CONTROL.md); and +7. record the release URL and verification result for Phase 8. + +Do not describe the RC as independently audited, certified, compliant, +production-ready, or stable v1. + +## 7. Registry publication and later phases + +GitHub prerelease promotion does not publish crates.io, npm, or PyPI packages. +Those uploads remain separately gated by +[issue #50](https://github.com/auths-dev/auths-proof/issues/50), including +registry-specific identity, provenance, package-order, and rollback checks. + +After an immutable, verified RC exists, Phase 8 may bind exact public claims to +its subjects. Phase 9 independent review remains required before making any +external-review claim. + +## Failure and retry rules + +| Failure point | Required response | +| --- | --- | +| Candidate CI or preparation fails | Fix through a bounded PR, merge a new commit, and restart both preparations. | +| Builder workflow bytes change | Treat the SLSA assessment as stale; reassess an observed successful run before promotion. | +| Reproduction or digest comparison fails | Reject the candidate; never relabel the differing subject as reproducible. | +| Authorization content or digest fails | Create a new canonical record for the exact unchanged candidate; do not weaken validation. | +| Promotion fails before tag creation | Correct only the external gate or exact input and retry against the same staged bytes. | +| Existing tag points elsewhere | Stop. Never move or delete the tag. Use a new RC ordinal after remediation. | +| Prerelease exists with incomplete assets | Stop and investigate; do not overwrite evidence casually. Resume only when the exact tag and staged bytes remain provable. | +| Defect found after publication | Mark the RC withdrawn, publish bounded guidance, fix at a new commit, increment the RC ordinal, and prepare again. | + +Never rebuild during promotion, overwrite a staged artifact, move an issued +tag, reuse an authorization for different bytes, or claim an external gate +passed without retrievable evidence. diff --git a/release/SLSA_BUILD_LEVEL_3_ASSESSMENT.md b/release/SLSA_BUILD_LEVEL_3_ASSESSMENT.md new file mode 100644 index 0000000..30c691f --- /dev/null +++ b/release/SLSA_BUILD_LEVEL_3_ASSESSMENT.md @@ -0,0 +1,87 @@ +# SLSA 1.2 Build Level 3 assessment + +## Result and boundary + +The Auths reusable release builder passes the applicable SLSA 1.2 Build Level +3 requirements for the workflow bytes whose SHA-256 is +`e2762ffe4ee2aa2c76c79f7382b2ac4913582a21630907c27fa0a517a1c7c25d`. +The machine-readable authority is +[`slsa-build-level-3-assessment.json`](slsa-build-level-3-assessment.json). + +This is a repository-owner-delegated technical assessment of the build +platform and an observed successful execution. It is not the Phase 9 +independent security review, and it does not claim that the source, its +dependencies, external providers, or the GitHub control plane are free of +vulnerabilities. + +The assessment uses the [SLSA 1.2 requirements](https://slsa.dev/spec/v1.2/requirements) +and [Build track basics](https://slsa.dev/spec/v1.2/build-track-basics). +GitHub documents that artifact attestations provide SLSA Build Level 2 and +that a reusable workflow using attestations can establish the isolation needed +for Build Level 3 in +[Increasing the security rating of your artifact attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/increase-security-rating). + +## Assessed runtime + +- Preparation run: [30849197798](https://github.com/auths-dev/auths-proof/actions/runs/30849197798) +- Source commit: `a6e7f99a151b641b94837504749404109f7a59e2` +- Proposed tag: `auths-v1.0.0-rc.1` +- Release-manifest SHA-256: + `2c9f38a7c2190e4d531af6f5bc3bb318bde18a599da459bc25802958eeefdd3a` +- Official build attestation: + [38657617](https://github.com/auths-dev/auths-proof/attestations/38657617) +- Independent-reproduction attestation: + [38657752](https://github.com/auths-dev/auths-proof/attestations/38657752) + +Both reusable-builder invocations completed on separate GitHub-hosted jobs. +Their provenance verified against the exact repository, reusable workflow, +source commit, and subjects, with self-hosted runners denied. Their +deterministic release subjects matched, and the offline staging verification +completed. GitHub states that standard hosted jobs run on a new virtual +machine and are decommissioned after the job in +[GitHub-hosted runners](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). + +## Requirement assessment + +| Requirement | Result | Concrete evidence | +| --- | --- | --- | +| L1: consistent build process | Pass | One digest-bound reusable workflow performs both isolated preparations with pinned action revisions and tool versions. | +| L1: provenance exists and identifies the build | Pass | GitHub artifact attestations use SLSA provenance v1 and bind every declared subject, the builder workflow, source repository, and source commit. | +| L1: provenance is distributed | Pass | Both signed attestations are retrievable through GitHub's attestation store, and the complete Sigstore bundle and trust root are included in the staged release evidence. | +| L2: hosted build platform | Pass | Both preparations ran on GitHub-hosted `ubuntu-latest` runners; the verifier rejects self-hosted provenance. | +| L2: platform-generated, signed provenance | Pass | `actions/attest` requests GitHub artifact attestations using job-scoped `attestations: write` and `id-token: write`; no repository signing key is supplied to the build. | +| L2: provenance authenticity is verified | Pass | The builder preserves the trust root and verifies every subject with exact repository, workflow, signer digest, source digest, and the self-hosted denial. | +| L3: build isolation | Pass | The build is encapsulated in a reusable workflow and the two invocations use separately provisioned hosted VMs. Neither preparation consumes output from the other before comparison. | +| L3: provenance signing secret is inaccessible to user build steps | Pass | Signing is performed by GitHub's artifact-attestation service from an ephemeral OIDC identity. The workflow contains no long-lived provenance key or signing secret for build commands to read. | + +The L3 conclusion is about the producer and build-platform controls, not the +truth of the program's security properties. SLSA explicitly leaves the build +platform and verification roots within the consumer's trust analysis. + +## Staleness and enforcement + +The result is valid only for the exact reusable-builder bytes named above and +the documented GitHub-hosted execution boundary. Release finalization reads +the machine-readable assessment, requires every enumerated requirement to be +`passed`, verifies the assessed workflow SHA-256 against the checked-out +workflow, and embeds a digest reference to the assessment in the release +manifest. Offline promotion verification repeats those checks against the +staged files. + +Changing the builder workflow therefore makes the assessment stale and blocks +the release. A successful CI label, an edited prose claim, or a manifest field +alone cannot preserve the result. + +## Exclusions + +This assessment does not: + +- authorize tag creation, a GitHub prerelease, or registry publication; +- replace the exact repository-owner promotion authorization; +- claim an independent security audit, certification, compliance, or + production readiness; +- establish that source code, dependencies, providers, or generated artifacts + are vulnerability-free; or +- cover compromise of GitHub, Sigstore trust roots, or a consumer's verifier. + +The Phase 9 independent review remains a separate post-RC gate. diff --git a/release/owner-authorization.schema.json b/release/owner-authorization.schema.json new file mode 100644 index 0000000..d143266 --- /dev/null +++ b/release/owner-authorization.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://auths.dev/schemas/owner-release-authorization-1.json", + "title": "Auths owner release authorization", + "type": "object", + "required": [ + "schema", + "operation", + "repository", + "authorizedBy", + "authorizedById", + "issuedAt", + "candidateCommit", + "tag", + "manifestSha256", + "preparationRunId", + "destinations", + "statement" + ], + "properties": { + "schema": { "const": "auths.owner-release-authorization/1" }, + "operation": { "const": "promote-prepared-candidate" }, + "repository": { "const": "auths-dev/auths-proof" }, + "authorizedBy": { "const": "bordumb" }, + "authorizedById": { "const": "3743841" }, + "issuedAt": { "type": "string", "format": "date-time" }, + "candidateCommit": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "tag": { "type": "string", "pattern": "^auths-v[0-9]+\\.[0-9]+\\.[0-9]+-rc\\.[1-9][0-9]*$" }, + "manifestSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "preparationRunId": { "type": "string", "pattern": "^[1-9][0-9]*$" }, + "destinations": { + "const": ["github-prerelease"] + }, + "statement": { + "const": "I authorize promotion of these exact prepared bytes to a GitHub prerelease." + } + }, + "additionalProperties": false +} diff --git a/release/release-manifest.contract-fixture.json b/release/release-manifest.contract-fixture.json index b2aa3ca..b1cf8db 100644 --- a/release/release-manifest.contract-fixture.json +++ b/release/release-manifest.contract-fixture.json @@ -12,6 +12,23 @@ "path": "release/semantic-freeze.json", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "builder": { + "workflow": "auths-dev/auths-proof/.github/workflows/release-builder.yml", + "workflowDigest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "environment": "release-candidate", + "oidcIssuer": "https://token.actions.githubusercontent.com", + "oidcSubject": "repo:auths-dev@260513770/auths-proof@1310728509:environment:release-candidate", + "slsaTarget": "SLSA 1.2 Build Level 3", + "slsaAssessmentStatus": "passed", + "slsaAssessment": { + "path": "target/release-evidence/slsa-build-level-3-assessment.json", + "sha256": "3333333333333333333333333333333333333333333333333333333333333333" + }, + "slsaBuilderWorkflow": { + "path": "target/release-evidence/release-builder.yml", + "sha256": "4444444444444444444444444444444444444444444444444444444444444444" + } + }, "subjects": [ { "name": "target/package/auths-1.0.0-rc.1.crate", @@ -49,6 +66,10 @@ "path": "evidence/benchmarks.json", "sha256": "2222222222222222222222222222222222222222222222222222222222222222" } - ] + ], + "releaseNotes": { + "path": "evidence/RELEASE_CANDIDATE_NOTES.md", + "sha256": "5555555555555555555555555555555555555555555555555555555555555555" + } } } diff --git a/release/release-manifest.schema.json b/release/release-manifest.schema.json index 6e400e7..b15440e 100644 --- a/release/release-manifest.schema.json +++ b/release/release-manifest.schema.json @@ -3,7 +3,7 @@ "$id": "https://auths.dev/schemas/release-manifest-1.json", "title": "Auths release manifest", "type": "object", - "required": ["schema", "release", "source", "semanticFreeze", "subjects", "evidence"], + "required": ["schema", "release", "source", "semanticFreeze", "subjects", "builder", "evidence"], "properties": { "schema": { "const": "auths.release-manifest/1" }, "release": { @@ -25,6 +25,17 @@ "additionalProperties": true }, "semanticFreeze": { "$ref": "#/$defs/digestReference" }, + "builder": { + "type": "object", + "required": ["workflow", "workflowDigest", "environment", "oidcIssuer", "oidcSubject", "slsaTarget", "slsaAssessmentStatus", "slsaAssessment", "slsaBuilderWorkflow"], + "properties": { + "slsaTarget": { "const": "SLSA 1.2 Build Level 3" }, + "slsaAssessmentStatus": { "const": "passed" }, + "slsaAssessment": { "$ref": "#/$defs/digestReference" }, + "slsaBuilderWorkflow": { "$ref": "#/$defs/digestReference" } + }, + "additionalProperties": true + }, "subjects": { "type": "array", "minItems": 1, @@ -47,13 +58,14 @@ }, "evidence": { "type": "object", - "required": ["spdx", "provenance", "formalManifest", "conformance", "benchmarks"], + "required": ["spdx", "provenance", "formalManifest", "conformance", "benchmarks", "releaseNotes"], "properties": { "spdx": { "$ref": "#/$defs/nonEmptyDigestReferences" }, "provenance": { "$ref": "#/$defs/nonEmptyDigestReferences" }, "formalManifest": { "$ref": "#/$defs/digestReference" }, "conformance": { "$ref": "#/$defs/nonEmptyDigestReferences" }, - "benchmarks": { "$ref": "#/$defs/nonEmptyDigestReferences" } + "benchmarks": { "$ref": "#/$defs/nonEmptyDigestReferences" }, + "releaseNotes": { "$ref": "#/$defs/digestReference" } }, "additionalProperties": true } diff --git a/release/semantic-freeze.json b/release/semantic-freeze.json index a119c2e..2c93013 100644 --- a/release/semantic-freeze.json +++ b/release/semantic-freeze.json @@ -1,6 +1,6 @@ { "schema": "auths.semantic-freeze/1", - "freezeVersion": 13, + "freezeVersion": 15, "publicSurface": { "rustRoots": [ "auths", @@ -592,7 +592,7 @@ }, { "id": "auths.release.public-surface", - "version": 13, + "version": 15, "classification": "release-metadata", "categories": [ "package-names", @@ -643,12 +643,17 @@ "product/runtime/auths-runtime/Cargo.toml", "product/sdk/auths-sdk/Cargo.toml", "release/CANDIDATE_CLOSURE.md", + "release/README.md", "release/RELEASE_CANDIDATE_NOTES.md", "release/RELEASE_CONTROL.md", + "release/RELEASE_RUNBOOK.md", + "release/SLSA_BUILD_LEVEL_3_ASSESSMENT.md", + "release/owner-authorization.schema.json", "release/public-naming.toml", "release/release-manifest.contract-fixture.json", "release/release-manifest.schema.json", "release/release-subjects.toml", + "release/slsa-build-level-3-assessment.json", "rust-toolchain.toml", "xtask/src/architecture.rs", "xtask/src/checks.rs", @@ -659,7 +664,7 @@ "xtask/src/release_control.rs", "xtask/src/semantic_freeze.rs" ], - "sha256": "e2ac37e0a545ad9f76c97c0fe238d429918e32f9dcc345943de32945122883c5" + "sha256": "5bfffdc929463d7205f49ccbfb68d13656615fc611f1b6fed5f1cc84c21fa235" } ] } diff --git a/release/slsa-build-level-3-assessment.json b/release/slsa-build-level-3-assessment.json new file mode 100644 index 0000000..f513ca1 --- /dev/null +++ b/release/slsa-build-level-3-assessment.json @@ -0,0 +1,68 @@ +{ + "schema": "auths.slsa-build-assessment/1", + "specification": "https://slsa.dev/spec/v1.2/requirements", + "target": "SLSA 1.2 Build Level 3", + "status": "passed", + "assessmentNature": "repository-owner-delegated-technical-assessment", + "assessedBuilder": { + "platform": "github-actions", + "workflow": ".github/workflows/release-builder.yml", + "workflowEvidence": "target/release-evidence/release-builder.yml", + "workflowSha256": "e2762ffe4ee2aa2c76c79f7382b2ac4913582a21630907c27fa0a517a1c7c25d", + "runner": "github-hosted/ubuntu-latest", + "provenance": "github-artifact-attestations/slsa-provenance-v1" + }, + "runtimeEvidence": { + "preparationRunId": "30849197798", + "preparationRunUrl": "https://github.com/auths-dev/auths-proof/actions/runs/30849197798", + "candidateCommit": "a6e7f99a151b641b94837504749404109f7a59e2", + "candidateTag": "auths-v1.0.0-rc.1", + "releaseManifestSha256": "2c9f38a7c2190e4d531af6f5bc3bb318bde18a599da459bc25802958eeefdd3a", + "officialAttestation": "https://github.com/auths-dev/auths-proof/attestations/38657617", + "reproductionAttestation": "https://github.com/auths-dev/auths-proof/attestations/38657752" + }, + "requirements": [ + { + "id": "build-l1-consistent-process", + "status": "passed" + }, + { + "id": "build-l1-provenance-exists", + "status": "passed" + }, + { + "id": "build-l1-provenance-distributed", + "status": "passed" + }, + { + "id": "build-l2-hosted-platform", + "status": "passed" + }, + { + "id": "build-l2-authentic-provenance", + "status": "passed" + }, + { + "id": "build-l2-consumer-verification", + "status": "passed" + }, + { + "id": "build-l3-isolated-builds", + "status": "passed" + }, + { + "id": "build-l3-signing-secrets-inaccessible", + "status": "passed" + } + ], + "validity": { + "condition": "The assessment remains valid only while the assessed workflow bytes and GitHub-hosted builder boundary remain unchanged.", + "enforcement": "Release finalization rejects a workflow digest mismatch or an incomplete assessment." + }, + "limitations": [ + "This is a SLSA build-platform assessment, not an independent security audit.", + "It does not establish source correctness, dependency trustworthiness, provider correctness, or production readiness.", + "It does not cover compromise of GitHub, Sigstore roots, or the consumer verification environment.", + "A new builder workflow digest requires a new runtime assessment." + ] +} diff --git a/xtask/src/release.rs b/xtask/src/release.rs index 705eaf7..e3cb979 100644 --- a/xtask/src/release.rs +++ b/xtask/src/release.rs @@ -67,7 +67,10 @@ fn validate_release_workflow_contract() -> Result<(), String> { "overwrite: false", "environment: release-promotion", "cargo xtask release-control verify-promotion", - "Promotion remains blocked pending SLSA runtime assessment", + "owner_authorization_base64:", + "target/owner-authorization.json", + "--notes-file", + "Promotion remains blocked pending exact owner authorization", ] { if !controller.contains(required) { return Err(format!("release control workflow is missing: {required}")); @@ -419,6 +422,19 @@ fn validate_release_contract_sources() -> Result<(), String> { { return Err("release-manifest schema identity or release status drifted".to_owned()); } + let authorization_schema: Value = serde_json::from_slice( + &fs::read(root().join("release/owner-authorization.schema.json")) + .map_err(|error| format!("could not read owner-authorization schema: {error}"))?, + ) + .map_err(|error| format!("owner-authorization schema is not valid JSON: {error}"))?; + if authorization_schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" + || authorization_schema["properties"]["schema"]["const"] + != "auths.owner-release-authorization/1" + || authorization_schema["properties"]["repository"]["const"] != RELEASE_REPOSITORY + || authorization_schema["additionalProperties"] != false + { + return Err("owner-authorization schema identity drifted".to_owned()); + } let catalogue: ReleaseSubjectCatalogue = toml::from_str( &fs::read_to_string(root().join("release/release-subjects.toml")) @@ -1403,6 +1419,19 @@ pub(crate) fn validate_release_manifest_value(manifest: &Value) -> Result<(), St .ok_or("release manifest has no source commit")?, )?; validate_digest_reference(&manifest["semanticFreeze"])?; + let builder = &manifest["builder"]; + if builder["workflow"].as_str().is_none_or(str::is_empty) + || builder["workflowDigest"].as_str().is_none_or(str::is_empty) + || builder["environment"].as_str().is_none_or(str::is_empty) + || builder["oidcIssuer"].as_str().is_none_or(str::is_empty) + || builder["oidcSubject"].as_str().is_none_or(str::is_empty) + || builder["slsaTarget"] != "SLSA 1.2 Build Level 3" + || builder["slsaAssessmentStatus"] != "passed" + { + return Err("release manifest builder assessment is incomplete".to_owned()); + } + validate_digest_reference(&builder["slsaAssessment"])?; + validate_digest_reference(&builder["slsaBuilderWorkflow"])?; let subjects = manifest["subjects"] .as_array() @@ -1457,7 +1486,8 @@ pub(crate) fn validate_release_manifest_value(manifest: &Value) -> Result<(), St validate_digest_reference(reference)?; } } - validate_digest_reference(&evidence["formalManifest"]) + validate_digest_reference(&evidence["formalManifest"])?; + validate_digest_reference(&evidence["releaseNotes"]) } fn validate_digest_reference(reference: &Value) -> Result<(), String> { @@ -1567,6 +1597,17 @@ mod tests { "commit": "b".repeat(40), }, "semanticFreeze": digest_reference("release/semantic-freeze.json"), + "builder": { + "workflow": "auths-dev/auths-proof/.github/workflows/release-builder.yml", + "workflowDigest": "b".repeat(40), + "environment": "release-candidate", + "oidcIssuer": "https://token.actions.githubusercontent.com", + "oidcSubject": "repo:auths-dev@260513770/auths-proof@1310728509:environment:release-candidate", + "slsaTarget": "SLSA 1.2 Build Level 3", + "slsaAssessmentStatus": "passed", + "slsaAssessment": digest_reference("target/release-evidence/slsa-build-level-3-assessment.json"), + "slsaBuilderWorkflow": digest_reference("target/release-evidence/release-builder.yml"), + }, "subjects": [{ "name": "target/package/auths-1.0.0-rc.1.crate", "mediaType": "application/vnd.rust.crate", @@ -1580,6 +1621,7 @@ mod tests { "formalManifest": digest_reference("formal/assurance-manifest-v1.toml"), "conformance": [digest_reference("evidence/conformance.json")], "benchmarks": [digest_reference("evidence/benchmarks.json")], + "releaseNotes": digest_reference("evidence/RELEASE_CANDIDATE_NOTES.md"), }, }) } diff --git a/xtask/src/release_control.rs b/xtask/src/release_control.rs index 5c2e016..2fe06af 100644 --- a/xtask/src/release_control.rs +++ b/xtask/src/release_control.rs @@ -2,11 +2,48 @@ use crate::*; const PREPARATION_COMPARISON_SCHEMA: &str = "auths.preparation-comparison/1"; const PROMOTION_REQUEST_SCHEMA: &str = "auths.promotion-request/1"; +const OWNER_AUTHORIZATION_SCHEMA: &str = "auths.owner-release-authorization/1"; +const OWNER_AUTHORIZATION_STATEMENT: &str = + "I authorize promotion of these exact prepared bytes to a GitHub prerelease."; const EXPECTED_OIDC_ISSUER: &str = "https://token.actions.githubusercontent.com"; const EXPECTED_OIDC_SUBJECT: &str = "repo:auths-dev@260513770/auths-proof@1310728509:environment:release-candidate"; const EXPECTED_BUILDER_WORKFLOW: &str = "auths-dev/auths-proof/.github/workflows/release-builder.yml"; +const SLSA_ASSESSMENT_PATH: &str = "release/slsa-build-level-3-assessment.json"; +const SLSA_ASSESSMENT_EVIDENCE_PATH: &str = + "target/release-evidence/slsa-build-level-3-assessment.json"; +const SLSA_ASSESSMENT_SCHEMA: &str = "auths.slsa-build-assessment/1"; +const SLSA_TARGET: &str = "SLSA 1.2 Build Level 3"; +const ASSESSED_BUILDER_PATH: &str = ".github/workflows/release-builder.yml"; +const ASSESSED_BUILDER_EVIDENCE_PATH: &str = "target/release-evidence/release-builder.yml"; +const REQUIRED_SLSA_REQUIREMENTS: [&str; 8] = [ + "build-l1-consistent-process", + "build-l1-provenance-exists", + "build-l1-provenance-distributed", + "build-l2-hosted-platform", + "build-l2-authentic-provenance", + "build-l2-consumer-verification", + "build-l3-isolated-builds", + "build-l3-signing-secrets-inaccessible", +]; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct OwnerAuthorizationRecord { + schema: String, + operation: String, + repository: String, + authorized_by: String, + authorized_by_id: String, + issued_at: String, + candidate_commit: String, + tag: String, + manifest_sha256: String, + preparation_run_id: String, + destinations: Vec, + statement: String, +} pub(crate) fn release_control(arguments: Vec) -> Result<(), String> { match arguments.as_slice() { @@ -26,11 +63,15 @@ pub(crate) fn release_control(arguments: Vec) -> Result<(), String> { [command, first, second, output] if command == "compare" => { compare_preparations(Path::new(first), Path::new(second), Path::new(output)) } - [command, staged, request] if command == "verify-promotion" => { - verify_promotion(Path::new(staged), Path::new(request)) + [command, staged, request, authorization] if command == "verify-promotion" => { + verify_promotion( + Path::new(staged), + Path::new(request), + Path::new(authorization), + ) } _ => Err( - "usage: cargo xtask release-control " + "usage: cargo xtask release-control " .to_owned(), ), } @@ -86,6 +127,22 @@ fn finalize_preparation( "attestation verification report", )?; validate_attestation_verification(&verification_path, &subjects)?; + validate_slsa_assessment(&root(), SLSA_ASSESSMENT_PATH, ASSESSED_BUILDER_PATH)?; + copy_evidence_file( + &root().join(SLSA_ASSESSMENT_PATH), + &root().join(SLSA_ASSESSMENT_EVIDENCE_PATH), + "SLSA Build Level 3 assessment", + )?; + copy_evidence_file( + &root().join(ASSESSED_BUILDER_PATH), + &root().join(ASSESSED_BUILDER_EVIDENCE_PATH), + "assessed reusable builder", + )?; + copy_evidence_file( + &root().join("release/RELEASE_CANDIDATE_NOTES.md"), + &root().join("target/release-evidence/RELEASE_CANDIDATE_NOTES.md"), + "release-candidate notes", + )?; let subject_values = input["subjects"] .as_array() @@ -109,8 +166,10 @@ fn finalize_preparation( "environment": "release-candidate", "oidcIssuer": EXPECTED_OIDC_ISSUER, "oidcSubject": EXPECTED_OIDC_SUBJECT, - "slsaTarget": "SLSA 1.2 Build Level 3", - "slsaAssessmentStatus": "pending-runtime-assessment", + "slsaTarget": SLSA_TARGET, + "slsaAssessmentStatus": "passed", + "slsaAssessment": digest_reference(SLSA_ASSESSMENT_EVIDENCE_PATH)?, + "slsaBuilderWorkflow": digest_reference(ASSESSED_BUILDER_EVIDENCE_PATH)?, }, "evidence": { "spdx": [digest_reference("target/release-evidence/sbom.spdx.json")?], @@ -124,12 +183,13 @@ fn finalize_preparation( digest_reference("demos/benchmarks/profiles/release.toml")?, digest_reference("docs/research/domains/0004-seven-domain-bounded-authorization-performance-baseline.md")?, ], + "releaseNotes": digest_reference("target/release-evidence/RELEASE_CANDIDATE_NOTES.md")?, "trustedRoot": digest_reference(path_text(&trusted_root_path)?)?, "attestationVerification": digest_reference(path_text(&verification_path)?)?, }, "limitations": [ "Preparation is not publication authorization.", - "The SLSA 1.2 Build Level 3 target remains a promotion blocker until runtime evidence is independently assessed.", + "The SLSA Build Level 3 assessment is bound to the exact reusable-builder bytes and becomes stale if they change.", "No independent security audit is claimed.", ], }); @@ -222,7 +282,11 @@ fn compare_subject_pair(name: &str, first: &Value, second: &Value) -> Result Result<(), String> { +fn verify_promotion( + staged: &Path, + request_path: &Path, + authorization_path: &Path, +) -> Result<(), String> { let manifest_path = staged.join("target/release-evidence/release-manifest.json"); let manifest = read_json(&manifest_path, "staged release manifest")?; validate_release_manifest_value(&manifest)?; @@ -241,6 +305,11 @@ fn verify_promotion(staged: &Path, request_path: &Path) -> Result<(), String> { .as_str() .ok_or("promotion request has no owner authorization digest")?; validate_sha256(authorization_digest)?; + if sha256_file(authorization_path)? != authorization_digest { + return Err("owner authorization bytes differ from the promotion request".to_owned()); + } + let authorization = read_canonical_owner_authorization(authorization_path)?; + validate_owner_authorization(&authorization, &manifest, &request)?; if request["preparationRunId"] .as_str() .is_none_or(str::is_empty) @@ -252,8 +321,80 @@ fn verify_promotion(staged: &Path, request_path: &Path) -> Result<(), String> { Ok(()) } +fn read_canonical_owner_authorization(path: &Path) -> Result { + let bytes = fs::read(path) + .map_err(|error| format!("could not read owner authorization record: {error}"))?; + let authorization: OwnerAuthorizationRecord = serde_json::from_slice(&bytes) + .map_err(|error| format!("owner authorization record is not valid JSON: {error}"))?; + let mut canonical = serde_json::to_vec(&authorization) + .map_err(|error| format!("could not encode owner authorization record: {error}"))?; + canonical.push(b'\n'); + if bytes != canonical { + return Err( + "owner authorization record is not canonical compact JSON with one trailing newline" + .to_owned(), + ); + } + Ok(authorization) +} + +fn validate_owner_authorization( + authorization: &OwnerAuthorizationRecord, + manifest: &Value, + request: &Value, +) -> Result<(), String> { + if authorization.schema != OWNER_AUTHORIZATION_SCHEMA + || authorization.operation != "promote-prepared-candidate" + || authorization.repository != RELEASE_REPOSITORY + || authorization.authorized_by != "bordumb" + || authorization.authorized_by_id != "3743841" + || !is_utc_second_timestamp(&authorization.issued_at) + || authorization.candidate_commit != manifest["source"]["commit"] + || authorization.tag != manifest["release"]["tag"] + || authorization.manifest_sha256 != request["manifestSha256"] + || authorization.preparation_run_id != request["preparationRunId"] + || authorization + .preparation_run_id + .parse::() + .ok() + .is_none_or(|run| run == 0) + || authorization.destinations != ["github-prerelease"] + || authorization.statement != OWNER_AUTHORIZATION_STATEMENT + { + return Err("owner authorization does not authorize the exact promotion".to_owned()); + } + Ok(()) +} + +fn is_utc_second_timestamp(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() != 20 + || bytes[4] != b'-' + || bytes[7] != b'-' + || bytes[10] != b'T' + || bytes[13] != b':' + || bytes[16] != b':' + || bytes[19] != b'Z' + || bytes.iter().enumerate().any(|(index, byte)| { + !matches!(index, 4 | 7 | 10 | 13 | 16 | 19) && !byte.is_ascii_digit() + }) + { + return false; + } + let component = |start: usize, end: usize| { + value[start..end] + .parse::() + .expect("validated ASCII digits") + }; + (1..=12).contains(&component(5, 7)) + && (1..=31).contains(&component(8, 10)) + && component(11, 13) < 24 + && component(14, 16) < 60 + && component(17, 19) < 60 +} + fn validate_slsa_promotion_status(manifest: &Value) -> Result<(), String> { - if manifest["builder"]["slsaTarget"] != "SLSA 1.2 Build Level 3" + if manifest["builder"]["slsaTarget"] != SLSA_TARGET || manifest["builder"]["slsaAssessmentStatus"] != "passed" { return Err( @@ -264,6 +405,91 @@ fn validate_slsa_promotion_status(manifest: &Value) -> Result<(), String> { Ok(()) } +fn validate_slsa_assessment( + base: &Path, + assessment_path: &str, + builder_path: &str, +) -> Result<(), String> { + let path = base.join(assessment_path); + let assessment = read_json(&path, "SLSA Build Level 3 assessment")?; + if assessment["schema"] != SLSA_ASSESSMENT_SCHEMA + || assessment["specification"] != "https://slsa.dev/spec/v1.2/requirements" + || assessment["target"] != SLSA_TARGET + || assessment["status"] != "passed" + || assessment["assessmentNature"] != "repository-owner-delegated-technical-assessment" + || assessment["assessedBuilder"]["platform"] != "github-actions" + || assessment["assessedBuilder"]["workflow"] != ASSESSED_BUILDER_PATH + || assessment["assessedBuilder"]["workflowEvidence"] != ASSESSED_BUILDER_EVIDENCE_PATH + || assessment["assessedBuilder"]["runner"] != "github-hosted/ubuntu-latest" + || assessment["assessedBuilder"]["provenance"] + != "github-artifact-attestations/slsa-provenance-v1" + { + return Err("SLSA Build Level 3 assessment metadata is incomplete".to_owned()); + } + let expected_workflow_digest = assessment["assessedBuilder"]["workflowSha256"] + .as_str() + .ok_or("SLSA assessment has no workflow SHA-256")?; + validate_sha256(expected_workflow_digest)?; + let actual_workflow_digest = sha256_file(&base.join(builder_path))?; + if expected_workflow_digest != actual_workflow_digest { + return Err( + "SLSA Build Level 3 assessment is stale: reusable builder bytes changed".to_owned(), + ); + } + validate_full_commit( + assessment["runtimeEvidence"]["candidateCommit"] + .as_str() + .ok_or("SLSA assessment has no assessed candidate commit")?, + )?; + validate_sha256( + assessment["runtimeEvidence"]["releaseManifestSha256"] + .as_str() + .ok_or("SLSA assessment has no release-manifest SHA-256")?, + )?; + for field in [ + "preparationRunId", + "preparationRunUrl", + "candidateTag", + "officialAttestation", + "reproductionAttestation", + ] { + if assessment["runtimeEvidence"][field] + .as_str() + .is_none_or(str::is_empty) + { + return Err(format!("SLSA assessment runtime evidence has no {field}")); + } + } + let requirements = assessment["requirements"] + .as_array() + .ok_or("SLSA assessment has no requirements")?; + let actual = requirements + .iter() + .map(|requirement| { + let id = requirement["id"] + .as_str() + .ok_or("SLSA assessment requirement has no id")?; + if requirement["status"] != "passed" { + return Err(format!("SLSA assessment requirement did not pass: {id}")); + } + Ok(id) + }) + .collect::, String>>()?; + let expected = REQUIRED_SLSA_REQUIREMENTS + .into_iter() + .collect::>(); + if actual != expected || requirements.len() != expected.len() { + return Err("SLSA assessment requirement set is incomplete".to_owned()); + } + if assessment["limitations"] + .as_array() + .is_none_or(|limitations| limitations.len() < 4) + { + return Err("SLSA assessment limitations are incomplete".to_owned()); + } + Ok(()) +} + fn validate_attestation_verification( path: &Path, expected: &BTreeMap, @@ -324,9 +550,25 @@ fn validate_manifest_files(manifest: &Value, base: &Path) -> Result<(), String> validate_digest_reference_file(base, reference)?; } } - for field in ["formalManifest", "trustedRoot", "attestationVerification"] { + for field in [ + "formalManifest", + "releaseNotes", + "trustedRoot", + "attestationVerification", + ] { validate_digest_reference_file(base, &evidence[field])?; } + validate_digest_reference_file(base, &manifest["builder"]["slsaAssessment"])?; + validate_digest_reference_file(base, &manifest["builder"]["slsaBuilderWorkflow"])?; + validate_slsa_assessment( + base, + manifest["builder"]["slsaAssessment"]["path"] + .as_str() + .ok_or("release manifest has no SLSA assessment path")?, + manifest["builder"]["slsaBuilderWorkflow"]["path"] + .as_str() + .ok_or("release manifest has no assessed builder path")?, + )?; Ok(()) } @@ -426,6 +668,36 @@ mod tests { }) } + fn owner_authorization() -> OwnerAuthorizationRecord { + OwnerAuthorizationRecord { + schema: OWNER_AUTHORIZATION_SCHEMA.to_owned(), + operation: "promote-prepared-candidate".to_owned(), + repository: RELEASE_REPOSITORY.to_owned(), + authorized_by: "bordumb".to_owned(), + authorized_by_id: "3743841".to_owned(), + issued_at: "2026-08-03T12:00:00Z".to_owned(), + candidate_commit: "b".repeat(40), + tag: "auths-v1.0.0-rc.1".to_owned(), + manifest_sha256: "a".repeat(64), + preparation_run_id: "30849197798".to_owned(), + destinations: vec!["github-prerelease".to_owned()], + statement: OWNER_AUTHORIZATION_STATEMENT.to_owned(), + } + } + + fn authorization_manifest_and_request() -> (Value, Value) { + ( + json!({ + "source": {"commit": "b".repeat(40)}, + "release": {"tag": "auths-v1.0.0-rc.1"}, + }), + json!({ + "manifestSha256": "a".repeat(64), + "preparationRunId": "30849197798", + }), + ) + } + #[test] fn changed_byte_identical_subject_is_terminal() { let first = subject("auths.crate", 'a', "byte-identical"); @@ -478,4 +750,75 @@ mod tests { .expect_err("pending runtime assessment must block promotion"); assert!(error.contains("has not passed")); } + + #[test] + fn checked_in_slsa_assessment_matches_builder_bytes() { + validate_slsa_assessment(&root(), SLSA_ASSESSMENT_PATH, ASSESSED_BUILDER_PATH) + .expect("checked-in assessment must be current"); + } + + #[test] + fn slsa_assessment_rejects_changed_builder_bytes() { + let temporary = root().join("target/release-control-slsa-staleness-test"); + fs::create_dir_all(temporary.join("release")).expect("create release test directory"); + fs::create_dir_all(temporary.join(".github/workflows")) + .expect("create workflow test directory"); + fs::copy( + root().join(SLSA_ASSESSMENT_PATH), + temporary.join(SLSA_ASSESSMENT_PATH), + ) + .expect("copy assessment"); + fs::write( + temporary.join(ASSESSED_BUILDER_PATH), + b"name: changed builder\n", + ) + .expect("write changed builder"); + let error = + validate_slsa_assessment(&temporary, SLSA_ASSESSMENT_PATH, ASSESSED_BUILDER_PATH) + .expect_err("changed builder must stale assessment"); + fs::remove_dir_all(&temporary).expect("remove test directory"); + assert!(error.contains("assessment is stale")); + } + + #[test] + fn promotion_accepts_passed_slsa_runtime_assessment() { + let manifest = json!({ + "builder": { + "slsaTarget": SLSA_TARGET, + "slsaAssessmentStatus": "passed", + } + }); + validate_slsa_promotion_status(&manifest).expect("passed assessment should promote"); + } + + #[test] + fn exact_owner_authorization_passes() { + let (manifest, request) = authorization_manifest_and_request(); + validate_owner_authorization(&owner_authorization(), &manifest, &request) + .expect("exact authorization must pass"); + } + + #[test] + fn owner_authorization_rejects_different_manifest() { + let (manifest, mut request) = authorization_manifest_and_request(); + request["manifestSha256"] = json!("c".repeat(64)); + let error = validate_owner_authorization(&owner_authorization(), &manifest, &request) + .expect_err("different manifest must fail"); + assert!(error.contains("exact promotion")); + } + + #[test] + fn owner_authorization_rejects_noncanonical_bytes() { + let temporary = root().join("target/release-control-owner-authorization-test.json"); + write_json( + &temporary, + &serde_json::to_value(owner_authorization()).expect("encode authorization"), + "test owner authorization", + ) + .expect("write pretty authorization"); + let error = read_canonical_owner_authorization(&temporary) + .expect_err("pretty authorization must fail"); + fs::remove_file(&temporary).expect("remove authorization"); + assert!(error.contains("not canonical compact JSON")); + } } diff --git a/xtask/src/semantic_freeze.rs b/xtask/src/semantic_freeze.rs index 455da34..3ae5542 100644 --- a/xtask/src/semantic_freeze.rs +++ b/xtask/src/semantic_freeze.rs @@ -4,7 +4,7 @@ use crate::*; const INVENTORY_PATH: &str = "release/semantic-freeze.json"; const INVENTORY_SCHEMA: &str = "auths.semantic-freeze/1"; -const FREEZE_VERSION: u64 = 13; +const FREEZE_VERSION: u64 = 15; const PUBLIC_RUST_ROOTS: [&str; 2] = ["auths", "auths-sdk"]; const PUBLIC_RUST_CLOSURE: [&str; 28] = [ "auths", @@ -304,12 +304,17 @@ fn generate_inventory() -> Result { "architecture.toml".to_owned(), "docs/plans/PHASE_7_RELEASE_OWNER_DECISIONS.md".to_owned(), "release/public-naming.toml".to_owned(), + "release/README.md".to_owned(), "release/RELEASE_CONTROL.md".to_owned(), + "release/RELEASE_RUNBOOK.md".to_owned(), "release/CANDIDATE_CLOSURE.md".to_owned(), + "release/SLSA_BUILD_LEVEL_3_ASSESSMENT.md".to_owned(), + "release/slsa-build-level-3-assessment.json".to_owned(), "release/RELEASE_CANDIDATE_NOTES.md".to_owned(), "release/release-manifest.contract-fixture.json".to_owned(), "release/release-manifest.schema.json".to_owned(), "release/release-subjects.toml".to_owned(), + "release/owner-authorization.schema.json".to_owned(), "xtask/src/architecture.rs".to_owned(), "xtask/src/checks.rs".to_owned(), "xtask/src/fixtures.rs".to_owned(), @@ -321,7 +326,7 @@ fn generate_inventory() -> Result { ]); entries.push(freeze_entry( "auths.release.public-surface", - 13, + 15, FreezeClassification::ReleaseMetadata, &[ "package-names",