diff --git a/.github/workflows/node-github-release.yml b/.github/workflows/node-github-release.yml index 5dcf856d..df48d264 100644 --- a/.github/workflows/node-github-release.yml +++ b/.github/workflows/node-github-release.yml @@ -225,10 +225,6 @@ jobs: --registry=https://registry.npmjs.org/ )" - printf '%s\n' "$metadata" | - node sdk/typescript/scripts/release-automation.mjs \ - verify-publication "$archive" "$RELEASE_VERSION" "$RELEASE_SHA" - consumer="$(mktemp -d)" trap 'rm -rf "$consumer"' EXIT npm install \ @@ -257,6 +253,7 @@ jobs: "$GITHUB_REPOSITORY" \ "$RELEASE_RUN_ID" 2>/dev/null )"; then + verified_run_id="$RELEASE_RUN_ID" printf '%s\n' "$verified_provenance" echo "Verified npm provenance for the resolved release run." else @@ -305,10 +302,21 @@ jobs: exit 1 fi + verified_run_id="$original_run_id" printf '%s\n' "$verified_provenance" echo "Verified protected recovery provenance from its original signing run." fi + printf '%s\n' "$metadata" | + CODEX_SECURITY_VERIFIED_PROVENANCE="$verified_provenance" \ + node sdk/typescript/scripts/release-automation.mjs \ + verify-github-publication \ + "$archive" \ + "$RELEASE_VERSION" \ + "$RELEASE_SHA" \ + "$GITHUB_REPOSITORY" \ + "$verified_run_id" + printf 'archive=%s\n' "$archive" >> "$GITHUB_OUTPUT" - name: Resolve published release history diff --git a/sdk/typescript/scripts/release-automation.mjs b/sdk/typescript/scripts/release-automation.mjs index 8ce72b16..02244376 100644 --- a/sdk/typescript/scripts/release-automation.mjs +++ b/sdk/typescript/scripts/release-automation.mjs @@ -437,6 +437,36 @@ export function verifyPublishedRelease(metadata, archive, expected) { }; } +export function verifyGitHubPublishedRelease( + metadata, + archive, + expected, + provenance, +) { + const version = releaseVersion(metadata); + const sha512 = createHash("sha512").update(archive).digest("hex"); + if ( + provenance?.version !== version || + provenance.gitHead !== expected.gitHead || + provenance.repository !== expected.repository || + provenance.runId !== String(expected.runId) || + provenance.sha512 !== sha512 + ) { + throw new Error( + "Verified signed npm provenance must match the GitHub release.", + ); + } + + if (metadata.gitHead === undefined) { + if (version !== "0.1.0" && version !== "0.1.1") { + throw new Error("Only npm releases 0.1.0 and 0.1.1 may omit gitHead."); + } + metadata = { ...metadata, gitHead: provenance.gitHead }; + } + + return verifyPublishedRelease(metadata, archive, expected); +} + export function verifySignatureAudit(report, archive, expected) { if ( !Array.isArray(report?.invalid) || @@ -793,6 +823,27 @@ function main() { return; } + if (command === "verify-github-publication" && process.argv.length === 8) { + const metadata = JSON.parse(readFileSync(0, "utf8")); + const archive = readFileSync(process.argv[3]); + const provenance = JSON.parse( + process.env.CODEX_SECURITY_VERIFIED_PROVENANCE ?? "null", + ); + const verified = verifyGitHubPublishedRelease( + metadata, + archive, + { + version: process.argv[4], + gitHead: process.argv[5], + repository: process.argv[6], + runId: process.argv[7], + }, + provenance, + ); + console.log(JSON.stringify(verified)); + return; + } + if (command === "verify-provenance" && process.argv.length === 8) { const report = JSON.parse(readFileSync(0, "utf8")); const archive = readFileSync(process.argv[3]); @@ -848,6 +899,9 @@ function main() { "release-history , " + "verify-publication " + "(package metadata JSON from stdin), " + + "verify-github-publication " + + " (package metadata JSON from stdin and " + + "verified provenance from CODEX_SECURITY_VERIFIED_PROVENANCE), " + "verify-provenance " + "(signature audit JSON from stdin), " + "verify-recovered-provenance " + diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index 1aa93d88..92619145 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -54,6 +54,28 @@ type ReleaseAutomation = { integrity: string; sha256: string; }; + verifyGitHubPublishedRelease: ( + metadata: ReleaseMetadata, + archive: Uint8Array, + expected: { + version: string; + gitHead: string; + repository: string; + runId: string; + }, + provenance: { + version: string; + gitHead: string; + repository: string; + runId: string; + sha512: string; + }, + ) => { + version: string; + gitHead: string; + integrity: string; + sha256: string; + }; verifySignatureAudit: ( report: ReleaseMetadata, archive: Uint8Array, @@ -108,6 +130,7 @@ const { requirePublishedReleaseIncrease, releaseHistory, verifyPublishedRelease, + verifyGitHubPublishedRelease, verifySignatureAudit, verifyRecoveredSignatureAudit, verifyGitHubRelease, @@ -744,6 +767,111 @@ describe("published npm release verification", () => { }); }); +describe("GitHub release publication verification", () => { + const expected = { + version: "0.1.2", + gitHead: releaseCommit, + repository: releaseRepository, + runId: releaseRun, + }; + const provenance = { + ...expected, + sha512, + }; + + test("retains strict source and artifact verification for current releases", () => { + expect( + verifyGitHubPublishedRelease( + publishedMetadata(), + archive, + expected, + provenance, + ), + ).toEqual({ + version: "0.1.2", + gitHead: releaseCommit, + integrity, + sha256: digest.slice("sha256:".length), + }); + }); + + test.each(["0.1.0", "0.1.1"])( + "recovers the missing gitHead only from verified provenance for %s", + (version) => { + const metadata: ReleaseMetadata = { ...publishedMetadata(), version }; + delete metadata["gitHead"]; + + expect( + verifyGitHubPublishedRelease( + metadata, + archive, + { ...expected, version }, + { ...provenance, version }, + ), + ).toEqual({ + version, + gitHead: releaseCommit, + integrity, + sha256: digest.slice("sha256:".length), + }); + + expect(() => + verifyPublishedRelease(metadata, archive, { ...expected, version }), + ).toThrow("npm package gitHead must match release commit"); + }, + ); + + test("rejects a missing gitHead on all later npm releases", () => { + const metadata: ReleaseMetadata = { ...publishedMetadata() }; + delete metadata["gitHead"]; + + expect(() => + verifyGitHubPublishedRelease(metadata, archive, expected, provenance), + ).toThrow("Only npm releases 0.1.0 and 0.1.1 may omit gitHead."); + }); + + test("rejects a mismatched historical npm gitHead", () => { + expect(() => + verifyGitHubPublishedRelease( + { + ...publishedMetadata(), + version: "0.1.0", + gitHead: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + archive, + { ...expected, version: "0.1.0" }, + { ...provenance, version: "0.1.0" }, + ), + ).toThrow("npm package gitHead must match release commit"); + }); + + test.each([ + { version: "0.1.1" }, + { gitHead: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + { repository: "different/codex-security" }, + { runId: "30481596228" }, + { sha512: "0".repeat(128) }, + ])("rejects signed provenance that does not match %j", (mismatch) => { + expect(() => + verifyGitHubPublishedRelease(publishedMetadata(), archive, expected, { + ...provenance, + ...mismatch, + }), + ).toThrow("Verified signed npm provenance must match the GitHub release."); + }); + + test("rejects missing verified provenance", () => { + expect(() => + verifyGitHubPublishedRelease( + publishedMetadata(), + archive, + expected, + undefined as unknown as typeof provenance, + ), + ).toThrow("Verified signed npm provenance must match the GitHub release."); + }); +}); + describe("cryptographically verified npm provenance", () => { test("binds the verified bundle to the exact archive, source, and run", () => { expect( @@ -2129,7 +2257,9 @@ describe("GitHub release workflow safeguards", () => { ' if [[ "${1:-}" == "sdk/typescript/scripts/release-automation.mjs" ]]; then', " cat >/dev/null", ' case "$2" in', - " verify-publication)", + " verify-github-publication)", + ' if [[ -z "${CODEX_SECURITY_VERIFIED_PROVENANCE:-}" ||', + ' "$6" != "$GITHUB_REPOSITORY" ]]; then return 69; fi', " printf '%s\\n' 'verified published artifact'", " ;;", " verify-provenance)",