| 1 | import assert from "node:assert/strict"; |
| 2 | import { mkdtemp, mkdir, readFile, rename, rm, symlink, writeFile } from "node:fs/promises"; |
| 3 | import os from "node:os"; |
| 4 | import path from "node:path"; |
| 5 | import test from "node:test"; |
| 6 | import { spawnSync } from "node:child_process"; |
| 7 | import { canReuseRequest, findReceipt, identity, identityDigest, inputDigest, receiptName, requireRecoverable, validateReceipt } from "./signpath-checkpoint.mjs"; |
| 8 | |
| 9 | const env = { |
| 10 | GITHUB_REPOSITORY: "example/project", GITHUB_RUN_ID: "123", GITHUB_RUN_ATTEMPT: "1", |
| 11 | SIGNPATH_SOURCE_SHA: "a".repeat(40), SIGNPATH_CONTROL_SHA: "b".repeat(40), |
| 12 | SIGNPATH_FINGERPRINT: `v1:${"c".repeat(64)}`, SIGNPATH_VERSION: "1.2.3", SIGNPATH_CHANNEL: "stable", |
| 13 | SIGNPATH_MODE: "preflight", SIGNPATH_PLATFORM: "windows-amd64", |
| 14 | SIGNPATH_ORGANIZATION_ID: "11111111-1111-1111-1111-111111111111", |
| 15 | SIGNPATH_PROJECT: "project", SIGNPATH_POLICY: "release-signing", SIGNPATH_CONFIGURATION: "windows-payload", |
| 16 | }; |
| 17 | const expected = identity(env); |
| 18 | const requestId = "22222222-2222-2222-2222-222222222222"; |
| 19 | const receipt = { schema: 1, identityDigest: identityDigest(expected), digest: "digest", attempt: "1", requestId }; |
| 20 | |
| 21 | test("an interrupted submission without a saved request cannot be blindly repeated", () => { |
| 22 | assert.doesNotThrow(() => requireRecoverable(false, "1")); |
| 23 | assert.doesNotThrow(() => requireRecoverable(true, "2")); |
| 24 | assert.throws(() => requireRecoverable(false, "2"), /check SignPath request history/); |
| 25 | assert.throws(() => requireRecoverable(true, ""), /invalid or excessive workflow attempt/); |
| 26 | }); |
| 27 | |
| 28 | const payloadStep = "Submit Windows payload for Authenticode signing"; |
| 29 | const installerStep = "Submit installer for Authenticode signing"; |
| 30 | const completedStep = (name, conclusion) => ({ name, status: "completed", conclusion }); |
| 31 | const previousJob = steps => ({ name: "verify stable SignPath control plane / build (windows-amd64, preflight)", |
| 32 | status: "completed", conclusion: "failure", steps }); |
| 33 | function historyAPI(receipts, attempts, calls = []) { |
| 34 | return async url => { |
| 35 | calls.push(url); |
| 36 | const parsed = new URL(url); |
| 37 | if (parsed.pathname.endsWith("/artifacts")) { |
| 38 | const artifacts = receipts.filter(item => item.name === parsed.searchParams.get("name")); |
| 39 | return { ok: true, json: async () => ({ artifacts, total_count: artifacts.length }) }; |
| 40 | } |
| 41 | const attempt = /\/attempts\/(\d+)\/jobs$/.exec(parsed.pathname)?.[1]; |
| 42 | assert.ok(attempt, url); |
| 43 | const jobs = attempts[attempt]; |
| 44 | if (!jobs) return { ok: false, status: 404 }; |
| 45 | const page = Number(parsed.searchParams.get("page")); |
| 46 | return { ok: true, json: async () => ({ jobs: jobs.slice((page - 1) * 100, page * 100), total_count: jobs.length }) }; |
| 47 | }; |
| 48 | } |
| 49 | |
| 50 | test("retry restores the signed payload and first-submits the installer skipped after a download failure", async () => { |
| 51 | const installer = identity({ ...env, SIGNPATH_CONFIGURATION: "windows-installer-v2" }); |
| 52 | const calls = []; |
| 53 | const api = historyAPI([{ name: receiptName(expected), expired: false }], { |
| 54 | 1: [previousJob([completedStep(payloadStep, "success"), completedStep(installerStep, "skipped")])], |
| 55 | }, calls); |
| 56 | assert.equal(await canReuseRequest(expected, "2", "test-only", api), true); |
| 57 | assert.equal(calls.length, 1, "known receipt does not need step history"); |
| 58 | assert.equal(await canReuseRequest(installer, "2", "test-only", api), false, "allow the first installer submission"); |
| 59 | assert.ok(calls.some(url => url.includes("/attempts/1/jobs"))); |
| 60 | }); |
| 61 | |
| 62 | test("a pre-signing build or input-upload failure permits the previously skipped submission", async () => { |
| 63 | for (const steps of [ |
| 64 | [completedStep("Build desktop", "failure"), completedStep(payloadStep, "skipped")], |
| 65 | [completedStep("Upload unsigned Windows payload for SignPath", "success"), completedStep(payloadStep, "skipped")], |
| 66 | ]) assert.equal(await canReuseRequest(expected, "2", "test-only", historyAPI([], { 1: [previousJob(steps)] })), false); |
| 67 | }); |
| 68 | |
| 69 | test("a missing receipt after any attempted submission remains blocked, even if later attempts skipped it", async () => { |
| 70 | for (const conclusion of ["success", "failure", "cancelled", null]) { |
| 71 | const api = historyAPI([], { |
| 72 | 1: [previousJob([completedStep(payloadStep, conclusion)])], |
| 73 | 2: [previousJob([completedStep(payloadStep, "skipped")])], |
| 74 | }); |
| 75 | await assert.rejects(canReuseRequest(expected, "3", "test-only", api), /check SignPath request history/); |
| 76 | } |
| 77 | }); |
| 78 | |
| 79 | test("all attempt pages are read and unrelated platform/mode jobs cannot authorize resubmission", async () => { |
| 80 | const unrelated = Array.from({ length: 100 }, (_, index) => ({ name: `unrelated-${index}` })); |
| 81 | const target = previousJob([completedStep(payloadStep, "success")]); |
| 82 | const api = historyAPI([], { 1: [...unrelated, target] }); |
| 83 | await assert.rejects(canReuseRequest(expected, "2", "test-only", api), /check SignPath request history/); |
| 84 | const skipped = previousJob([completedStep(payloadStep, "skipped")]); |
| 85 | const jobs = [skipped, { ...target, name: "build (windows-arm64, preflight)" }, { ...target, name: "build (windows-amd64, release)" }]; |
| 86 | assert.equal(await canReuseRequest(expected, "3", "test-only", historyAPI([], { 1: jobs, 2: [] })), false); |
| 87 | }); |
| 88 | |
| 89 | test("unavailable, nonterminal or ambiguous step evidence fails closed", async () => { |
| 90 | const skipped = previousJob([completedStep(payloadStep, "skipped")]); |
| 91 | for (const jobs of [ |
| 92 | [{ ...skipped, status: "in_progress" }], [skipped, skipped], |
| 93 | [{ ...skipped, steps: [] }], [{ ...skipped, steps: undefined }], |
| 94 | [{ ...skipped, steps: [completedStep(payloadStep, "skipped"), completedStep(payloadStep, "success")] }], |
| 95 | ]) await assert.rejects(canReuseRequest(expected, "2", "test-only", historyAPI([], { 1: jobs }))); |
| 96 | await assert.rejects(canReuseRequest(expected, "2", "test-only", historyAPI([], {})), /HTTP 404/); |
| 97 | }); |
| 98 | |
| 99 | test("receipt resumes one request across attempts but rejects changed release identity or bytes", () => { |
| 100 | assert.equal(validateReceipt(receipt, expected, "digest", "2"), requestId); |
| 101 | assert.equal(receiptName(expected), receiptName(identity({ ...env, GITHUB_RUN_ATTEMPT: "2" }))); |
| 102 | for (const key of Object.keys(expected)) { |
| 103 | assert.throws(() => validateReceipt(receipt, { ...expected, [key]: `${expected[key]}changed` }, "digest", "2"), /identity/); |
| 104 | } |
| 105 | assert.throws(() => validateReceipt(receipt, expected, "changed", "2"), /bytes changed/); |
| 106 | for (const change of [{ schema: 2 }, { attempt: "3" }, { attempt: "" }, { requestId: "bad\nrequest_id=unsafe" }]) { |
| 107 | assert.throws(() => validateReceipt({ ...receipt, ...change }, expected, "digest", "2"), /invalid/); |
| 108 | } |
| 109 | }); |
| 110 | |
| 111 | test("input digest covers nested filenames and bytes, ignores mtime, rejects symlinks", async t => { |
| 112 | const root = await mkdtemp(path.join(os.tmpdir(), "signpath-input-")); |
| 113 | t.after(() => rm(root, { recursive: true, force: true })); |
| 114 | await assert.rejects(inputDigest(root), /empty/); |
| 115 | await mkdir(path.join(root, "app")); |
| 116 | const file = path.join(root, "app", "app.exe"); |
| 117 | await writeFile(file, "original"); |
| 118 | const digest = await inputDigest(root); |
| 119 | await writeFile(file, "original"); |
| 120 | assert.equal(await inputDigest(root), digest); |
| 121 | await writeFile(file, "modified"); |
| 122 | assert.notEqual(await inputDigest(root), digest); |
| 123 | await writeFile(file, "original"); |
| 124 | await rename(file, path.join(root, "app", "renamed.exe")); |
| 125 | assert.notEqual(await inputDigest(root), digest); |
| 126 | await symlink("renamed.exe", file); |
| 127 | await assert.rejects(inputDigest(root), /symbolic link/); |
| 128 | }); |
| 129 | |
| 130 | test("receipt lookup is same-run and fails closed on API failures, expiry and ambiguity", async () => { |
| 131 | const item = { name: receiptName(expected), expired: false }; |
| 132 | const fetcher = artifacts => async (url, options) => { |
| 133 | assert.match(url, /repos\/example\/project\/actions\/runs\/123\/artifacts\?name=signpath-request-123-/); |
| 134 | assert.equal(options.headers.Authorization, "Bearer test-only"); |
| 135 | return { ok: true, json: async () => ({ artifacts, total_count: artifacts.length }) }; |
| 136 | }; |
| 137 | assert.equal(await findReceipt(expected, "test-only", fetcher([])), false); |
| 138 | assert.equal(await findReceipt(expected, "test-only", fetcher([item])), true); |
| 139 | for (const artifacts of [[item, item], [{ ...item, expired: true }]]) { |
| 140 | await assert.rejects(findReceipt(expected, "test-only", fetcher(artifacts)), /ambiguous or expired/); |
| 141 | } |
| 142 | await assert.rejects(findReceipt(expected, "test-only", async () => ({ ok: false, status: 403 })), /refusing to resubmit/); |
| 143 | await assert.rejects(findReceipt(expected, "test-only", async () => ({ ok: true, json: async () => ({ total_count: 101, artifacts: [] }) })), /incomplete/); |
| 144 | await assert.rejects(findReceipt(expected, ""), /GH_TOKEN/); |
| 145 | }); |
| 146 | |
| 147 | test("CLI records and restores the original request without a signing API call", async t => { |
| 148 | const root = await mkdtemp(path.join(os.tmpdir(), "signpath-receipt-")); |
| 149 | t.after(() => rm(root, { recursive: true, force: true })); |
| 150 | const input = path.join(root, "input"); |
| 151 | const checkpoint = path.join(root, "receipt"); |
| 152 | const output = path.join(root, "output"); |
| 153 | await mkdir(input); |
| 154 | await writeFile(path.join(input, "app.exe"), "test artifact"); |
| 155 | const run = (command, extra = {}) => spawnSync(process.execPath, |
| 156 | [new URL("./signpath-checkpoint.mjs", import.meta.url).pathname, command, input, checkpoint, env.SIGNPATH_CONFIGURATION], |
| 157 | { env: { ...process.env, ...env, GITHUB_OUTPUT: output, SIGNPATH_REQUEST_ID: requestId, ...extra }, encoding: "utf8" }); |
| 158 | assert.equal(run("record").status, 0); |
| 159 | assert.ok(!(await readFile(path.join(checkpoint, "request.json"), "utf8")).includes(env.SIGNPATH_ORGANIZATION_ID)); |
| 160 | assert.equal(run("restore", { GITHUB_RUN_ATTEMPT: "2" }).status, 0); |
| 161 | assert.equal(await readFile(output, "utf8"), `request_id=${requestId}\n`); |
| 162 | await writeFile(path.join(input, "app.exe"), "different artifact"); |
| 163 | const failed = run("restore", { GITHUB_RUN_ATTEMPT: "2" }); |
| 164 | assert.notEqual(failed.status, 0); |
| 165 | assert.match(failed.stderr, /input bytes changed/); |
| 166 | assert.equal(await readFile(output, "utf8"), `request_id=${requestId}\n`); |
| 167 | assert.notEqual(run("record").status, 0, "cannot overwrite an existing receipt"); |
| 168 | }); |
| 169 | |
| 170 | test("release no longer consumes SignPath quota while legacy receipts remain readable", async () => { |
| 171 | const workflow = await readFile(new URL("../.github/workflows/release-desktop.yml", import.meta.url), "utf8"); |
| 172 | assert.ok(!workflow.includes("signpath/github-action-submit-signing-request")); |
| 173 | assert.ok(!workflow.includes("secrets.SIGNPATH_API_TOKEN")); |
| 174 | assert.ok(!workflow.includes("node release-control/scripts/signpath-checkpoint.mjs")); |
| 175 | assert.match(workflow, /uses: \.\/release-control\/\.github\/actions\/setup-certum/); |
| 176 | }); |
| 177 |