| 1 | // Same-run recovery receipts contain no credentials or signed payloads. A receipt |
| 2 | // is usable only with exactly the same input bytes and protected release identity. |
| 3 | import { createHash } from "node:crypto"; |
| 4 | import { createReadStream } from "node:fs"; |
| 5 | import { appendFile, lstat, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; |
| 6 | import path from "node:path"; |
| 7 | import { pathToFileURL } from "node:url"; |
| 8 | |
| 9 | const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i; |
| 10 | |
| 11 | export async function inputDigest(directory) { |
| 12 | const files = []; |
| 13 | async function walk(relative = "") { |
| 14 | const current = path.join(directory, relative); |
| 15 | const stat = await lstat(current); |
| 16 | if (stat.isSymbolicLink()) throw new Error("signing input contains a symbolic link"); |
| 17 | if (stat.isDirectory()) { |
| 18 | for (const name of (await readdir(current)).sort()) await walk(relative ? `${relative}/${name}` : name); |
| 19 | } else if (stat.isFile()) { |
| 20 | const hash = createHash("sha256"); |
| 21 | for await (const chunk of createReadStream(current)) hash.update(chunk); |
| 22 | files.push([relative, stat.size, hash.digest("hex")]); |
| 23 | } else throw new Error("signing input contains a non-regular file"); |
| 24 | } |
| 25 | await walk(); |
| 26 | if (!files.length) throw new Error("empty signing input"); |
| 27 | return createHash("sha256").update(JSON.stringify(files)).digest("hex"); |
| 28 | } |
| 29 | |
| 30 | export function identity(env) { |
| 31 | const fields = ["GITHUB_REPOSITORY", "GITHUB_RUN_ID", "SIGNPATH_SOURCE_SHA", "SIGNPATH_CONTROL_SHA", |
| 32 | "SIGNPATH_FINGERPRINT", "SIGNPATH_VERSION", "SIGNPATH_CHANNEL", "SIGNPATH_MODE", "SIGNPATH_PLATFORM", |
| 33 | "SIGNPATH_ORGANIZATION_ID", "SIGNPATH_PROJECT", "SIGNPATH_POLICY", "SIGNPATH_CONFIGURATION"]; |
| 34 | const result = Object.fromEntries(fields.map(key => { |
| 35 | if (!env[key] || /[\r\n]/.test(env[key])) throw new Error(`missing or invalid ${key}`); |
| 36 | return [key, env[key]]; |
| 37 | })); |
| 38 | if (!/^[1-9][0-9]*$/.test(result.GITHUB_RUN_ID) |
| 39 | || !/^[\w.-]+\/[\w.-]+$/.test(result.GITHUB_REPOSITORY) |
| 40 | || !uuid.test(result.SIGNPATH_ORGANIZATION_ID) |
| 41 | || ![result.SIGNPATH_SOURCE_SHA, result.SIGNPATH_CONTROL_SHA].every(value => /^[a-f0-9]{40}$/.test(value))) { |
| 42 | throw new Error("invalid signing checkpoint identity"); |
| 43 | } |
| 44 | return result; |
| 45 | } |
| 46 | |
| 47 | export function receiptName(expected) { |
| 48 | // Deliberately exclude run_attempt and input digest. Changed bytes on a retry |
| 49 | // must fail closed instead of silently making another quota-consuming request. |
| 50 | return `signpath-request-${expected.GITHUB_RUN_ID}-${identityDigest(expected).slice(0, 24)}`; |
| 51 | } |
| 52 | |
| 53 | export const identityDigest = expected => createHash("sha256").update(JSON.stringify(expected)).digest("hex"); |
| 54 | |
| 55 | export function validateReceipt(receipt, expected, digest, attempt) { |
| 56 | if (receipt.schema !== 1 || !uuid.test(receipt.requestId ?? "") |
| 57 | || !/^[1-9][0-9]*$/.test(receipt.attempt ?? "") |
| 58 | || !/^[1-9][0-9]*$/.test(attempt ?? "") || BigInt(receipt.attempt) > BigInt(attempt)) { |
| 59 | throw new Error("invalid signing recovery receipt"); |
| 60 | } |
| 61 | if (receipt.identityDigest !== identityDigest(expected) || receipt.digest !== digest) { |
| 62 | throw new Error("signing recovery identity or input bytes changed; refusing to reuse or resubmit"); |
| 63 | } |
| 64 | return receipt.requestId; |
| 65 | } |
| 66 | |
| 67 | async function githubGet(expected, route, token, fetcher) { |
| 68 | if (!token) throw new Error("GH_TOKEN is required for signing recovery"); |
| 69 | const url = `https://api.github.com/repos/${expected.GITHUB_REPOSITORY}/actions/runs/${expected.GITHUB_RUN_ID}/${route}`; |
| 70 | const response = await fetcher(url, { |
| 71 | headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" }, |
| 72 | signal: AbortSignal.timeout(30000), |
| 73 | }); |
| 74 | if (!response.ok) throw new Error(`cannot read signing recovery evidence (HTTP ${response.status}); refusing to resubmit`); |
| 75 | return response.json(); |
| 76 | } |
| 77 | |
| 78 | export async function findReceipt(expected, token, fetcher = fetch) { |
| 79 | const name = receiptName(expected); |
| 80 | const data = await githubGet(expected, `artifacts?name=${name}&per_page=100`, token, fetcher); |
| 81 | if (!Array.isArray(data.artifacts) || !Number.isSafeInteger(data.total_count) |
| 82 | || data.total_count !== data.artifacts.length) throw new Error("incomplete signing receipt lookup"); |
| 83 | const matches = data.artifacts.filter(artifact => artifact.name === name); |
| 84 | if (matches.length > 1 || matches.some(artifact => artifact.expired)) throw new Error("ambiguous or expired signing receipt"); |
| 85 | return matches.length === 1; |
| 86 | } |
| 87 | |
| 88 | export function requireRecoverable(exists, attempt, neverSubmitted = false) { |
| 89 | if (!/^[1-9][0-9]*$/.test(attempt ?? "") || Number(attempt) > 100) throw new Error("invalid or excessive workflow attempt"); |
| 90 | if (!exists && attempt !== "1" && !neverSubmitted) { |
| 91 | throw new Error("no signing receipt on a rerun; check SignPath request history before starting a new run"); |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | // A missing receipt alone says nothing about whether submission happened. Read |
| 96 | // every prior attempt, including ones omitted by a failed-jobs-only retry. Only |
| 97 | // a terminal skipped step (or no job in a complete listing) proves non-execution. |
| 98 | export async function wasNeverSubmitted(expected, attempt, token, fetcher = fetch) { |
| 99 | requireRecoverable(true, attempt); |
| 100 | const stepName = { |
| 101 | "windows-payload": "Submit Windows payload for Authenticode signing", |
| 102 | "windows-installer-v2": "Submit installer for Authenticode signing", |
| 103 | }[expected.SIGNPATH_CONFIGURATION]; |
| 104 | if (!stepName || !["preflight", "release"].includes(expected.SIGNPATH_MODE) |
| 105 | || !["windows-amd64", "windows-arm64"].includes(expected.SIGNPATH_PLATFORM)) throw new Error("unsupported signing stage"); |
| 106 | const jobName = `build (${expected.SIGNPATH_PLATFORM}, ${expected.SIGNPATH_MODE})`; |
| 107 | for (let previous = 1; previous < Number(attempt); previous++) { |
| 108 | const jobs = []; |
| 109 | let total; |
| 110 | for (let page = 1; ; page++) { |
| 111 | if (page > 100) throw new Error("excessive signing job history"); |
| 112 | const data = await githubGet(expected, `attempts/${previous}/jobs?per_page=100&page=${page}`, token, fetcher); |
| 113 | if (!Array.isArray(data.jobs) || !Number.isSafeInteger(data.total_count) || data.total_count < 0 |
| 114 | || (total !== undefined && total !== data.total_count)) throw new Error("invalid signing job history"); |
| 115 | total = data.total_count; |
| 116 | jobs.push(...data.jobs); |
| 117 | if (jobs.length === total) break; |
| 118 | if (!data.jobs.length || jobs.length > total) throw new Error("incomplete signing job history"); |
| 119 | } |
| 120 | if (jobs.some(job => typeof job.name !== "string")) throw new Error("invalid signing job name"); |
| 121 | const matches = jobs.filter(job => job.name === jobName || job.name.endsWith(` / ${jobName}`)); |
| 122 | if (matches.length > 1) throw new Error("ambiguous signing job history"); |
| 123 | for (const job of matches) { |
| 124 | if (job.status !== "completed") throw new Error("signing job history is not terminal"); |
| 125 | if (!Array.isArray(job.steps)) throw new Error("missing signing step history"); |
| 126 | const submissions = job.steps.filter(step => step.name === stepName); |
| 127 | if (submissions.length === 0 && job.conclusion === "skipped" && job.steps.length === 0) continue; |
| 128 | if (submissions.length !== 1) throw new Error("missing or ambiguous signing submission step"); |
| 129 | if (submissions[0].status !== "completed" || submissions[0].conclusion !== "skipped") return false; |
| 130 | } |
| 131 | } |
| 132 | return true; |
| 133 | } |
| 134 | |
| 135 | export async function canReuseRequest(expected, attempt, token, fetcher = fetch) { |
| 136 | requireRecoverable(true, attempt); |
| 137 | const exists = await findReceipt(expected, token, fetcher); |
| 138 | const neverSubmitted = !exists && attempt !== "1" && await wasNeverSubmitted(expected, attempt, token, fetcher); |
| 139 | requireRecoverable(exists, attempt, neverSubmitted); |
| 140 | return exists; |
| 141 | } |
| 142 | |
| 143 | async function main() { |
| 144 | const [command, input, checkpoint, configuration] = process.argv.slice(2); |
| 145 | if (!["prepare", "restore", "record"].includes(command) || !input || !checkpoint || !configuration) { |
| 146 | throw new Error("usage: signpath-checkpoint.mjs prepare|restore|record INPUT CHECKPOINT CONFIGURATION"); |
| 147 | } |
| 148 | const expected = identity({ ...process.env, SIGNPATH_CONFIGURATION: configuration }); |
| 149 | const output = async (key, value) => appendFile(process.env.GITHUB_OUTPUT, `${key}=${value}\n`); |
| 150 | if (command === "prepare") { |
| 151 | const exists = await canReuseRequest(expected, process.env.GITHUB_RUN_ATTEMPT, process.env.GH_TOKEN); |
| 152 | await output("name", receiptName(expected)); |
| 153 | await output("exists", String(exists)); |
| 154 | return; |
| 155 | } |
| 156 | const digest = await inputDigest(input); |
| 157 | const receiptPath = path.join(checkpoint, "request.json"); |
| 158 | if (command === "restore") { |
| 159 | const receipt = JSON.parse(await readFile(receiptPath, "utf8")); |
| 160 | await output("request_id", validateReceipt(receipt, expected, digest, process.env.GITHUB_RUN_ATTEMPT)); |
| 161 | } else { |
| 162 | // Bind configuration without publishing the organization's private ID. |
| 163 | const receipt = { schema: 1, identityDigest: identityDigest(expected), digest, attempt: process.env.GITHUB_RUN_ATTEMPT, requestId: process.env.SIGNPATH_REQUEST_ID }; |
| 164 | validateReceipt(receipt, expected, digest, process.env.GITHUB_RUN_ATTEMPT); |
| 165 | await mkdir(checkpoint, { recursive: true }); |
| 166 | await writeFile(receiptPath, JSON.stringify(receipt, null, 2), { flag: "wx" }); |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) await main(); |
| 171 |