import { createHash, createPublicKey } from "node:crypto"; import path from "node:path"; import { PROMOTION_VERIFIER_ID, PROMOTION_VERIFIER_VERSION, evaluatePromotionEvidence, providerPublicKeyFingerprint, trustPolicySha256, type ProviderVerificationArtifactType, type ProviderTrust, } from "./provider-evidence.ts"; import { RELEASE_CANDIDATE_MANIFEST_PATH, releaseCandidateManifestSchema, verifyReleaseCandidate, } from "./release-candidate.ts"; import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts"; import { readBoundedRegularFile } from "./ci-artifact-validator.ts"; import { supplyChainDigest } from "./supply-chain.ts"; type LocalEvidenceVerifier = typeof verifyArchivedLocalEvidence; export type VerifyPromotionInputsOptions = Readonly<{ artifactType: ProviderVerificationArtifactType; environment?: NodeJS.ProcessEnv; repositoryRoot?: string; providerEvidenceRoot?: string; trustRoot?: string; verifyLocalEvidence?: LocalEvidenceVerifier; nowEpochMs?: () => number; }>; export async function verifyPromotionInputs( options: VerifyPromotionInputsOptions, ) { const environment = options.environment ?? process.env; const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd()); const trustRoot = path.resolve(options.trustRoot ?? repositoryRoot); const providerEvidenceRoot = path.resolve( options.providerEvidenceRoot ?? repositoryRoot, ); const inputFailures: string[] = []; const archive = await captureOptionalInput( providerEvidenceRoot, environment.CANDIDATE_ARCHIVE_PATH, 268_435_456, "candidate archive", inputFailures, ); if (!environment.CANDIDATE_ARCHIVE_SHA256) { inputFailures.push("candidate archive expected SHA-256 is missing"); } else if ( archive.sha256 && archive.sha256 !== environment.CANDIDATE_ARCHIVE_SHA256 ) { inputFailures.push("candidate archive SHA-256 does not match immutable output"); } const vulnerabilityCapture = await captureOptionalInput( providerEvidenceRoot, environment.VULNERABILITY_REPORT_PATH, 16_777_216, "vulnerability report", inputFailures, ); const provenanceCapture = await captureOptionalInput( providerEvidenceRoot, environment.PROVENANCE_ATTESTATION_PATH, 16_777_216, "provenance attestation", inputFailures, ); const manifestDocument = await requiredJson( repositoryRoot, RELEASE_CANDIDATE_MANIFEST_PATH, ); const manifest = releaseCandidateManifestSchema.parse(manifestDocument); const candidate = await verifyReleaseCandidate( manifestDocument, repositoryRoot, ); const localEvidence = await ( options.verifyLocalEvidence ?? verifyArchivedLocalEvidence )({ extractionRoot: repositoryRoot, expectedManifest: manifest }); const vulnerabilityReport = parseCapturedJson(vulnerabilityCapture.bytes); const provenanceAttestation = parseCapturedJson(provenanceCapture.bytes); const vulnerabilityTrust = await readProviderTrust( trustRoot, environment.VULNERABILITY_PUBLIC_KEY_PATH, environment.VULNERABILITY_KEY_ID, ); const provenanceTrust = await readProviderTrust( trustRoot, environment.PROVENANCE_PUBLIC_KEY_PATH, environment.PROVENANCE_KEY_ID, ); const runId = environment.CI_RUN_ID ?? "missing-run"; const runAttempt = Number(environment.CI_RUN_ATTEMPT); if (!environment.CI_RUN_ID) inputFailures.push("provider expected run ID is missing"); if (!Number.isInteger(runAttempt) || runAttempt < 1 || runAttempt > 1_000) { inputFailures.push("provider expected run attempt is missing or invalid"); } if (!localEvidence.identity) { inputFailures.push("archived local evidence identity is unavailable"); } if ( environment.EXPECTED_SOURCE_REVISION && localEvidence.identity && environment.EXPECTED_SOURCE_REVISION !== localEvidence.identity.sourceRevision ) { inputFailures.push( `provider expected source revision mismatch: expected ${environment.EXPECTED_SOURCE_REVISION}, archived ${localEvidence.identity.sourceRevision}`, ); } const vulnerabilityInvocationNonce = requiredExpectedNonce( environment.VULNERABILITY_INVOCATION_NONCE, "vulnerability", inputFailures, ); const provenanceInvocationNonce = requiredExpectedNonce( environment.PROVENANCE_INVOCATION_NONCE, "provenance", inputFailures, ); const expected = { run: { id: runId, attempt: Number.isInteger(runAttempt) ? runAttempt : 1 }, source: { revision: localEvidence.identity?.sourceRevision ?? environment.EXPECTED_SOURCE_REVISION ?? "0".repeat(40), sourceSetSha256: localEvidence.identity?.sourceSetSha256 ?? "0".repeat(64), }, candidate: { archiveSha256: archive.sha256 ?? "0".repeat(64), bundleSha256: manifest.bundleSha256, distSha256: manifest.distSha256, lockfileSha256: manifest.lockfileSha256, }, vulnerabilityInvocationNonce, provenanceInvocationNonce, } as const; const result = evaluatePromotionEvidence({ expected, localStatus: localEvidence.status, vulnerabilityReport, provenanceAttestation, vulnerabilityTrust, provenanceTrust, nowEpochMs: options.nowEpochMs, }); const failures = [ ...inputFailures, ...candidate.failures, ...localEvidence.failures, ...result.failures, ]; const now = (options.nowEpochMs ?? Date.now)(); const common = { schemaVersion: 3 as const, artifactType: options.artifactType, verifiedAt: new Date(now).toISOString(), status: failures.length === 0 && result.status === "PASS" ? ("PASS" as const) : ("FAIL_UNVERIFIED" as const), verifier: Object.freeze({ id: PROMOTION_VERIFIER_ID, version: PROMOTION_VERIFIER_VERSION, }), run: expected.run, source: expected.source, candidate: expected.candidate, providerEvidence: Object.freeze({ vulnerabilityReportSha256: vulnerabilityCapture.sha256 ?? "0".repeat(64), provenanceAttestationSha256: provenanceCapture.sha256 ?? "0".repeat(64), vulnerabilityInvocationNonce: expected.vulnerabilityInvocationNonce, provenanceInvocationNonce: expected.provenanceInvocationNonce, vulnerabilityKeyId: vulnerabilityTrust?.keyId ?? environment.VULNERABILITY_KEY_ID ?? "missing-key", vulnerabilityKeyFingerprint: vulnerabilityTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`, provenanceKeyId: provenanceTrust?.keyId ?? environment.PROVENANCE_KEY_ID ?? "missing-key", provenanceKeyFingerprint: provenanceTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`, }), trustPolicySha256: verificationTrustPolicySha256( vulnerabilityTrust, provenanceTrust, environment, ), failures: Object.freeze(failures), }; return options.artifactType === "provider-verification" ? Object.freeze({ ...common, artifactType: "provider-verification" as const, vulnerabilityStatus: result.vulnerabilityStatus, provenanceAttestationStatus: result.provenanceAttestationStatus, }) : Object.freeze({ ...common, artifactType: "promotion-verification" as const, localEvidenceStatus: localEvidence.status, localEvidenceAssessmentSha256: localEvidence.identity?.assessmentSha256 ?? "0".repeat(64), providerVerificationSha256: environment.PROVIDER_VERIFICATION_SHA256 ?? "0".repeat(64), }); } function requiredExpectedNonce( value: string | undefined, label: "vulnerability" | "provenance", failures: string[], ): string { if (value && /^[a-f0-9]{64}$/u.test(value)) return value; failures.push(`${label} expected invocation nonce is missing or invalid`); return "0".repeat(64); } function verificationTrustPolicySha256( vulnerabilityTrust: ProviderTrust | null, provenanceTrust: ProviderTrust | null, environment: NodeJS.ProcessEnv, ): string { if (vulnerabilityTrust && provenanceTrust) { return trustPolicySha256({ vulnerabilityTrust, provenanceTrust }); } return supplyChainDigest({ algorithm: "Ed25519", vulnerability: { keyId: vulnerabilityTrust?.keyId ?? environment.VULNERABILITY_KEY_ID ?? "missing-key", publicKeyFingerprint: vulnerabilityTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`, }, provenance: { keyId: provenanceTrust?.keyId ?? environment.PROVENANCE_KEY_ID ?? "missing-key", publicKeyFingerprint: provenanceTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`, }, issuedAtFutureSkewMs: 5 * 60 * 1_000, maximumLifetimeMs: 2 * 60 * 60 * 1_000, }); } export async function readProviderTrust( repositoryRoot: string, publicKeyPath: string | undefined, keyId: string | undefined, ): Promise { if (!publicKeyPath || !keyId?.trim()) return null; try { const publicKey = createPublicKey( new TextDecoder("utf-8", { fatal: true }).decode( await boundedConfiguredFile(repositoryRoot, publicKeyPath, 1_048_576), ), ); return Object.freeze({ keyId, publicKey, publicKeyFingerprint: providerPublicKeyFingerprint(publicKey), }); } catch { return null; } } async function captureOptionalInput( root: string, configuredPath: string | undefined, maxBytes: number, label: string, failures: string[], ): Promise> { if (!configuredPath) { failures.push(`${label} path is missing`); return Object.freeze({ bytes: null, sha256: null }); } try { const bytes = await boundedConfiguredFile(root, configuredPath, maxBytes); return Object.freeze({ bytes, sha256: createHash("sha256").update(bytes).digest("hex"), }); } catch (error) { failures.push( `${label} capture failed: ${error instanceof Error ? error.message : String(error)}`, ); return Object.freeze({ bytes: null, sha256: null }); } } function parseCapturedJson(bytes: Buffer | null): unknown { if (!bytes) return null; try { return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; } catch { return null; } } async function requiredJson( repositoryRoot: string, file: string, ): Promise> { const value: unknown = JSON.parse( new TextDecoder("utf-8", { fatal: true }).decode( await boundedConfiguredFile(repositoryRoot, file, 8_388_608), ), ); if (!value || typeof value !== "object" || Array.isArray(value)) { throw new TypeError(`${file} must be a JSON object`); } return value as Record; } async function boundedConfiguredFile( configuredRoot: string, configuredPath: string, maxBytes: number, ): Promise { const root = path.resolve(configuredRoot); 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, }); }