@kungfu-tech/buildchain 3.0.2-alpha.0 → 3.0.2-alpha.10
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.
- package/README.md +4 -2
- package/actions/github-artifact-attestation/README.md +10 -0
- package/actions/promote-buildchain-ref/README.md +7 -0
- package/bin/buildchain.mjs +5 -0
- package/bin/internal/trust-release-cli.mjs +74 -3
- package/contracts/auditable-demo-media-profiles-v1.json +168 -0
- package/contracts/evidence/auditable-demo-web-delivery-v1.json +103 -0
- package/contracts/fixtures/auditable-demo-web-delivery-v1/complete-transcript.txt +2 -0
- package/contracts/fixtures/auditable-demo-web-delivery-v1/public-projection.json +16 -0
- package/contracts/fixtures/auditable-demo-web-delivery-v1/scene.json +12 -0
- package/dist/site/artifact-schemas.json +5 -1
- package/dist/site/buildchain-contract.json +133 -34
- package/dist/site/buildchain-site.json +159 -40
- package/dist/site/capability-registry.json +14 -13
- package/dist/site/cli-registry.json +12 -0
- package/dist/site/controller-registry.json +72 -4
- package/dist/site/kfd-claims.json +338 -20
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +24 -9
- package/dist/site/node-api-registry.json +89 -11
- package/dist/site/page-registry.json +135 -25
- package/dist/site/public-surface-audit.json +203 -21
- package/dist/site/publication-authority-registry.json +72 -2
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/release-model.json +2 -1
- package/dist/site/release-passport-check-manifest.json +1 -0
- package/dist/site/release-provenance.json +6 -0
- package/dist/site/schemas/release-passport-v1.schema.json +6 -0
- package/dist/site/site-manifest.json +21 -13
- package/dist/site/workflow-registry.json +167 -13
- package/docs/MAP.md +5 -1
- package/docs/auditable-demo.md +55 -3
- package/docs/binary-distribution.md +7 -0
- package/docs/cli.md +9 -0
- package/docs/dev-alpha-candidate-patrol.md +111 -0
- package/docs/github-artifact-attestation.md +219 -0
- package/docs/release-governance.md +32 -0
- package/docs/release-passport.md +13 -0
- package/docs/reusable-build-surface.md +83 -61
- package/docs/runtime-train-validation.md +21 -0
- package/docs/versioning.md +1 -0
- package/package.json +8 -1
- package/packages/core/artifact-signing-result.js +228 -0
- package/packages/core/artifact-signing.js +412 -0
- package/packages/core/buildchain-config.js +58 -0
- package/packages/core/buildchain-contract.js +14 -0
- package/packages/core/buildchain-kfd-claims.js +5 -0
- package/packages/core/buildchain-publication-authority.js +3 -0
- package/packages/core/cache-evidence.js +288 -0
- package/packages/core/channel-candidate.js +186 -0
- package/packages/core/detached-artifact-signature.js +121 -0
- package/packages/core/diagnostics.js +276 -10
- package/packages/core/github-artifact-attestation.js +642 -0
- package/packages/core/github-governance-authority.js +77 -16
- package/packages/core/index.js +62 -0
- package/packages/core/publication-authority.js +1 -1
- package/packages/core/release-passport-contract.js +2 -0
- package/packages/core/release-passport.js +60 -3
- package/scripts/auditable-demo.mjs +520 -28
- package/scripts/build-contract-core.mjs +31 -0
- package/scripts/buildchain-channel-router.mjs +8 -2
- package/scripts/check-inventory.mjs +9 -0
- package/scripts/create-github-artifact-attestation-policy.mjs +62 -0
- package/scripts/dev-alpha-candidate-patrol.mjs +864 -0
- package/scripts/dispatch-artifact-signing-authority.mjs +152 -0
- package/scripts/finalize-native-artifact-signing-result.mjs +96 -0
- package/scripts/generate-channel-promotion-workflow.mjs +6 -0
- package/scripts/generate-site-bundle.mjs +20 -0
- package/scripts/import-artifact-signing-results.mjs +76 -0
- package/scripts/inspect-artifact-signing-requests.mjs +101 -0
- package/scripts/locked-source-checkout.mjs +48 -0
- package/scripts/materialize-artifact-signing-request.mjs +66 -0
- package/scripts/merge-artifact-signing-results.mjs +76 -0
- package/scripts/publish-github-artifact-attestation-evidence.mjs +201 -0
- package/scripts/reconcile-github-governance.mjs +158 -25
- package/scripts/release-candidate-resolver.mjs +12 -0
- package/scripts/release-line-policy.mjs +27 -0
- package/scripts/runtime-ref-core.mjs +10 -6
- package/scripts/seal-artifact-signing-requests.mjs +368 -0
- package/scripts/shifu-gate-profile.mjs +26 -20
- package/scripts/sign-detached-artifact-requests.mjs +237 -0
- package/scripts/stage-github-artifact-attestation-inputs.mjs +65 -0
- package/scripts/verify-artifact-signing-results.mjs +99 -0
|
@@ -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
|
+
}
|
|
@@ -114,10 +114,24 @@ export function prepareGateExecutionFiles(files) {
|
|
|
114
114
|
for (const file of files) fs.rmSync(file, { force: true });
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
export function commandSpawnOptions({
|
|
118
|
+
cwd,
|
|
119
|
+
env = process.env,
|
|
120
|
+
streamOutput = false,
|
|
121
|
+
} = {}) {
|
|
122
|
+
if (streamOutput) return { cwd, env, stdio: "inherit" };
|
|
123
|
+
return {
|
|
124
|
+
cwd,
|
|
125
|
+
env,
|
|
126
|
+
encoding: "utf8",
|
|
127
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
117
131
|
function runArgv(
|
|
118
132
|
argv,
|
|
119
133
|
args,
|
|
120
|
-
{ cwd, env = process.env, allowFailure = false } = {},
|
|
134
|
+
{ cwd, env = process.env, allowFailure = false, streamOutput = false } = {},
|
|
121
135
|
) {
|
|
122
136
|
const command = argv[0];
|
|
123
137
|
const commandArgs = [...argv.slice(1), ...args];
|
|
@@ -126,25 +140,12 @@ function runArgv(
|
|
|
126
140
|
const batchInvocation = windowsBatch
|
|
127
141
|
? windowsBatchInvocation(command, commandArgs, { cwd })
|
|
128
142
|
: null;
|
|
143
|
+
const spawnOptions = commandSpawnOptions({ cwd, env, streamOutput });
|
|
129
144
|
const result = windowsBatch
|
|
130
|
-
? spawnSync(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
cwd,
|
|
135
|
-
env,
|
|
136
|
-
encoding: "utf8",
|
|
137
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
138
|
-
},
|
|
139
|
-
)
|
|
140
|
-
: spawnSync(command, commandArgs, {
|
|
141
|
-
cwd,
|
|
142
|
-
env,
|
|
143
|
-
encoding: "utf8",
|
|
144
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
145
|
-
});
|
|
146
|
-
if (result.stdout) process.stdout.write(result.stdout);
|
|
147
|
-
if (result.stderr) process.stderr.write(result.stderr);
|
|
145
|
+
? spawnSync(batchInvocation.command, batchInvocation.args, spawnOptions)
|
|
146
|
+
: spawnSync(command, commandArgs, spawnOptions);
|
|
147
|
+
if (!streamOutput && result.stdout) process.stdout.write(result.stdout);
|
|
148
|
+
if (!streamOutput && result.stderr) process.stderr.write(result.stderr);
|
|
148
149
|
if (result.error) throw result.error;
|
|
149
150
|
const status = result.status ?? 1;
|
|
150
151
|
if (!allowFailure && status !== 0)
|
|
@@ -257,7 +258,12 @@ function runMode() {
|
|
|
257
258
|
],
|
|
258
259
|
registry,
|
|
259
260
|
);
|
|
260
|
-
const runResult = runArgv(argv, runArgs, {
|
|
261
|
+
const runResult = runArgv(argv, runArgs, {
|
|
262
|
+
cwd,
|
|
263
|
+
env,
|
|
264
|
+
allowFailure: true,
|
|
265
|
+
streamOutput: true,
|
|
266
|
+
});
|
|
261
267
|
let receipt = fs.existsSync(receiptPath)
|
|
262
268
|
? readJson(receiptPath, "Shifu gate receipt")
|
|
263
269
|
: null;
|
|
@@ -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
|
+
}
|