返回 DeepSeek-Reasonix
signpath-checkpoint.mjs
根目录 / scripts / signpath-checkpoint.mjs
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
171 lines Plain Text