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
16 changes: 12 additions & 4 deletions .github/workflows/node-github-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions sdk/typescript/scripts/release-automation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -848,6 +899,9 @@ function main() {
"release-history <tag>, " +
"verify-publication <archive> <version> <git-head> " +
"(package metadata JSON from stdin), " +
"verify-github-publication <archive> <version> <git-head> " +
"<repository> <run-id> (package metadata JSON from stdin and " +
"verified provenance from CODEX_SECURITY_VERIFIED_PROVENANCE), " +
"verify-provenance <archive> <version> <git-head> <repository> <run-id> " +
"(signature audit JSON from stdin), " +
"verify-recovered-provenance <archive> <version> <git-head> " +
Expand Down
132 changes: 131 additions & 1 deletion sdk/typescript/tests-ts/release-automation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -108,6 +130,7 @@ const {
requirePublishedReleaseIncrease,
releaseHistory,
verifyPublishedRelease,
verifyGitHubPublishedRelease,
verifySignatureAudit,
verifyRecoveredSignatureAudit,
verifyGitHubRelease,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)",
Expand Down
Loading