import { createHash, createPublicKey, randomBytes as cryptoRandomBytes, } from "node:crypto"; import { constants } from "node:fs"; import { lstat, mkdir, open, rm, stat, } from "node:fs/promises"; import path from "node:path"; import { PROMOTED_FILE_NAMES, type PromotedFileName, } from "../contracts/promotion-artifacts.ts"; import { evaluatePromotionEvidence, providerPublicKeyFingerprint, providerVerificationArtifactSchema, PROMOTION_VERIFIER_ID, PROMOTION_VERIFIER_VERSION, provenanceProviderAttestationSchema, trustPolicySha256, vulnerabilityProviderReportSchema, type ProviderTrust, } from "./provider-evidence.ts"; import { captureCiCandidateArchive, withVerifiedCapturedCandidate, } from "./ci-candidate-archive.ts"; import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts"; import { readBoundedRegularFile } from "./ci-artifact-validator.ts"; type StagedFile = Readonly<{ name: PromotedFileName; bytes: Buffer; sha256: string; }>; export type FinalizedPromotion = Readonly<{ stagingRoot: string; cleanupToken: string; runnerTempIdentity: Readonly<{ dev: number; ino: number }>; files: readonly Readonly<{ name: PromotedFileName; sha256: string }>[]; }>; export async function finalizeVerifiedPromotion(input: Readonly<{ repositoryRoot: string; archivePath: string; expectedArchiveSha256: string; vulnerabilityReportPath: string; provenanceAttestationPath: string; vulnerabilityPublicKeyPath: string; vulnerabilityKeyId: string; provenancePublicKeyPath: string; provenanceKeyId: string; expectedRun: Readonly<{ id: string; attempt: number; sourceRevision: string }>; vulnerabilityInvocationNonce: string; provenanceInvocationNonce: string; runnerTempRoot: string; }>, dependencies: Readonly<{ captureArchive?: typeof captureCiCandidateArchive; nowEpochMs?: () => number; randomBytes?: (bytes: number) => Buffer; afterCapture?: () => Promise; beforePublish?: () => Promise; afterStagingWrite?: () => Promise; }> = {}): Promise { const root = path.resolve(input.repositoryRoot); const capturedArchive = await (dependencies.captureArchive ?? captureCiCandidateArchive)({ archivePath: input.archivePath, expectedSha256: input.expectedArchiveSha256, }); const [vulnerabilityBytes, provenanceBytes, vulnerabilityKeyBytes, provenanceKeyBytes] = await Promise.all([ capture(root, input.vulnerabilityReportPath, 16_777_216), capture(root, input.provenanceAttestationPath, 16_777_216), capture(root, input.vulnerabilityPublicKeyPath, 1_048_576), capture(root, input.provenancePublicKeyPath, 1_048_576), ]); await dependencies.afterCapture?.(); const vulnerabilityTrust = capturedTrust( input.vulnerabilityKeyId, vulnerabilityKeyBytes, ); const provenanceTrust = capturedTrust( input.provenanceKeyId, provenanceKeyBytes, ); const vulnerabilityReport = vulnerabilityProviderReportSchema.parse( parseJson(vulnerabilityBytes), ); const provenanceAttestation = provenanceProviderAttestationSchema.parse( parseJson(provenanceBytes), ); const now = (dependencies.nowEpochMs ?? Date.now)(); const verifiedAt = new Date(now).toISOString(); const generated = await withVerifiedCapturedCandidate({ captured: capturedArchive, verify: async ({ extractionRoot, manifest }) => { const local = await verifyArchivedLocalEvidence({ extractionRoot, expectedManifest: manifest, }); if (local.status !== "PASS" || !local.identity) { throw new Error( `captured local evidence failed final verification: ${local.failures.join(", ")}`, ); } if (local.identity.sourceRevision !== input.expectedRun.sourceRevision) { throw new Error("captured source revision differs from expected promotion revision"); } const expected = { run: { id: input.expectedRun.id, attempt: input.expectedRun.attempt }, source: { revision: local.identity.sourceRevision, sourceSetSha256: local.identity.sourceSetSha256, }, candidate: { archiveSha256: capturedArchive.archiveSha256, bundleSha256: manifest.bundleSha256, distSha256: manifest.distSha256, lockfileSha256: manifest.lockfileSha256, }, vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce, provenanceInvocationNonce: input.provenanceInvocationNonce, } as const; const reevaluated = evaluatePromotionEvidence({ expected, localStatus: local.status, vulnerabilityReport, provenanceAttestation, vulnerabilityTrust, provenanceTrust, nowEpochMs: () => now, }); if (reevaluated.status !== "PASS") { throw new Error( `captured provider evidence failed trusted revalidation: ${reevaluated.failures.join(", ")}`, ); } const providerEvidence = { vulnerabilityReportSha256: sha256(vulnerabilityBytes), provenanceAttestationSha256: sha256(provenanceBytes), vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce, provenanceInvocationNonce: input.provenanceInvocationNonce, vulnerabilityKeyId: vulnerabilityTrust.keyId, vulnerabilityKeyFingerprint: vulnerabilityTrust.publicKeyFingerprint, provenanceKeyId: provenanceTrust.keyId, provenanceKeyFingerprint: provenanceTrust.publicKeyFingerprint, } as const; const trustDigest = trustPolicySha256({ vulnerabilityTrust, provenanceTrust }); const common = { schemaVersion: 3 as const, verifiedAt, status: "PASS" as const, verifier: { id: PROMOTION_VERIFIER_ID, version: PROMOTION_VERIFIER_VERSION, }, run: expected.run, source: expected.source, candidate: expected.candidate, providerEvidence, trustPolicySha256: trustDigest, failures: [] as const, }; const providerRecord = providerVerificationArtifactSchema.parse({ ...common, artifactType: "provider-verification", vulnerabilityStatus: reevaluated.vulnerabilityStatus, provenanceAttestationStatus: reevaluated.provenanceAttestationStatus, }); const providerRecordBytes = canonicalJsonBytes(providerRecord); const promotionRecord = providerVerificationArtifactSchema.parse({ ...common, artifactType: "promotion-verification", localEvidenceStatus: local.status, localEvidenceAssessmentSha256: local.identity.assessmentSha256, providerVerificationSha256: sha256(providerRecordBytes), }); return Object.freeze({ providerRecordBytes, promotionRecordBytes: canonicalJsonBytes(promotionRecord), }); }, }); const stagedFiles: readonly StagedFile[] = Object.freeze([ staged("release-candidate.tar.gz", capturedArchive.bytes), staged("vulnerability-report.json", vulnerabilityBytes), staged("provenance-attestation.json", provenanceBytes), staged("provider-verification.json", generated.providerRecordBytes), staged("promotion-verification.json", generated.promotionRecordBytes), ]); if ( JSON.stringify(stagedFiles.map(({ name }) => name)) !== JSON.stringify(PROMOTED_FILE_NAMES) ) { throw new Error("promotion exact-five canonical file order drift"); } await dependencies.beforePublish?.(); return publishPrivateStaging( input.runnerTempRoot, input.expectedRun, stagedFiles, dependencies.randomBytes ?? cryptoRandomBytes, dependencies.afterStagingWrite, ); } export const stageVerifiedPromotion = finalizeVerifiedPromotion; export async function cleanupFinalizedPromotion(input: Readonly<{ runnerTempRoot: string; stagingRoot: string; cleanupToken: string; runnerTempIdentity: Readonly<{ dev: number; ino: number }>; }>, dependencies: Readonly<{ beforeRemove?: () => Promise; }> = {}): Promise { const parent = path.resolve(input.runnerTempRoot); const expected = path.join(parent, input.cleanupToken); if ( !/^[A-Za-z0-9._-]+-[a-f0-9]{32}$/u.test(input.cleanupToken) || path.resolve(input.stagingRoot) !== expected || !Number.isSafeInteger(input.runnerTempIdentity.dev) || input.runnerTempIdentity.dev <= 0 || !Number.isSafeInteger(input.runnerTempIdentity.ino) || input.runnerTempIdentity.ino <= 0 ) { throw new TypeError("promotion cleanup root/token mismatch"); } const parentHandle = await open( parent, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, ); try { const openedParent = await parentHandle.stat(); assertRunnerTempIdentity(openedParent, input.runnerTempIdentity); const descriptorRoot = `/proc/self/fd/${parentHandle.fd}`; const descriptorMetadata = await stat(descriptorRoot); if (!descriptorMetadata.isDirectory()) { throw new Error("descriptor-relative cleanup is unavailable"); } const descriptorExpected = path.join(descriptorRoot, input.cleanupToken); let metadata; try { metadata = await lstat(descriptorExpected); } catch (error) { if (hasErrorCode(error, "ENOENT")) return; throw error; } if (metadata.isSymbolicLink() || !metadata.isDirectory()) { throw new TypeError("promotion cleanup leaf is unsafe"); } await dependencies.beforeRemove?.(); const visibleParent = await lstat(parent); assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity); await rm(descriptorExpected, { recursive: true, force: true }); const afterParent = await lstat(parent); assertRunnerTempIdentity(afterParent, input.runnerTempIdentity); } finally { await parentHandle.close(); } } async function publishPrivateStaging( runnerTempRoot: string, run: Readonly<{ id: string; attempt: number }>, files: readonly StagedFile[], randomBytes: (bytes: number) => Buffer, afterStagingWrite?: () => Promise, ): Promise { const parentPath = path.resolve(runnerTempRoot); const before = await lstat(parentPath); if (!before.isDirectory() || before.isSymbolicLink()) { throw new TypeError("runner temporary root must be a real directory"); } const parentHandle = await open( parentPath, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, ); const tokenBytes = randomBytes(16); if (tokenBytes.byteLength !== 16) { await parentHandle.close(); throw new TypeError("promotion staging nonce must contain exactly 128 random bits"); } const safeRun = run.id.replaceAll(/[^A-Za-z0-9._-]/gu, "_").slice(0, 64) || "run"; const cleanupToken = `promotion-${safeRun}-${run.attempt}-${tokenBytes.toString("hex")}`; const descriptorRoot = `/proc/self/fd/${parentHandle.fd}`; const descriptorStaging = path.join(descriptorRoot, cleanupToken); const visibleStaging = path.join(parentPath, cleanupToken); let ownsStaging = false; try { const procMetadata = await stat(descriptorRoot); if (!procMetadata.isDirectory()) throw new Error("descriptor-relative staging is unavailable"); await mkdir(descriptorStaging, { mode: 0o700 }); ownsStaging = true; for (const file of files) { const handle = await open( path.join(descriptorStaging, file.name), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o400, ); try { await handle.writeFile(file.bytes); await handle.sync(); } finally { await handle.close(); } } await syncDirectory(descriptorStaging); await syncHandle(parentHandle); await afterStagingWrite?.(); const after = await lstat(parentPath); if ( after.dev !== before.dev || after.ino !== before.ino || after.isSymbolicLink() || !after.isDirectory() ) { throw new Error("runner temporary parent identity changed during staging"); } const visible = await lstat(visibleStaging); if (!visible.isDirectory() || visible.isSymbolicLink()) { throw new Error("promotion staging visibility identity mismatch"); } ownsStaging = false; return Object.freeze({ stagingRoot: visibleStaging, cleanupToken, runnerTempIdentity: Object.freeze({ dev: before.dev, ino: before.ino }), files: Object.freeze( files.map(({ name, sha256: digest }) => Object.freeze({ name, sha256: digest })), ), }); } finally { if (ownsStaging) { await rm(descriptorStaging, { recursive: true, force: true }).catch(() => undefined); } await parentHandle.close(); } } function assertRunnerTempIdentity( metadata: Readonly<{ dev: number; ino: number; isDirectory: () => boolean; isSymbolicLink?: () => boolean }>, expected: Readonly<{ dev: number; ino: number }>, ): void { if ( metadata.dev !== expected.dev || metadata.ino !== expected.ino || !metadata.isDirectory() || metadata.isSymbolicLink?.() ) { throw new Error("runner temporary parent identity changed during cleanup"); } } function capturedTrust(keyId: string, bytes: Buffer): ProviderTrust { const publicKey = createPublicKey( new TextDecoder("utf-8", { fatal: true }).decode(bytes), ); return Object.freeze({ keyId, publicKey, publicKeyFingerprint: providerPublicKeyFingerprint(publicKey), }); } async function capture(root: string, configuredPath: string, maxBytes: number): Promise { const absolute = path.resolve(root, configuredPath); const relative = path.relative(root, absolute); const outside = relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); return readBoundedRegularFile({ root: outside ? path.dirname(absolute) : root, relativePath: outside ? path.basename(absolute) : relative.replaceAll(path.sep, "/"), maxBytes, }); } function staged(name: PromotedFileName, bytes: Buffer): StagedFile { return Object.freeze({ name, bytes, sha256: sha256(bytes) }); } function canonicalJsonBytes(value: unknown): Buffer { return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"); } function parseJson(bytes: Buffer): unknown { try { return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; } catch { throw new TypeError("captured provider evidence is not valid UTF-8 JSON"); } } function sha256(bytes: Buffer): string { return createHash("sha256").update(bytes).digest("hex"); } async function syncDirectory(directory: string): Promise { const handle = await open( directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, ); try { await syncHandle(handle); } finally { await handle.close(); } } async function syncHandle(handle: Awaited>): Promise { try { await handle.sync(); } catch (error) { if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error; } } function hasErrorCode(error: unknown, code: string): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === code); }