354 lines
12 KiB
TypeScript
354 lines
12 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import {
|
|
PROMOTED_FILE_NAMES,
|
|
type PromotedFileName,
|
|
} from "../contracts/promotion-artifacts.ts";
|
|
import {
|
|
PROMOTION_VERIFIER_ID,
|
|
PROMOTION_VERIFIER_VERSION,
|
|
assertDistinctProviderTrust,
|
|
evaluatePromotionEvidence,
|
|
providerVerificationArtifactSchema,
|
|
provenanceProviderAttestationSchema,
|
|
vulnerabilityProviderReportSchema,
|
|
trustPolicySha256,
|
|
type ProviderTrust,
|
|
} from "./provider-evidence.ts";
|
|
import { verifyCapturedCiCandidateArchive } from "./ci-candidate-archive.ts";
|
|
import { LOCAL_EVIDENCE_ASSESSMENT_PATH } from "./release-candidate.ts";
|
|
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
|
|
|
export type ExactPromotionBundle = Readonly<
|
|
Partial<Record<PromotedFileName, Buffer>>
|
|
>;
|
|
|
|
export type ExactPromotionExpectedContext = Readonly<{
|
|
run: Readonly<{ id: string; attempt: number }>;
|
|
sourceRevision: string;
|
|
archiveSha256: string;
|
|
sourceSetSha256?: string;
|
|
bundleSha256?: string;
|
|
distSha256?: string;
|
|
lockfileSha256?: string;
|
|
}>;
|
|
|
|
export async function verifyExactPromotionBundle(
|
|
files: ExactPromotionBundle,
|
|
options: Readonly<{
|
|
vulnerabilityTrust: ProviderTrust;
|
|
provenanceTrust: ProviderTrust;
|
|
expected: ExactPromotionExpectedContext;
|
|
nowEpochMs?: () => number;
|
|
}>,
|
|
): Promise<Readonly<{ status: "PASS" }>> {
|
|
assertDistinctProviderTrust(options);
|
|
assertExternalExpectedContext(options.expected);
|
|
const names = Object.keys(files).sort(asciiCompare);
|
|
const expectedNames = [...PROMOTED_FILE_NAMES].sort(asciiCompare);
|
|
if (JSON.stringify(names) !== JSON.stringify(expectedNames)) {
|
|
throw new Error("promotion bundle must contain the exact five canonical files");
|
|
}
|
|
for (const name of PROMOTED_FILE_NAMES) {
|
|
if (!Buffer.isBuffer(files[name])) {
|
|
throw new TypeError(`promotion bundle file is missing or not captured bytes: ${name}`);
|
|
}
|
|
}
|
|
|
|
const archiveBytes = files["release-candidate.tar.gz"]!;
|
|
const vulnerabilityBytes = files["vulnerability-report.json"]!;
|
|
const provenanceBytes = files["provenance-attestation.json"]!;
|
|
const providerBytes = files["provider-verification.json"]!;
|
|
const promotionBytes = files["promotion-verification.json"]!;
|
|
const vulnerability = vulnerabilityProviderReportSchema.parse(parseJson(
|
|
vulnerabilityBytes,
|
|
"vulnerability report",
|
|
));
|
|
const provenance = provenanceProviderAttestationSchema.parse(parseJson(
|
|
provenanceBytes,
|
|
"provenance attestation",
|
|
));
|
|
const provider = providerVerificationArtifactSchema.parse(parseJson(
|
|
providerBytes,
|
|
"provider verification",
|
|
));
|
|
const promotion = providerVerificationArtifactSchema.parse(parseJson(
|
|
promotionBytes,
|
|
"promotion verification",
|
|
));
|
|
|
|
if (
|
|
provider.artifactType !== "provider-verification" ||
|
|
promotion.artifactType !== "promotion-verification"
|
|
) {
|
|
throw new Error("promotion verification artifact role mismatch");
|
|
}
|
|
for (const [label, record] of [
|
|
["provider", provider],
|
|
["promotion", promotion],
|
|
] as const) {
|
|
if (
|
|
record.verifier.id !== PROMOTION_VERIFIER_ID ||
|
|
record.verifier.version !== PROMOTION_VERIFIER_VERSION
|
|
) {
|
|
throw new Error(`${label} verification literal verifier identity mismatch`);
|
|
}
|
|
if (record.status !== "PASS" || record.failures.length !== 0) {
|
|
throw new Error(`${label} verification must be PASS without failures`);
|
|
}
|
|
}
|
|
if (
|
|
provider.vulnerabilityStatus !== "PASS" ||
|
|
provider.provenanceAttestationStatus !== "PASS"
|
|
) {
|
|
throw new Error("provider verification subordinate statuses must both be PASS");
|
|
}
|
|
if (promotion.localEvidenceStatus !== "PASS") {
|
|
throw new Error("promotion local evidence subordinate status must be PASS");
|
|
}
|
|
|
|
assertEqual("shared verifiedAt", provider.verifiedAt, promotion.verifiedAt);
|
|
assertEqual("shared run", provider.run, promotion.run);
|
|
assertEqual("shared source", provider.source, promotion.source);
|
|
assertEqual("shared candidate", provider.candidate, promotion.candidate);
|
|
assertEqual(
|
|
"shared provider evidence",
|
|
provider.providerEvidence,
|
|
promotion.providerEvidence,
|
|
);
|
|
assertEqual(
|
|
"shared trust policy",
|
|
provider.trustPolicySha256,
|
|
promotion.trustPolicySha256,
|
|
);
|
|
assertEqual("external expected run", provider.run, options.expected.run);
|
|
assertEqual(
|
|
"external expected source revision",
|
|
provider.source.revision,
|
|
options.expected.sourceRevision,
|
|
);
|
|
assertEqual(
|
|
"external expected archive digest",
|
|
provider.candidate.archiveSha256,
|
|
options.expected.archiveSha256,
|
|
);
|
|
for (const [label, actual, expected] of [
|
|
["source set", provider.source.sourceSetSha256, options.expected.sourceSetSha256],
|
|
["bundle", provider.candidate.bundleSha256, options.expected.bundleSha256],
|
|
["dist", provider.candidate.distSha256, options.expected.distSha256],
|
|
["lockfile", provider.candidate.lockfileSha256, options.expected.lockfileSha256],
|
|
] as const) {
|
|
if (expected !== undefined) {
|
|
assertEqual(`external expected ${label} digest`, actual, expected);
|
|
}
|
|
}
|
|
const anchoredTrustPolicySha256 = trustPolicySha256(options);
|
|
if (provider.trustPolicySha256 !== anchoredTrustPolicySha256) {
|
|
throw new Error("verification trust policy does not match anchored provider keys");
|
|
}
|
|
|
|
if (promotion.providerVerificationSha256 !== sha256(providerBytes)) {
|
|
throw new Error("promotion provider verification byte hash mismatch");
|
|
}
|
|
if (
|
|
provider.candidate.archiveSha256 !== sha256(archiveBytes) ||
|
|
provider.providerEvidence.vulnerabilityReportSha256 !== sha256(vulnerabilityBytes) ||
|
|
provider.providerEvidence.provenanceAttestationSha256 !== sha256(provenanceBytes)
|
|
) {
|
|
if (provider.candidate.archiveSha256 !== sha256(archiveBytes)) {
|
|
throw new Error("candidate archive actual digest mismatch");
|
|
}
|
|
if (
|
|
provider.providerEvidence.vulnerabilityReportSha256 !==
|
|
sha256(vulnerabilityBytes)
|
|
) {
|
|
throw new Error("vulnerability report actual digest mismatch");
|
|
}
|
|
throw new Error("provenance attestation actual digest mismatch");
|
|
}
|
|
|
|
for (const [label, evidence, nonce, keyId, fingerprint] of [
|
|
[
|
|
"vulnerability",
|
|
vulnerability,
|
|
provider.providerEvidence.vulnerabilityInvocationNonce,
|
|
provider.providerEvidence.vulnerabilityKeyId,
|
|
provider.providerEvidence.vulnerabilityKeyFingerprint,
|
|
],
|
|
[
|
|
"provenance",
|
|
provenance,
|
|
provider.providerEvidence.provenanceInvocationNonce,
|
|
provider.providerEvidence.provenanceKeyId,
|
|
provider.providerEvidence.provenanceKeyFingerprint,
|
|
],
|
|
] as const) {
|
|
assertEqual(`${label} run`, { id: evidence.run.id, attempt: evidence.run.attempt }, provider.run);
|
|
assertEqual(`${label} source`, evidence.source, provider.source);
|
|
assertEqual(`${label} candidate`, evidence.candidate, provider.candidate);
|
|
if (
|
|
evidence.run.invocationNonce !== nonce ||
|
|
evidence.signature.keyId !== keyId ||
|
|
evidence.signature.publicKeyFingerprint !== fingerprint
|
|
) {
|
|
throw new Error(`${label} provider evidence nonce or trust role mismatch`);
|
|
}
|
|
}
|
|
if (provenance.subject.digest.sha256 !== provider.candidate.distSha256) {
|
|
throw new Error("provenance subject dist digest mismatch");
|
|
}
|
|
if (vulnerability.findings.length !== 0) {
|
|
throw new Error("vulnerability report is not PASS");
|
|
}
|
|
assertEqual(
|
|
"signed secret scan attestation",
|
|
vulnerability.secretScanAttestation,
|
|
provider.providerEvidence.secretScanAttestation,
|
|
);
|
|
|
|
let assessmentSha256: string | null = null;
|
|
const localIdentityHolder: {
|
|
current: null | Readonly<{
|
|
sourceRevision: string;
|
|
sourceSetSha256: string;
|
|
assessmentSha256: string;
|
|
secretScan: Readonly<{
|
|
policySha256: string;
|
|
sarifSha256: string;
|
|
scanInputSha256: string;
|
|
}>;
|
|
}>;
|
|
} = { current: null };
|
|
await verifyCapturedCiCandidateArchive(
|
|
archiveBytes,
|
|
provider.candidate.archiveSha256,
|
|
{
|
|
verifyExtracted: async (extractionRoot, manifest) => {
|
|
assertEqual("archive candidate", {
|
|
bundleSha256: manifest.bundleSha256,
|
|
distSha256: manifest.distSha256,
|
|
lockfileSha256: manifest.lockfileSha256,
|
|
}, {
|
|
bundleSha256: provider.candidate.bundleSha256,
|
|
distSha256: provider.candidate.distSha256,
|
|
lockfileSha256: provider.candidate.lockfileSha256,
|
|
});
|
|
assessmentSha256 = sha256(
|
|
await readFile(path.join(extractionRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH)),
|
|
);
|
|
const local = await verifyArchivedLocalEvidence({
|
|
extractionRoot,
|
|
expectedManifest: manifest,
|
|
});
|
|
if (local.status !== "PASS" || !local.identity) {
|
|
throw new Error(
|
|
`exact-five archived local verification is not PASS: ${local.failures.join(", ")}`,
|
|
);
|
|
}
|
|
localIdentityHolder.current = local.identity;
|
|
},
|
|
},
|
|
);
|
|
if (assessmentSha256 !== promotion.localEvidenceAssessmentSha256) {
|
|
throw new Error("promotion local evidence assessment actual digest mismatch");
|
|
}
|
|
if (
|
|
!localIdentityHolder.current ||
|
|
localIdentityHolder.current.sourceRevision !== provider.source.revision ||
|
|
localIdentityHolder.current.sourceSetSha256 !== provider.source.sourceSetSha256 ||
|
|
localIdentityHolder.current.assessmentSha256 !== promotion.localEvidenceAssessmentSha256
|
|
) {
|
|
throw new Error("exact-five archived local identity mismatch");
|
|
}
|
|
assertEqual("archived secret scan attestation", {
|
|
status: "PASS",
|
|
localEvidenceAssessmentSha256: localIdentityHolder.current.assessmentSha256,
|
|
sourceSetSha256: localIdentityHolder.current.sourceSetSha256,
|
|
policySha256: localIdentityHolder.current.secretScan.policySha256,
|
|
sarifSha256: localIdentityHolder.current.secretScan.sarifSha256,
|
|
scanInputSha256: localIdentityHolder.current.secretScan.scanInputSha256,
|
|
}, vulnerability.secretScanAttestation);
|
|
const reevaluated = evaluatePromotionEvidence({
|
|
expected: {
|
|
run: provider.run,
|
|
source: provider.source,
|
|
candidate: provider.candidate,
|
|
vulnerabilityInvocationNonce:
|
|
provider.providerEvidence.vulnerabilityInvocationNonce,
|
|
provenanceInvocationNonce:
|
|
provider.providerEvidence.provenanceInvocationNonce,
|
|
secretScanAttestation: provider.providerEvidence.secretScanAttestation,
|
|
},
|
|
localStatus: "PASS",
|
|
vulnerabilityReport: vulnerability,
|
|
provenanceAttestation: provenance,
|
|
vulnerabilityTrust: options.vulnerabilityTrust,
|
|
provenanceTrust: options.provenanceTrust,
|
|
nowEpochMs: options.nowEpochMs,
|
|
});
|
|
if (
|
|
reevaluated.status !== "PASS" ||
|
|
reevaluated.vulnerabilityStatus !== "PASS" ||
|
|
reevaluated.provenanceAttestationStatus !== "PASS"
|
|
) {
|
|
throw new Error(
|
|
`exact-five provider signature/freshness revalidation is not PASS: ${reevaluated.failures.join(", ")}`,
|
|
);
|
|
}
|
|
return Object.freeze({ status: "PASS" as const });
|
|
}
|
|
|
|
function assertExternalExpectedContext(
|
|
expected: ExactPromotionExpectedContext,
|
|
): void {
|
|
if (
|
|
!expected ||
|
|
typeof expected.run?.id !== "string" ||
|
|
expected.run.id.length === 0 ||
|
|
!Number.isSafeInteger(expected.run.attempt) ||
|
|
expected.run.attempt < 1 ||
|
|
!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(expected.sourceRevision) ||
|
|
!isSha256(expected.archiveSha256)
|
|
) {
|
|
throw new TypeError("external expected promotion context is invalid or incomplete");
|
|
}
|
|
for (const digest of [
|
|
expected.sourceSetSha256,
|
|
expected.bundleSha256,
|
|
expected.distSha256,
|
|
expected.lockfileSha256,
|
|
]) {
|
|
if (digest !== undefined && !isSha256(digest)) {
|
|
throw new TypeError("external optional expected promotion digest is invalid");
|
|
}
|
|
}
|
|
}
|
|
|
|
function isSha256(value: unknown): value is string {
|
|
return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
|
|
}
|
|
|
|
function parseJson(bytes: Buffer, label: string): unknown {
|
|
try {
|
|
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
|
|
} catch {
|
|
throw new TypeError(`${label} is not strict UTF-8 JSON`);
|
|
}
|
|
}
|
|
|
|
function assertEqual(label: string, left: unknown, right: unknown): void {
|
|
if (JSON.stringify(left) !== JSON.stringify(right)) {
|
|
throw new Error(`${label} mismatch`);
|
|
}
|
|
}
|
|
|
|
function sha256(bytes: Buffer): string {
|
|
return createHash("sha256").update(bytes).digest("hex");
|
|
}
|
|
|
|
function asciiCompare(left: string, right: string): number {
|
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
}
|