@kungfu-tech/buildchain 3.0.2-alpha.7 → 3.0.2-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +3 -2
  2. package/contracts/auditable-demo-media-profiles-v1.json +168 -0
  3. package/contracts/evidence/auditable-demo-web-delivery-v1.json +103 -0
  4. package/contracts/fixtures/auditable-demo-web-delivery-v1/complete-transcript.txt +2 -0
  5. package/contracts/fixtures/auditable-demo-web-delivery-v1/public-projection.json +16 -0
  6. package/contracts/fixtures/auditable-demo-web-delivery-v1/scene.json +12 -0
  7. package/dist/site/buildchain-contract.json +44 -26
  8. package/dist/site/buildchain-site.json +42 -32
  9. package/dist/site/capability-registry.json +3 -3
  10. package/dist/site/controller-registry.json +11 -3
  11. package/dist/site/kfd-claims.json +74 -8
  12. package/dist/site/kfd-upstream-aggregate.json +1 -1
  13. package/dist/site/manual-registry.json +7 -7
  14. package/dist/site/node-api-registry.json +44 -5
  15. package/dist/site/page-registry.json +30 -20
  16. package/dist/site/public-surface-audit.json +38 -10
  17. package/dist/site/publication-authority-registry.json +26 -1
  18. package/dist/site/publication-registry.json +4 -4
  19. package/dist/site/release-provenance.json +3 -0
  20. package/dist/site/site-manifest.json +11 -11
  21. package/dist/site/workflow-registry.json +41 -7
  22. package/docs/MAP.md +2 -0
  23. package/docs/auditable-demo.md +55 -3
  24. package/docs/dev-alpha-candidate-patrol.md +9 -0
  25. package/docs/github-artifact-attestation.md +1 -1
  26. package/docs/release-governance.md +32 -0
  27. package/docs/reusable-build-surface.md +73 -58
  28. package/docs/runtime-train-validation.md +21 -0
  29. package/docs/versioning.md +1 -0
  30. package/package.json +5 -1
  31. package/packages/core/artifact-signing-result.js +228 -0
  32. package/packages/core/artifact-signing.js +412 -0
  33. package/packages/core/buildchain-config.js +58 -0
  34. package/packages/core/buildchain-contract.js +8 -0
  35. package/packages/core/buildchain-publication-authority.js +1 -0
  36. package/packages/core/detached-artifact-signature.js +121 -0
  37. package/packages/core/github-governance-authority.js +6 -0
  38. package/packages/core/index.js +27 -0
  39. package/scripts/auditable-demo.mjs +491 -22
  40. package/scripts/buildchain-channel-router.mjs +8 -2
  41. package/scripts/check-inventory.mjs +5 -0
  42. package/scripts/dev-alpha-candidate-patrol.mjs +18 -0
  43. package/scripts/dispatch-artifact-signing-authority.mjs +152 -0
  44. package/scripts/finalize-native-artifact-signing-result.mjs +96 -0
  45. package/scripts/generate-site-bundle.mjs +3 -0
  46. package/scripts/import-artifact-signing-results.mjs +76 -0
  47. package/scripts/inspect-artifact-signing-requests.mjs +101 -0
  48. package/scripts/materialize-artifact-signing-request.mjs +66 -0
  49. package/scripts/merge-artifact-signing-results.mjs +76 -0
  50. package/scripts/release-line-policy.mjs +27 -0
  51. package/scripts/runtime-ref-core.mjs +10 -6
  52. package/scripts/seal-artifact-signing-requests.mjs +368 -0
  53. package/scripts/sign-detached-artifact-requests.mjs +237 -0
  54. package/scripts/verify-artifact-signing-results.mjs +99 -0
@@ -70,6 +70,18 @@ export function parsePublishGateChannelRef(ref) {
70
70
  };
71
71
  }
72
72
 
73
+ export function parseAuthorityRef(ref) {
74
+ const normalizedRef = normalizeRef(ref);
75
+ const match = normalizedRef.match(/^authority\/v(\d+)\/v(\d+\.\d+)\/[A-Za-z0-9._/-]+$/);
76
+ if (!match) return undefined;
77
+ return {
78
+ major: Number(match[1]),
79
+ loose: Number(match[2]),
80
+ normalizedRef,
81
+ lineSuffix: `/v${match[1]}/v${match[2]}`,
82
+ };
83
+ }
84
+
73
85
  export function getChannel(ref) {
74
86
  const versionStateTarget = parseVersionStateRef(ref);
75
87
  if (versionStateTarget) return versionStateTarget.channel;
@@ -116,6 +128,7 @@ export function getBumpKeyword({ cwd = process.cwd(), headRef, baseRef, loose =
116
128
  const versionStateTarget = parseVersionStateRef(headRef);
117
129
  const releaseLineRecoveryTarget = parseReleaseLineRecoveryRef(headRef);
118
130
  const publishGateTarget = parsePublishGateChannelRef(headRef);
131
+ const authorityTarget = parseAuthorityRef(baseRef);
119
132
  const headChannel = getChannel(headRef);
120
133
  const baseChannel = getChannel(baseRef);
121
134
  const key = `${headChannel}->${baseChannel}`;
@@ -130,6 +143,20 @@ export function getBumpKeyword({ cwd = process.cwd(), headRef, baseRef, loose =
130
143
  const preminor = headChannel === "release" && lts;
131
144
  const majorGate = headChannel === "release" && baseChannel === MAJOR_GATE_CHANNEL;
132
145
 
146
+ if (authorityTarget) {
147
+ const headMatch = normalizeRef(headRef).match(/^dev\/v(\d+)\/v(\d+\.\d+)$/);
148
+ if (
149
+ !headMatch ||
150
+ Number(headMatch[1]) !== authorityTarget.major ||
151
+ Number(headMatch[2]) !== authorityTarget.loose ||
152
+ version.major !== authorityTarget.major ||
153
+ looseVersionNumber !== authorityTarget.loose
154
+ ) {
155
+ throw new Error(`Versions not match for head/base refs: ${headRef} -> ${baseRef}`);
156
+ }
157
+ return "none";
158
+ }
159
+
133
160
  if (releaseLineRecoveryTarget) {
134
161
  if (baseChannel !== "release" || releaseLineRecoveryTarget.normalizedRef !== normalizeRef(baseRef)) {
135
162
  throw new Error(`Versions not match for head/base refs: ${headRef} -> ${baseRef}`);
@@ -1,5 +1,6 @@
1
1
  const EXACT_SHA_RE = /^[0-9a-f]{40}$/i;
2
2
  const TRAIN_REF_RE = /^train\/v\d+\/v\d+\.\d+\/[A-Za-z0-9._/-]+$/;
3
+ const AUTHORITY_REF_RE = /^authority\/v\d+\/v\d+\.\d+\/[A-Za-z0-9._/-]+$/;
3
4
  const OFFICIAL_CHANNEL_REF_RE = /^v\d+(?:\.\d+)?(?:-alpha)?$/;
4
5
 
5
6
  export function parseWorkflowShellRef(workflowRef = "", fallback = "v2", buildchainRepository = "kungfu-systems/buildchain") {
@@ -20,6 +21,9 @@ export function classifyBuildchainRuntimeRef(ref = "") {
20
21
  if (TRAIN_REF_RE.test(value)) {
21
22
  return "train";
22
23
  }
24
+ if (AUTHORITY_REF_RE.test(value)) {
25
+ return "authority";
26
+ }
23
27
  if (/^v\d+(?:\.\d+)?$/.test(value) || /^v\d+\.\d+\.\d+$/.test(value)) {
24
28
  return "stable";
25
29
  }
@@ -58,16 +62,16 @@ export function normalizeRequestedRuntimeRef(requestedRef = "") {
58
62
  officialChannel: false,
59
63
  };
60
64
  }
61
- const trainRef = requested.replace(/^refs\/heads\//, "");
62
- if (!TRAIN_REF_RE.test(trainRef)) {
65
+ const protectedRef = requested.replace(/^refs\/heads\//, "");
66
+ if (!TRAIN_REF_RE.test(protectedRef) && !AUTHORITY_REF_RE.test(protectedRef)) {
63
67
  throw new Error(
64
- "buildchain-ref override must be train/vN/vN.M/<capability>, refs/heads/train/vN/vN.M/<capability>, or an exact 40-character SHA",
68
+ "buildchain-ref override must be train/vN/vN.M/<capability>, refs/heads/train/vN/vN.M/<capability>, authority/vN/vN.M/<capability>, refs/heads/authority/vN/vN.M/<capability>, or an exact 40-character SHA",
65
69
  );
66
70
  }
67
71
  return {
68
- ref: trainRef,
69
- fullRef: `refs/heads/${trainRef}`,
70
- class: "train",
72
+ ref: protectedRef,
73
+ fullRef: `refs/heads/${protectedRef}`,
74
+ class: AUTHORITY_REF_RE.test(protectedRef) ? "authority" : "train",
71
75
  exactSha: false,
72
76
  officialChannel: false,
73
77
  };
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ import {
9
+ createArtifactSigningRequest,
10
+ validateArtifactSigningRequest,
11
+ } from "../packages/core/artifact-signing.js";
12
+ import { loadBuildchainConfig } from "../packages/core/buildchain-config.js";
13
+ import { writeGitHubOutputs } from "./build-contract-core.mjs";
14
+
15
+ const INDEX_CONTRACT = "kungfu-buildchain-artifact-signing-request-index/v1";
16
+
17
+ function required(value, label) {
18
+ const normalized = String(value || "").trim();
19
+ if (!normalized) throw new Error(`${label} is required`);
20
+ return normalized;
21
+ }
22
+
23
+ function toPosix(value) {
24
+ return String(value || "")
25
+ .split(path.sep)
26
+ .join("/");
27
+ }
28
+
29
+ function sha256File(filePath) {
30
+ const hash = crypto.createHash("sha256");
31
+ hash.update(fs.readFileSync(filePath));
32
+ return `sha256:${hash.digest("hex")}`;
33
+ }
34
+
35
+ function safeId(value) {
36
+ const normalized = String(value || "")
37
+ .trim()
38
+ .replace(/[^A-Za-z0-9._-]+/gu, "-")
39
+ .replace(/^-+|-+$/gu, "");
40
+ if (!normalized || normalized === "." || normalized === "..") {
41
+ throw new Error(`unsafe artifact signing id: ${value}`);
42
+ }
43
+ return normalized;
44
+ }
45
+
46
+ function assertInside(root, target, label) {
47
+ const relative = path.relative(root, target);
48
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
49
+ if (path.resolve(root) !== path.resolve(target)) {
50
+ throw new Error(`${label} must stay inside the build workspace`);
51
+ }
52
+ }
53
+ }
54
+
55
+ function walkSubject(subjectRoot) {
56
+ const entries = [];
57
+ function visit(current) {
58
+ const stat = fs.lstatSync(current);
59
+ const relative = toPosix(path.relative(subjectRoot, current)) || ".";
60
+ if (stat.isSymbolicLink()) {
61
+ const target = fs.readlinkSync(current);
62
+ const resolved = path.resolve(path.dirname(current), target);
63
+ assertInside(subjectRoot, resolved, `artifact symlink ${relative}`);
64
+ entries.push({ path: relative, type: "symlink", target });
65
+ return;
66
+ }
67
+ if (stat.isDirectory()) {
68
+ if (relative !== ".") entries.push({ path: relative, type: "directory" });
69
+ for (const name of fs.readdirSync(current).sort())
70
+ visit(path.join(current, name));
71
+ return;
72
+ }
73
+ if (!stat.isFile())
74
+ throw new Error(`unsupported artifact filesystem entry: ${relative}`);
75
+ entries.push({
76
+ path: relative,
77
+ type: "file",
78
+ bytes: stat.size,
79
+ digest: sha256File(current),
80
+ });
81
+ }
82
+ visit(subjectRoot);
83
+ return entries;
84
+ }
85
+
86
+ function subjectDescriptor(subjectPath) {
87
+ const stat = fs.lstatSync(subjectPath);
88
+ if (stat.isSymbolicLink())
89
+ throw new Error("artifact root must not be a symlink");
90
+ if (stat.isFile()) {
91
+ return {
92
+ bytes: stat.size,
93
+ digest: sha256File(subjectPath),
94
+ entries: [
95
+ {
96
+ path: path.basename(subjectPath),
97
+ bytes: stat.size,
98
+ digest: sha256File(subjectPath),
99
+ },
100
+ ],
101
+ };
102
+ }
103
+ if (!stat.isDirectory())
104
+ throw new Error("artifact must be a regular file or directory");
105
+ const entries = walkSubject(subjectPath);
106
+ const bytes = entries.reduce((sum, entry) => sum + (entry.bytes || 0), 0);
107
+ const digest = `sha256:${crypto
108
+ .createHash("sha256")
109
+ .update(JSON.stringify(entries))
110
+ .digest("hex")}`;
111
+ return { bytes, digest, entries };
112
+ }
113
+
114
+ function normalizePlatform(value) {
115
+ const normalized = String(value || "")
116
+ .trim()
117
+ .toLowerCase();
118
+ if (["macos", "mac", "darwin"].includes(normalized)) return "macos";
119
+ if (["windows", "win32", "win"].includes(normalized)) return "windows";
120
+ if (normalized === "linux") return "linux";
121
+ return normalized;
122
+ }
123
+
124
+ function inferKind(subjectPath, declaredKind) {
125
+ if (declaredKind && declaredKind !== "auto") return declaredKind;
126
+ const lower = subjectPath.toLowerCase();
127
+ const stat = fs.statSync(subjectPath);
128
+ if (lower.endsWith(".app")) return "app-bundle";
129
+ if (lower.endsWith(".framework")) return "framework-bundle";
130
+ if (lower.endsWith(".xpc")) return "xpc-bundle";
131
+ if (lower.endsWith(".plugin")) return "plugin-bundle";
132
+ if (lower.endsWith(".dylib")) return "dylib";
133
+ if (lower.endsWith(".pkg")) return "pkg";
134
+ if (lower.endsWith(".dmg")) return "dmg";
135
+ if (stat.isDirectory()) return "directory";
136
+ if (/\.(?:zip|tar|tgz|gz|bz2|xz|7z)$/u.test(lower)) return "archive";
137
+ return "binary";
138
+ }
139
+
140
+ function run(command, args) {
141
+ const result = spawnSync(command, args, {
142
+ encoding: "utf8",
143
+ stdio: ["ignore", "pipe", "pipe"],
144
+ });
145
+ if (result.error || result.status !== 0) {
146
+ throw (
147
+ result.error ||
148
+ new Error(
149
+ `${path.basename(command)} failed with status ${result.status}: ${(result.stderr || result.stdout || "").trim().slice(0, 1200)}`,
150
+ )
151
+ );
152
+ }
153
+ }
154
+
155
+ function archiveSubject({ subjectPath, outputRoot, id, kind, platform }) {
156
+ const artifactRoot = path.join(outputRoot, id);
157
+ fs.mkdirSync(artifactRoot, { recursive: true });
158
+ const stat = fs.statSync(subjectPath);
159
+ let archivePath;
160
+ let format;
161
+ if (stat.isFile()) {
162
+ archivePath = path.join(artifactRoot, path.basename(subjectPath));
163
+ fs.copyFileSync(subjectPath, archivePath, fs.constants.COPYFILE_EXCL);
164
+ format = "exact-file";
165
+ } else if (
166
+ platform === "macos" &&
167
+ ["app-bundle", "framework-bundle", "plugin-bundle", "xpc-bundle"].includes(
168
+ kind,
169
+ )
170
+ ) {
171
+ archivePath = path.join(artifactRoot, "subject.ditto.zip");
172
+ run("/usr/bin/ditto", [
173
+ "-c",
174
+ "-k",
175
+ "--sequesterRsrc",
176
+ "--keepParent",
177
+ subjectPath,
178
+ archivePath,
179
+ ]);
180
+ format = "ditto-zip";
181
+ } else {
182
+ archivePath = path.join(artifactRoot, "subject.tar");
183
+ run("tar", [
184
+ "-cf",
185
+ archivePath,
186
+ "-C",
187
+ path.dirname(subjectPath),
188
+ path.basename(subjectPath),
189
+ ]);
190
+ format = "tar";
191
+ }
192
+ return {
193
+ file: toPosix(path.relative(outputRoot, archivePath)),
194
+ format,
195
+ bytes: fs.statSync(archivePath).size,
196
+ digest: sha256File(archivePath),
197
+ };
198
+ }
199
+
200
+ function verifyLifecycleBinding({
201
+ manifest,
202
+ workspace,
203
+ subjectPath,
204
+ descriptor,
205
+ }) {
206
+ const byPath = new Map(
207
+ (manifest.files || []).map((entry) => [toPosix(entry.path), entry]),
208
+ );
209
+ const relativeSubject = toPosix(path.relative(workspace, subjectPath));
210
+ const files = descriptor.entries.filter(
211
+ (entry) => entry.type !== "directory",
212
+ );
213
+ for (const entry of files) {
214
+ if (entry.type === "symlink") continue;
215
+ const manifestPath = fs.statSync(subjectPath).isFile()
216
+ ? relativeSubject
217
+ : `${relativeSubject}/${entry.path}`;
218
+ const observed = byPath.get(manifestPath);
219
+ if (!observed)
220
+ throw new Error(
221
+ `signed artifact file is absent from lifecycle manifest: ${manifestPath}`,
222
+ );
223
+ if (
224
+ `sha256:${observed.sha256}` !== entry.digest ||
225
+ Number(observed.size) !== entry.bytes
226
+ ) {
227
+ throw new Error(
228
+ `signed artifact file does not match lifecycle manifest: ${manifestPath}`,
229
+ );
230
+ }
231
+ }
232
+ }
233
+
234
+ export function sealArtifactSigningRequests({
235
+ workspace = process.env.GITHUB_WORKSPACE || process.cwd(),
236
+ cwd = process.env.BUILDCHAIN_SIGNING_CWD || ".",
237
+ manifestPath = process.env.BUILDCHAIN_SIGNING_ARTIFACT_MANIFEST,
238
+ outputRoot = process.env.BUILDCHAIN_SIGNING_OUTPUT_ROOT ||
239
+ ".buildchain/signing/requests",
240
+ repository = process.env.BUILDCHAIN_SOURCE_REPOSITORY ||
241
+ process.env.GITHUB_REPOSITORY,
242
+ sourceSha = process.env.BUILDCHAIN_SOURCE_SHA || process.env.GITHUB_SHA,
243
+ sourceTreeSha = process.env.BUILDCHAIN_SOURCE_TREE_SHA,
244
+ runtimeSha = process.env.BUILDCHAIN_RUNTIME_SHA,
245
+ platformId = process.env.BUILDCHAIN_PLATFORM_ID,
246
+ } = {}) {
247
+ const resolvedWorkspace = path.resolve(workspace);
248
+ const resolvedCwd = path.resolve(resolvedWorkspace, cwd);
249
+ assertInside(resolvedWorkspace, resolvedCwd, "signing working directory");
250
+ const loaded = loadBuildchainConfig(resolvedCwd);
251
+ const declarations = loaded?.config?.signing?.artifacts || [];
252
+ const selected = declarations.filter(
253
+ (entry) =>
254
+ entry.platforms.length === 0 || entry.platforms.includes(platformId),
255
+ );
256
+ const resolvedOutputRoot = path.resolve(resolvedWorkspace, outputRoot);
257
+ assertInside(resolvedWorkspace, resolvedOutputRoot, "signing request output");
258
+ fs.mkdirSync(resolvedOutputRoot, { recursive: true });
259
+ if (selected.length === 0) {
260
+ const index = { schemaVersion: 1, contract: INDEX_CONTRACT, requests: [] };
261
+ const indexPath = path.join(resolvedOutputRoot, "index.json");
262
+ fs.writeFileSync(indexPath, `${JSON.stringify(index, null, 2)}\n`);
263
+ writeGitHubOutputs({
264
+ "request-count": "0",
265
+ "request-index": indexPath,
266
+ "request-root": resolvedOutputRoot,
267
+ });
268
+ return index;
269
+ }
270
+ const resolvedManifest = path.resolve(
271
+ resolvedWorkspace,
272
+ required(manifestPath, "artifact manifest path"),
273
+ );
274
+ assertInside(resolvedWorkspace, resolvedManifest, "artifact manifest");
275
+ const manifest = JSON.parse(fs.readFileSync(resolvedManifest, "utf8"));
276
+ const platform = normalizePlatform(manifest.platform?.os || process.platform);
277
+ const requests = [];
278
+ for (const declaration of selected) {
279
+ const subjectPath = path.resolve(resolvedCwd, declaration.path);
280
+ assertInside(
281
+ resolvedWorkspace,
282
+ subjectPath,
283
+ `signing artifact ${declaration.id}`,
284
+ );
285
+ if (!fs.existsSync(subjectPath))
286
+ throw new Error(
287
+ `declared signing artifact does not exist: ${declaration.path}`,
288
+ );
289
+ const realSubject = fs.realpathSync(subjectPath);
290
+ assertInside(
291
+ resolvedWorkspace,
292
+ realSubject,
293
+ `signing artifact ${declaration.id}`,
294
+ );
295
+ const descriptor = subjectDescriptor(subjectPath);
296
+ verifyLifecycleBinding({
297
+ manifest,
298
+ workspace: resolvedWorkspace,
299
+ subjectPath,
300
+ descriptor,
301
+ });
302
+ const id = safeId(declaration.id);
303
+ const kind = inferKind(subjectPath, declaration.kind);
304
+ const transport = archiveSubject({
305
+ subjectPath,
306
+ outputRoot: resolvedOutputRoot,
307
+ id,
308
+ kind,
309
+ platform,
310
+ });
311
+ const request = createArtifactSigningRequest({
312
+ source: { repository, sha: sourceSha, treeSha: sourceTreeSha },
313
+ runtime: { repository: "kungfu-systems/buildchain", sha: runtimeSha },
314
+ artifact: {
315
+ id: declaration.id,
316
+ path: toPosix(path.relative(resolvedWorkspace, subjectPath)),
317
+ kind,
318
+ platform,
319
+ arch: String(manifest.platform?.arch || process.arch).toLowerCase(),
320
+ bytes: descriptor.bytes,
321
+ digest: descriptor.digest,
322
+ transport,
323
+ },
324
+ signature: {
325
+ profile: declaration.profile,
326
+ required: declaration.required,
327
+ },
328
+ });
329
+ const check = validateArtifactSigningRequest(request);
330
+ if (!check.ok)
331
+ throw new Error(
332
+ `generated signing request is invalid: ${check.issues.join(", ")}`,
333
+ );
334
+ const requestPath = path.join(resolvedOutputRoot, id, "request.json");
335
+ fs.writeFileSync(requestPath, `${JSON.stringify(request, null, 2)}\n`);
336
+ requests.push({
337
+ id: declaration.id,
338
+ digest: request.digest,
339
+ path: toPosix(path.relative(resolvedOutputRoot, requestPath)),
340
+ required: request.signature.required,
341
+ profile: request.signature.profile,
342
+ platform: request.artifact.platform,
343
+ });
344
+ }
345
+ const index = { schemaVersion: 1, contract: INDEX_CONTRACT, requests };
346
+ const indexPath = path.join(resolvedOutputRoot, "index.json");
347
+ fs.writeFileSync(indexPath, `${JSON.stringify(index, null, 2)}\n`);
348
+ writeGitHubOutputs({
349
+ "request-count": String(requests.length),
350
+ "request-index": indexPath,
351
+ "request-root": resolvedOutputRoot,
352
+ });
353
+ return index;
354
+ }
355
+
356
+ if (
357
+ process.argv[1] &&
358
+ import.meta.url === pathToFileURL(process.argv[1]).href
359
+ ) {
360
+ try {
361
+ sealArtifactSigningRequests();
362
+ } catch (error) {
363
+ console.error(
364
+ `::error::${String(error?.message || error).replace(/\r?\n/gu, "%0A")}`,
365
+ );
366
+ process.exitCode = 1;
367
+ }
368
+ }
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+
7
+ import {
8
+ createArtifactSigningReceipt,
9
+ validateArtifactSigningRequest,
10
+ } from "../packages/core/artifact-signing.js";
11
+ import {
12
+ artifactSigningEvidenceDigest,
13
+ createArtifactSigningResult,
14
+ } from "../packages/core/artifact-signing-result.js";
15
+ import { signDetachedArtifactRequest } from "../packages/core/detached-artifact-signature.js";
16
+ import { writeGitHubOutputs } from "./build-contract-core.mjs";
17
+
18
+ function required(value, label) {
19
+ const normalized = String(value || "").trim();
20
+ if (!normalized) throw new Error(`${label} is required`);
21
+ return normalized;
22
+ }
23
+
24
+ function sha256File(filePath) {
25
+ const hash = crypto.createHash("sha256");
26
+ hash.update(fs.readFileSync(filePath));
27
+ return `sha256:${hash.digest("hex")}`;
28
+ }
29
+
30
+ function resolveInside(root, relative, label) {
31
+ const resolvedRoot = path.resolve(root);
32
+ const resolved = path.resolve(resolvedRoot, required(relative, label));
33
+ const rel = path.relative(resolvedRoot, resolved);
34
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
35
+ throw new Error(`${label} must resolve below its declared root`);
36
+ }
37
+ return resolved;
38
+ }
39
+
40
+ function decodePrivateKey(value) {
41
+ const compact = required(value, "detached signing private key").replace(
42
+ /\s+/gu,
43
+ "",
44
+ );
45
+ if (compact.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(compact)) {
46
+ throw new Error("detached signing private key must be canonical base64");
47
+ }
48
+ const decoded = Buffer.from(compact, "base64");
49
+ if (decoded.length < 32 || decoded.length > 16 * 1024) {
50
+ throw new Error("detached signing private key has an invalid size");
51
+ }
52
+ return crypto.createPrivateKey({
53
+ key: decoded,
54
+ format: "der",
55
+ type: "pkcs8",
56
+ });
57
+ }
58
+
59
+ export function signDetachedArtifactRequests({
60
+ inputRoot = process.env.BUILDCHAIN_SIGNING_REQUEST_ROOT,
61
+ outputRoot = process.env.BUILDCHAIN_SIGNING_RESULT_ROOT,
62
+ privateKeyBase64 = process.env.BUILDCHAIN_DETACHED_PRIVATE_KEY_PKCS8_BASE64,
63
+ keyId = process.env.BUILDCHAIN_DETACHED_KEY_ID,
64
+ artifactId = process.env.BUILDCHAIN_SIGNING_ARTIFACT_ID,
65
+ } = {}) {
66
+ const resolvedInput = path.resolve(
67
+ required(inputRoot, "signing request root"),
68
+ );
69
+ const resolvedOutput = path.resolve(
70
+ required(outputRoot, "signing result root"),
71
+ );
72
+ fs.mkdirSync(resolvedOutput, { recursive: true });
73
+ const index = JSON.parse(
74
+ fs.readFileSync(path.join(resolvedInput, "index.json"), "utf8"),
75
+ );
76
+ if (
77
+ index.contract !== "kungfu-buildchain-artifact-signing-request-index/v1"
78
+ ) {
79
+ throw new Error("artifact signing request index contract mismatch");
80
+ }
81
+ const privateKey = decodePrivateKey(privateKeyBase64);
82
+ const results = [];
83
+ for (const entry of index.requests || []) {
84
+ const requestPath = resolveInside(
85
+ resolvedInput,
86
+ entry.path,
87
+ "signing request path",
88
+ );
89
+ const request = JSON.parse(fs.readFileSync(requestPath, "utf8"));
90
+ const check = validateArtifactSigningRequest(request);
91
+ if (!check.ok)
92
+ throw new Error(
93
+ `invalid artifact signing request: ${check.issues.join(", ")}`,
94
+ );
95
+ if (request.digest !== entry.digest)
96
+ throw new Error("signing request index digest mismatch");
97
+ if (artifactId && request.artifact.id !== artifactId) continue;
98
+ if (request.signature.profile !== "detached-signature-v1") continue;
99
+ const transport = request.artifact.transport;
100
+ if (transport?.format !== "exact-file") {
101
+ throw new Error(
102
+ "detached signing currently requires an exact-file transport",
103
+ );
104
+ }
105
+ const payloadPath = resolveInside(
106
+ resolvedInput,
107
+ transport.file,
108
+ "signing payload path",
109
+ );
110
+ const bytes = fs.statSync(payloadPath).size;
111
+ const digest = sha256File(payloadPath);
112
+ if (
113
+ bytes !== transport.bytes ||
114
+ digest !== transport.digest ||
115
+ bytes !== request.artifact.bytes ||
116
+ digest !== request.artifact.digest
117
+ ) {
118
+ throw new Error(
119
+ "detached signing payload does not match the sealed request",
120
+ );
121
+ }
122
+ const signed = signDetachedArtifactRequest({ request, privateKey, keyId });
123
+ const resultDirectory = path.join(
124
+ resolvedOutput,
125
+ path.basename(path.dirname(requestPath)),
126
+ );
127
+ fs.mkdirSync(resultDirectory, { recursive: true });
128
+ const envelopePath = path.join(resultDirectory, "signature.json");
129
+ const receiptPath = path.join(resultDirectory, "receipt.json");
130
+ const envelopeText = `${JSON.stringify(signed.envelope, null, 2)}\n`;
131
+ fs.writeFileSync(envelopePath, envelopeText);
132
+ const evidence = [
133
+ {
134
+ kind: "ed25519-detached",
135
+ path: "signature.json",
136
+ digest: sha256File(envelopePath),
137
+ },
138
+ ];
139
+ const receipt = createArtifactSigningReceipt({
140
+ request,
141
+ authority: { runtimeSha: request.runtime.sha },
142
+ result: {
143
+ artifactDigest: request.artifact.digest,
144
+ evidenceDigest: artifactSigningEvidenceDigest(evidence),
145
+ },
146
+ signatures: [
147
+ {
148
+ kind: "ed25519-detached",
149
+ digest: signed.envelope.digest,
150
+ },
151
+ ],
152
+ });
153
+ fs.writeFileSync(
154
+ receiptPath,
155
+ `${JSON.stringify(receipt, null, 2)}\n`,
156
+ );
157
+ const payloadDirectory = path.join(resultDirectory, "payload");
158
+ fs.mkdirSync(payloadDirectory, { recursive: true });
159
+ const payloadOutputPath = path.join(
160
+ payloadDirectory,
161
+ path.basename(payloadPath),
162
+ );
163
+ fs.copyFileSync(payloadPath, payloadOutputPath, fs.constants.COPYFILE_EXCL);
164
+ const result = createArtifactSigningResult({
165
+ request,
166
+ receipt,
167
+ receiptPath: "receipt.json",
168
+ payload: {
169
+ path: `payload/${path.basename(payloadOutputPath)}`,
170
+ bytes,
171
+ digest,
172
+ },
173
+ evidence,
174
+ verification: {
175
+ status: "passed",
176
+ provider: request.signature.provider,
177
+ checks: ["sealed-payload-digest", "ed25519-signature-created"],
178
+ },
179
+ });
180
+ const resultPath = path.join(resultDirectory, "result.json");
181
+ fs.writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`);
182
+ results.push({
183
+ id: entry.id,
184
+ requestDigest: request.digest,
185
+ resultDigest: result.digest,
186
+ result: path
187
+ .relative(resolvedOutput, resultPath)
188
+ .split(path.sep)
189
+ .join("/"),
190
+ payload: path
191
+ .relative(resolvedOutput, payloadOutputPath)
192
+ .split(path.sep)
193
+ .join("/"),
194
+ envelope: path
195
+ .relative(resolvedOutput, envelopePath)
196
+ .split(path.sep)
197
+ .join("/"),
198
+ receipt: path
199
+ .relative(resolvedOutput, receiptPath)
200
+ .split(path.sep)
201
+ .join("/"),
202
+ });
203
+ }
204
+ const resultIndex = {
205
+ schemaVersion: 1,
206
+ contract: "kungfu-buildchain-artifact-signing-result-index/v1",
207
+ results,
208
+ };
209
+ if (artifactId && results.length !== 1) {
210
+ throw new Error(`expected one detached signing request for ${artifactId}`);
211
+ }
212
+ const resultIndexPath = path.join(resolvedOutput, "index.json");
213
+ fs.writeFileSync(
214
+ resultIndexPath,
215
+ `${JSON.stringify(resultIndex, null, 2)}\n`,
216
+ );
217
+ writeGitHubOutputs({
218
+ "result-count": String(results.length),
219
+ "result-index": resultIndexPath,
220
+ "result-root": resolvedOutput,
221
+ });
222
+ return resultIndex;
223
+ }
224
+
225
+ if (
226
+ process.argv[1] &&
227
+ import.meta.url === pathToFileURL(process.argv[1]).href
228
+ ) {
229
+ try {
230
+ signDetachedArtifactRequests();
231
+ } catch (error) {
232
+ console.error(
233
+ `::error::${String(error?.message || error).replace(/\r?\n/gu, "%0A")}`,
234
+ );
235
+ process.exitCode = 1;
236
+ }
237
+ }