From 8050aa8c008ef2802bf469a5d510e0d0d1b2796c Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:36:34 -0400 Subject: [PATCH 1/5] feat(deployment): check AMD CRL revocation in attestation validation Attestation validation proved AMD SEV-SNP hardware genuine and nonce-bound but never checked AMD's CRL, so a genuine-but-revoked signing key still read as valid. Add a node:crypto-only CRL parser (manual ASN.1 walk; node has no CRL type) that verifies the ARK-signed CRL (RSA-PSS or ECDSA), enforces nextUpdate freshness, and reports whether the ASK or ARK serial is revoked. The SEV-SNP verifier now fetches the per-product CRL from KDS and resolves: revoked to invalid, fresh and clear to valid, and unretrievable/unverifiable/stale to unverifiable (revocation unknown, never a silent pass). It populates the reserved notRevoked verdict check and drops the "Revocation not checked" detail. Refs CON-596 --- .../services/amd-snp/amd-crl.parser.spec.ts | 145 +++++++++++++++ .../services/amd-snp/amd-crl.parser.ts | 176 ++++++++++++++++++ .../services/amd-snp/amd-kds.client.ts | 26 +++ .../services/amd-snp/amd-snp.service.spec.ts | 85 ++++++++- .../services/amd-snp/amd-snp.service.ts | 90 +++++++-- 5 files changed, 501 insertions(+), 21 deletions(-) create mode 100644 apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.spec.ts create mode 100644 apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.ts diff --git a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.spec.ts b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.spec.ts new file mode 100644 index 0000000..861dcb2 --- /dev/null +++ b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.spec.ts @@ -0,0 +1,145 @@ +import { execFileSync } from "node:child_process"; +import { X509Certificate } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { AmdCrlParseError, isCertRevoked, isCrlExpired, normalizeSerial, parseAmdCrl, verifyCrlSignature } from "./amd-crl.parser"; + +// A real RSA ARK→ASK chain plus RSA-PSS CRLs (clean and one revoking the ASK), generated once via +// openssl to mirror AMD KDS — which issues RSA-4096 ARKs and signs its CRLs with RSA-PSS/SHA-384. +// Hermetic: no committed keys, no network. +let fixtures: ReturnType; + +beforeAll(() => { + fixtures = generateCrlFixtures(); +}); + +afterAll(() => { + if (fixtures) rmSync(fixtures.dir, { recursive: true, force: true }); +}); + +describe(parseAmdCrl.name, () => { + it("parses nextUpdate and an empty revoked list from a clean CRL", () => { + const crl = parseAmdCrl(fixtures.cleanCrlDer); + + expect(crl.revokedSerials.size).toBe(0); + expect(crl.nextUpdate).toBeInstanceOf(Date); + expect((crl.nextUpdate as Date).getTime()).toBeGreaterThan(Date.now()); + }); + + it("extracts the revoked serial number from a CRL with revocations", () => { + const crl = parseAmdCrl(fixtures.revokedCrlDer); + + expect(crl.revokedSerials.has(normalizeSerial(fixtures.askCert.serialNumber))).toBe(true); + }); + + it("throws AmdCrlParseError for bytes that are not a CRL", () => { + expect(() => parseAmdCrl(Buffer.from("clearly not a DER-encoded CRL"))).toThrow(AmdCrlParseError); + }); +}); + +describe(verifyCrlSignature.name, () => { + it("verifies an RSA-PSS CRL signed by the issuing ARK", () => { + const crl = parseAmdCrl(fixtures.cleanCrlDer); + + expect(verifyCrlSignature(crl, fixtures.arkCert)).toBe(true); + }); + + it("rejects a CRL when verified against a certificate that did not sign it", () => { + const crl = parseAmdCrl(fixtures.cleanCrlDer); + + expect(verifyCrlSignature(crl, fixtures.askCert)).toBe(false); + }); + + it("rejects a CRL whose signed content was tampered with", () => { + const crl = parseAmdCrl(fixtures.cleanCrlDer); + const tampered = { ...crl, tbsDer: Buffer.from(crl.tbsDer) }; + tampered.tbsDer[20] ^= 0xff; + + expect(verifyCrlSignature(tampered, fixtures.arkCert)).toBe(false); + }); +}); + +describe(isCertRevoked.name, () => { + it("returns true when the certificate serial is listed as revoked", () => { + const crl = parseAmdCrl(fixtures.revokedCrlDer); + + expect(isCertRevoked(crl, fixtures.askCert)).toBe(true); + }); + + it("returns false when the certificate serial is not listed", () => { + const crl = parseAmdCrl(fixtures.cleanCrlDer); + + expect(isCertRevoked(crl, fixtures.askCert)).toBe(false); + }); +}); + +describe(normalizeSerial.name, () => { + it("strips a leading sign byte and uppercases", () => { + expect(normalizeSerial("00a1b2")).toBe("A1B2"); + }); + + it("treats serials that differ only by leading zeros as equal", () => { + expect(normalizeSerial("0010001")).toBe(normalizeSerial("10001")); + }); + + it("preserves an all-zero serial", () => { + expect(normalizeSerial("00")).toBe("0"); + }); +}); + +describe(isCrlExpired.name, () => { + it("returns true once now is at or past nextUpdate", () => { + const crl = parseAmdCrl(fixtures.cleanCrlDer); + + expect(isCrlExpired(crl, new Date((crl.nextUpdate as Date).getTime() + 1000))).toBe(true); + }); + + it("returns false while the CRL is still current", () => { + const crl = parseAmdCrl(fixtures.cleanCrlDer); + + expect(isCrlExpired(crl, new Date((crl.nextUpdate as Date).getTime() - 1000))).toBe(false); + }); +}); + +function generateCrlFixtures() { + const dir = mkdtempSync(join(tmpdir(), "amd-crl-")); + const ossl = (args: string[]) => execFileSync("openssl", args, { cwd: dir, stdio: ["ignore", "ignore", "pipe"] }); + const rsaKey = (name: string) => ossl(["genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", name]); + + rsaKey("ark.key"); + ossl(["req", "-new", "-x509", "-key", "ark.key", "-out", "ark.pem", "-days", "2", "-subj", "/CN=ARK-Milan", "-sha384"]); + rsaKey("ask.key"); + ossl(["req", "-new", "-key", "ask.key", "-out", "ask.csr", "-subj", "/CN=SEV-Milan", "-sha384"]); + ossl(["x509", "-req", "-in", "ask.csr", "-CA", "ark.pem", "-CAkey", "ark.key", "-CAcreateserial", "-out", "ask.pem", "-days", "2", "-sha384"]); + + const arkCert = new X509Certificate(readFileSync(join(dir, "ark.pem"))); + const askCert = new X509Certificate(readFileSync(join(dir, "ask.pem"))); + + // Minimal CA scaffold so `openssl ca -gencrl` can emit a CRL signed by the ARK. + writeFileSync( + join(dir, "ca.cnf"), + ["[ca]", "default_ca = myca", "[myca]", "database = index.txt", "crlnumber = crlnumber", "certificate = ark.pem", "private_key = ark.key", "default_md = sha384", "default_crl_days = 30", ""].join("\n") + ); + // AMD signs CRLs with RSA-PSS; force the same here so the parser is exercised against the real algorithm. + const gencrl = (out: string) => ossl(["ca", "-config", "ca.cnf", "-gencrl", "-out", out, "-sigopt", "rsa_padding_mode:pss", "-sigopt", "rsa_pss_saltlen:digest"]); + const toDer = (pem: string, der: string) => { + ossl(["crl", "-in", pem, "-outform", "DER", "-out", der]); + return readFileSync(join(dir, der)); + }; + + writeFileSync(join(dir, "index.txt"), ""); + writeFileSync(join(dir, "crlnumber"), "1000\n"); + gencrl("crl_clean.pem"); + const cleanCrlDer = toDer("crl_clean.pem", "crl_clean.der"); + + // An index entry marks the ASK serial revoked; openssl emits it into the regenerated CRL. + writeFileSync(join(dir, "index.txt"), `R\t350101000000Z\t240101000000Z\t${askCert.serialNumber}\tunknown\t/CN=SEV-Milan\n`); + writeFileSync(join(dir, "crlnumber"), "1001\n"); + gencrl("crl_revoked.pem"); + const revokedCrlDer = toDer("crl_revoked.pem", "crl_revoked.der"); + + return { dir, arkCert, askCert, cleanCrlDer, revokedCrlDer }; +} diff --git a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.ts b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.ts new file mode 100644 index 0000000..f4be360 --- /dev/null +++ b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.ts @@ -0,0 +1,176 @@ +import crypto from "node:crypto"; + +/** + * Parses and verifies the AMD KDS Certificate Revocation List (CRL). + * + * AMD publishes one CRL per product at `/vcek/v1/{product}/crl` (DER-encoded), issued and signed by + * that product's ARK (root). It revokes intermediate (ASK) certificates by serial number — revoking + * an ASK withdraws AMD's trust from every VCEK, and therefore every chip endorsement, beneath it. + * + * `node:crypto` exposes no CRL type, so we walk the `CertificateList` structure (RFC 5280 §5.1) by + * hand: capture the signed `tbsCertList` bytes, the signature, `nextUpdate`, and the revoked serials, + * then verify the signature with `node:crypto`. AMD signs these CRLs with RSA-PSS/SHA-384 (the ARK is + * RSA-4096); {@link verifyCrlSignature} also accepts an ECDSA issuer so test fixtures need not be RSA. + */ + +export interface ParsedAmdCrl { + /** DER bytes of `tbsCertList` — exactly the region the signature covers. */ + readonly tbsDer: Buffer; + /** Raw signature bytes (BIT STRING contents with the unused-bits octet stripped). */ + readonly signature: Buffer; + /** `nextUpdate`, after which the CRL is stale; `null` when the (optional) field is absent. */ + readonly nextUpdate: Date | null; + /** Revoked certificate serial numbers, canonicalised via {@link normalizeSerial}. */ + readonly revokedSerials: ReadonlySet; +} + +export class AmdCrlParseError extends Error { + constructor(message: string) { + super(message); + this.name = "AmdCrlParseError"; + } +} + +const TAG = { + INTEGER: 0x02, + BIT_STRING: 0x03, + SEQUENCE: 0x30, + UTC_TIME: 0x17, + GENERALIZED_TIME: 0x18 +} as const; + +interface Tlv { + readonly tag: number; + readonly contentStart: number; + readonly contentEnd: number; + /** Offset of the byte after this element — i.e. the start of its sibling. */ + readonly end: number; +} + +/** Reads one DER tag-length-value element. AMD CRLs use only low-tag-number, definite-length forms. */ +function readTlv(der: Buffer, offset: number): Tlv { + if (offset + 2 > der.length) throw new AmdCrlParseError("Truncated DER element"); + const tag = der[offset]; + let length = der[offset + 1]; + let pos = offset + 2; + if (length & 0x80) { + const numBytes = length & 0x7f; + if (numBytes === 0 || numBytes > 4) throw new AmdCrlParseError("Unsupported DER length encoding"); + length = 0; + for (let i = 0; i < numBytes; i++) { + if (pos >= der.length) throw new AmdCrlParseError("Truncated DER length"); + length = length * 256 + der[pos++]; + } + } + const contentEnd = pos + length; + if (contentEnd > der.length) throw new AmdCrlParseError("DER element exceeds buffer"); + return { tag, contentStart: pos, contentEnd, end: contentEnd }; +} + +/** Canonicalises a serial (hex) for comparison: uppercase, leading zeros stripped (sign byte / padding). */ +export function normalizeSerial(hex: string): string { + const trimmed = hex.toUpperCase().replace(/^0+/, ""); + return trimmed === "" ? "0" : trimmed; +} + +function parseAsn1Time(tag: number, content: Buffer): Date { + const text = content.toString("latin1").trim(); + const isUtc = tag === TAG.UTC_TIME; + let pos = isUtc ? 2 : 4; + const twoDigitYear = Number(text.slice(0, 2)); + // RFC 5280: UTCTime years 00–49 map to 2000–2049, 50–99 to 1950–1999. + const year = isUtc ? (twoDigitYear < 50 ? 2000 + twoDigitYear : 1900 + twoDigitYear) : Number(text.slice(0, 4)); + const read2 = () => { + const value = Number(text.slice(pos, pos + 2)); + pos += 2; + return value; + }; + const month = read2(); + const day = read2(); + const hour = read2(); + const minute = read2(); + const second = Number(text.slice(pos, pos + 2)) || 0; + return new Date(Date.UTC(year, month - 1, day, hour, minute, second)); +} + +/** + * Parses a DER-encoded AMD CRL. Walks `tbsCertList` positionally (RFC 5280 §5.1.2): optional version, + * signature AlgorithmIdentifier, issuer, thisUpdate, optional nextUpdate, optional revokedCertificates. + */ +export function parseAmdCrl(der: Buffer): ParsedAmdCrl { + const certificateList = readTlv(der, 0); + if (certificateList.tag !== TAG.SEQUENCE) throw new AmdCrlParseError("CRL is not a SEQUENCE"); + + const tbs = readTlv(der, certificateList.contentStart); + if (tbs.tag !== TAG.SEQUENCE) throw new AmdCrlParseError("tbsCertList is not a SEQUENCE"); + const tbsDer = der.subarray(certificateList.contentStart, tbs.end); + + const signatureAlgorithm = readTlv(der, tbs.end); + const signatureValue = readTlv(der, signatureAlgorithm.end); + if (signatureValue.tag !== TAG.BIT_STRING) throw new AmdCrlParseError("Missing CRL signature"); + // A BIT STRING's first content octet counts unused trailing bits (0 for a byte-aligned signature). + const signature = der.subarray(signatureValue.contentStart + 1, signatureValue.contentEnd); + + let cursor = tbs.contentStart; + const next = (): Tlv | null => (cursor < tbs.contentEnd ? readTlv(der, cursor) : null); + const skip = (element: Tlv | null): Tlv | null => { + if (!element) return null; + cursor = element.end; + return next(); + }; + + let field = next(); + if (field && field.tag === TAG.INTEGER) field = skip(field); // optional version + field = skip(field); // signature AlgorithmIdentifier + field = skip(field); // issuer + field = skip(field); // thisUpdate + + let nextUpdate: Date | null = null; + if (field && (field.tag === TAG.UTC_TIME || field.tag === TAG.GENERALIZED_TIME)) { + nextUpdate = parseAsn1Time(field.tag, der.subarray(field.contentStart, field.contentEnd)); + field = skip(field); + } + + const revokedSerials = new Set(); + // The next element is `revokedCertificates` only when it is a SEQUENCE; otherwise it is the absent + // (empty CRL) case or the `[0]` crlExtensions, neither of which we read. + if (field && field.tag === TAG.SEQUENCE) { + let entryPos = field.contentStart; + while (entryPos < field.contentEnd) { + const entry = readTlv(der, entryPos); + const serial = readTlv(der, entry.contentStart); // userCertificate INTEGER (first field) + revokedSerials.add(normalizeSerial(der.subarray(serial.contentStart, serial.contentEnd).toString("hex"))); + entryPos = entry.end; + } + } + + return { tbsDer, signature, nextUpdate, revokedSerials }; +} + +/** + * Verifies the CRL signature against the issuer (the ARK). AMD uses RSA-PSS/SHA-384; an ECDSA issuer + * is also accepted (its signature is the DER form node expects by default). Any error → `false`, so an + * unverifiable CRL is never trusted. + */ +export function verifyCrlSignature(crl: ParsedAmdCrl, issuer: crypto.X509Certificate): boolean { + try { + const key = issuer.publicKey; + const options = + key.asymmetricKeyType === "rsa" + ? { key, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: crypto.constants.RSA_PSS_SALTLEN_AUTO } + : { key }; + return crypto.verify("sha384", crl.tbsDer, options, crl.signature); + } catch { + return false; + } +} + +/** True when the CRL lists the certificate's serial number as revoked. */ +export function isCertRevoked(crl: ParsedAmdCrl, cert: crypto.X509Certificate): boolean { + return crl.revokedSerials.has(normalizeSerial(cert.serialNumber)); +} + +/** True when `now` is at or past the CRL's `nextUpdate` (a CRL without `nextUpdate` never expires). */ +export function isCrlExpired(crl: ParsedAmdCrl, now: Date): boolean { + return crl.nextUpdate !== null && now.getTime() >= crl.nextUpdate.getTime(); +} diff --git a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-kds.client.ts b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-kds.client.ts index 487cd2b..68d3082 100644 --- a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-kds.client.ts +++ b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-kds.client.ts @@ -11,6 +11,8 @@ export interface AmdCaChain { } const CA_CHAIN_TTL_MS = 24 * 60 * 60 * 1000; +// CRLs rotate more often than the CA chain; the verifier additionally enforces the CRL's own nextUpdate. +const CRL_TTL_MS = 6 * 60 * 60 * 1000; /** * Thin client over the AMD Key Distribution Service (https://kdsintf.amd.com). It fetches the per-chip VCEK @@ -22,6 +24,7 @@ const CA_CHAIN_TTL_MS = 24 * 60 * 60 * 1000; */ export class AmdKdsClient { readonly #caChainCache = new Map(); + readonly #crlCache = new Map(); constructor( private readonly httpClient: HttpClient, @@ -65,10 +68,33 @@ export class AmdKdsClient { } } + /** + * Fetches the DER-encoded CRL for a product (cached). Returns `null` when the product has no CRL + * (404); transport errors propagate so the verifier treats revocation status as unknown. + */ + async getCrl(product: string): Promise { + const cached = this.#crlCache.get(product); + if (cached && cached.expiresAt > Date.now()) return cached.value; + + try { + const response = await this.httpClient.get(`/vcek/v1/${product}/crl`, { responseType: "arraybuffer" }); + return this.#cacheCrl(product, Buffer.from(response.data)); + } catch (error) { + if (isAxiosError(error) && error.response?.status === 404) return this.#cacheCrl(product, null); + this.logger.error({ event: "AMD_KDS_CRL_FETCH_FAILED", product, error }); + throw error; + } + } + #cacheCaChain(product: string, value: AmdCaChain | null): AmdCaChain | null { this.#caChainCache.set(product, { value, expiresAt: Date.now() + CA_CHAIN_TTL_MS }); return value; } + + #cacheCrl(product: string, value: Buffer | null): Buffer | null { + this.#crlCache.set(product, { value, expiresAt: Date.now() + CRL_TTL_MS }); + return value; + } } /** Splits a concatenated PEM blob into individual `-----BEGIN CERTIFICATE-----...` strings. */ diff --git a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.spec.ts b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.spec.ts index 6436f34..56207f3 100644 --- a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.spec.ts +++ b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.spec.ts @@ -1,7 +1,7 @@ import type { LoggerService } from "@akashnetwork/logging"; import { execFileSync } from "node:child_process"; import crypto, { X509Certificate } from "node:crypto"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -15,9 +15,15 @@ import { SNP_REPORT_MIN_LENGTH } from "./snp-report.parser"; // A real P-384 ARK→ASK→VCEK chain generated once via openssl, plus the VCEK private key used to sign reports. // Generating it at test time keeps the suite hermetic (no committed keys, no network). let chain: ReturnType; +// CRLs signed by the chain's ARK: one clean, one revoking the ASK. Drive the revocation-check paths. +let cleanCrlDer: Buffer; +let revokedCrlDer: Buffer; beforeAll(() => { chain = generateP384Chain(); + const askSerial = new X509Certificate(chain.askPem).serialNumber; + cleanCrlDer = generateCrl(chain.dir, []); + revokedCrlDer = generateCrl(chain.dir, [askSerial]); }); afterAll(() => { @@ -33,7 +39,8 @@ describe(AmdSnpService.name, () => { const verdict = await service.verify({ report: report.toString("base64"), certChain: "", nonce: nonce.toString("base64") }); expect(verdict).toMatchObject({ kind: "cpu", vendor: "amd-sev-snp", status: "valid" }); - expect(verdict.checks).toEqual({ certChainValid: true, signatureValid: true, nonceMatch: true }); + expect(verdict.checks).toEqual({ certChainValid: true, signatureValid: true, nonceMatch: true, notRevoked: true }); + expect(verdict.detail).not.toMatch(/revocation not checked/i); }); it("returns invalid when the report signature does not verify", async () => { @@ -102,11 +109,48 @@ describe(AmdSnpService.name, () => { expect(kdsClient.getCaChain).not.toHaveBeenCalled(); }); - function setup(input: { withKds: boolean; products?: string[]; resolvingProduct?: string }) { + it("returns invalid when AMD has revoked the signing key in the chain", async () => { + const nonce = crypto.randomBytes(64); + const report = buildSignedReport(nonce); + const { service } = setup({ withKds: true, crl: "revoked" }); + + const verdict = await service.verify({ report: report.toString("base64"), certChain: "", nonce: nonce.toString("base64") }); + + expect(verdict.status).toBe("invalid"); + expect(verdict.checks?.notRevoked).toBe(false); + }); + + it("returns unverifiable when the AMD CRL cannot be retrieved", async () => { + const nonce = crypto.randomBytes(64); + const report = buildSignedReport(nonce); + const { service } = setup({ withKds: true, crl: "missing" }); + + const verdict = await service.verify({ report: report.toString("base64"), certChain: "", nonce: nonce.toString("base64") }); + + expect(verdict.status).toBe("unverifiable"); + expect(verdict.checks?.notRevoked).toBeUndefined(); + }); + + it("returns unverifiable when fetching the AMD CRL throws", async () => { + const nonce = crypto.randomBytes(64); + const report = buildSignedReport(nonce); + const { service } = setup({ withKds: true, crl: "error" }); + + const verdict = await service.verify({ report: report.toString("base64"), certChain: "", nonce: nonce.toString("base64") }); + + expect(verdict.status).toBe("unverifiable"); + }); + + function setup(input: { withKds: boolean; products?: string[]; resolvingProduct?: string; crl?: "clean" | "revoked" | "missing" | "error" }) { const kdsClient = mock(); const resolving = input.resolvingProduct ?? "Genoa"; kdsClient.getCaChain.mockImplementation(async product => (input.withKds && product === resolving ? { ask: chain.askPem, ark: chain.arkPem } : null)); kdsClient.getVcek.mockImplementation(async product => (input.withKds && product === resolving ? chain.vcekDer : null)); + kdsClient.getCrl.mockImplementation(async () => { + if (input.crl === "missing") return null; + if (input.crl === "error") throw new Error("AMD KDS unavailable"); + return input.crl === "revoked" ? revokedCrlDer : cleanCrlDer; + }); const config = { ...envSchema.parse({}), AMD_SNP_PRODUCTS: input.products ?? [resolving] }; const service = new AmdSnpService(kdsClient, config, mock()); @@ -143,10 +187,11 @@ function generateP384Chain() { }; const read = (name: string) => readFileSync(join(dir, name), "utf-8"); + // CN mirrors the real AMD ARK (`ARK-`) so the service can derive the product for CRL lookup. genKey("ark"); - selfSign("ark", "ARK"); + selfSign("ark", "ARK-Milan"); genKey("ask"); - caSign("ask", "ASK", "ark"); + caSign("ask", "SEV-Milan", "ark"); genKey("vcek"); caSign("vcek", "VCEK", "ask"); @@ -159,3 +204,33 @@ function generateP384Chain() { vcekDer: Buffer.from(new X509Certificate(read("vcek.pem")).raw) }; } + +/** Generates a DER CRL signed by the chain's ARK (in `chainDir`), revoking the given serials (empty = clean). */ +function generateCrl(chainDir: string, revokedSerials: string[]): Buffer { + const dir = mkdtempSync(join(tmpdir(), "amd-crl-")); + const ossl = (args: string[]) => execFileSync("openssl", args, { cwd: dir, stdio: ["ignore", "ignore", "pipe"] }); + + const entries = revokedSerials.map(serial => `R\t350101000000Z\t240101000000Z\t${serial}\tunknown\t/CN=SEV-Milan\n`).join(""); + writeFileSync(join(dir, "index.txt"), entries); + writeFileSync(join(dir, "crlnumber"), "1000\n"); + writeFileSync( + join(dir, "ca.cnf"), + [ + "[ca]", + "default_ca = myca", + "[myca]", + `database = ${join(dir, "index.txt")}`, + `crlnumber = ${join(dir, "crlnumber")}`, + `certificate = ${join(chainDir, "ark.pem")}`, + `private_key = ${join(chainDir, "ark.key")}`, + "default_md = sha384", + "default_crl_days = 30", + "" + ].join("\n") + ); + ossl(["ca", "-config", "ca.cnf", "-gencrl", "-out", "crl.pem"]); + ossl(["crl", "-in", "crl.pem", "-outform", "DER", "-out", "crl.der"]); + const der = readFileSync(join(dir, "crl.der")); + rmSync(dir, { recursive: true, force: true }); + return der; +} diff --git a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.ts b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.ts index 1f15605..b3cc74c 100644 --- a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.ts +++ b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.ts @@ -3,6 +3,7 @@ import crypto from "node:crypto"; import type { ConfidentialComputeConfig } from "../../config/env.config"; import type { CpuReportVerdict } from "../../http-schemas/attestation.schema"; +import { isCertRevoked, isCrlExpired, parseAmdCrl, verifyCrlSignature } from "./amd-crl.parser"; import type { AmdKdsClient } from "./amd-kds.client"; import { splitPemChain } from "./amd-kds.client"; import type { ParsedSnpReport } from "./snp-report.parser"; @@ -12,13 +13,15 @@ interface SnpCertChain { vcek: crypto.X509Certificate; ask: crypto.X509Certificate; ark: crypto.X509Certificate; + /** AMD product the chain belongs to, used to fetch the matching CRL; `null` when undeterminable. */ + product: string | null; } /** - * Verifies AMD SEV-SNP CPU attestation reports (authenticity-only): the report is signed by a VCEK that - * chains ARK→ASK→VCEK to the AMD root, and its `report_data` is bound to the tenant nonce. Workload - * measurement is NOT compared. Revocation (CRL) is NOT checked — `node:crypto` has no CRL support — so a - * revoked-but-otherwise-genuine chip still reads `valid`; this is surfaced in the verdict detail. + * Verifies AMD SEV-SNP CPU attestation reports: the report is signed by a VCEK that chains + * ARK→ASK→VCEK to the AMD root, its `report_data` is bound to the tenant nonce, and the signing key is + * not revoked by AMD's published CRL. Workload measurement is NOT compared. When the CRL cannot be + * retrieved or verified the report is `unverifiable` (revocation unknown) rather than `valid`. * * Ported from apps/api with tsyringe DI replaced by constructor injection. */ @@ -52,21 +55,39 @@ export class AmdSnpService { const certChainValid = this.#verifyChain(chain); const signatureValid = certChainValid && this.#verifySignature(parsed, chain.vcek); const nonceMatch = this.#verifyNonce(parsed, input.nonce); - const checks = { certChainValid, signatureValid, nonceMatch }; + const checks: NonNullable = { certChainValid, signatureValid, nonceMatch }; + + if (!certChainValid || !signatureValid || !nonceMatch) { + const reasons: string[] = []; + if (!certChainValid) reasons.push("VCEK does not chain to the AMD root"); + if (!signatureValid) reasons.push("report signature did not verify"); + if (!nonceMatch) reasons.push("report is not bound to the request nonce"); + return this.#verdict("invalid", `SEV-SNP verification failed: ${reasons.join("; ")}.`, checks); + } + + // Authenticity is established; evaluate AMD revocation status before declaring the report valid. + const revocation = await this.#checkRevocation(chain); + if (revocation !== "unknown") checks.notRevoked = revocation === "not-revoked"; - if (certChainValid && signatureValid && nonceMatch) { + if (revocation === "revoked") { return this.#verdict( - "valid", - "Genuine AMD SEV-SNP hardware: VCEK chains to the AMD root, report signature and nonce verified. Revocation not checked.", + "invalid", + "AMD SEV-SNP signing key revoked: the chip is genuine and nonce-bound, but AMD has revoked the signing key in its certificate chain, withdrawing trust in the hardware.", checks ); } - - const reasons: string[] = []; - if (!certChainValid) reasons.push("VCEK does not chain to the AMD root"); - if (!signatureValid) reasons.push("report signature did not verify"); - if (!nonceMatch) reasons.push("report is not bound to the request nonce"); - return this.#verdict("invalid", `SEV-SNP verification failed: ${reasons.join("; ")}.`, checks); + if (revocation === "unknown") { + return this.#verdict( + "unverifiable", + "Genuine AMD SEV-SNP hardware (VCEK chains to the AMD root, report signature and nonce verified), but revocation status is unknown: AMD's CRL could not be retrieved or verified.", + checks + ); + } + return this.#verdict( + "valid", + "Genuine AMD SEV-SNP hardware: VCEK chains to the AMD root, report signature and nonce verified, and the signing key is not revoked by AMD's CRL.", + checks + ); } async #resolveChain(parsed: ParsedSnpReport, certChainB64: string): Promise { @@ -84,7 +105,8 @@ export class AmdSnpService { return { vcek: new crypto.X509Certificate(vcekDer), ask: new crypto.X509Certificate(caChain.ask), - ark: new crypto.X509Certificate(caChain.ark) + ark: new crypto.X509Certificate(caChain.ark), + product }; } return null; @@ -98,13 +120,49 @@ export class AmdSnpService { const ask = ark && certs.find(cert => cert !== ark && safeVerify(cert, ark.publicKey)); const vcek = ask && certs.find(cert => cert !== ark && cert !== ask && safeVerify(cert, ask.publicKey)); if (!ark || !ask || !vcek) return null; - return { vcek, ask, ark }; + return { vcek, ask, ark, product: this.#deriveProduct(ark) }; } catch (error) { this.logger.error({ event: "AMD_SNP_EMBEDDED_CHAIN_PARSE_FAILED", error }); return null; } } + /** Best-effort product from the ARK subject CN (`CN=ARK-Milan` → `Milan`), normalised to config spelling. */ + #deriveProduct(ark: crypto.X509Certificate): string | null { + const match = ark.subject.match(/CN=ARK-([A-Za-z0-9]+)/i); + if (!match) return null; + const product = match[1]; + return this.config.AMD_SNP_PRODUCTS.find(candidate => candidate.toLowerCase() === product.toLowerCase()) ?? product; + } + + /** + * Checks the chain against AMD's published CRL. Returns `revoked` when the ASK or ARK serial is + * listed, `not-revoked` when a fresh, ARK-signed CRL omits them, and `unknown` (→ `unverifiable`) + * when the CRL can't be fetched, parsed, verified, or is stale — never a silent pass. + */ + async #checkRevocation(chain: SnpCertChain): Promise<"not-revoked" | "revoked" | "unknown"> { + if (!chain.product) return "unknown"; + + let der: Buffer | null; + try { + der = await this.kdsClient.getCrl(chain.product); + } catch (error) { + this.logger.error({ event: "AMD_SNP_CRL_FETCH_FAILED", product: chain.product, error }); + return "unknown"; + } + if (!der) return "unknown"; + + try { + const crl = parseAmdCrl(der); + if (isCrlExpired(crl, new Date())) return "unknown"; + if (!verifyCrlSignature(crl, chain.ark)) return "unknown"; + return isCertRevoked(crl, chain.ask) || isCertRevoked(crl, chain.ark) ? "revoked" : "not-revoked"; + } catch (error) { + this.logger.error({ event: "AMD_SNP_CRL_PARSE_FAILED", product: chain.product, error }); + return "unknown"; + } + } + #verifyChain({ vcek, ask, ark }: SnpCertChain): boolean { return safeVerify(ark, ark.publicKey) && safeVerify(ask, ark.publicKey) && safeVerify(vcek, ask.publicKey); } From 6bed4db3e56fcd84e26ab669ef2cbab16b7302b0 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:33:32 -0400 Subject: [PATCH 2/5] fix(deployment): fail closed on missing CRL freshness; assert CRL product wiring Address CodeRabbit review on the AMD CRL revocation path: - parseAsn1Time now rejects malformed times and isCrlExpired treats a missing or invalid nextUpdate as expired, so a CRL with no usable freshness bound is unverifiable rather than trusted as current. - The amd-snp.service.spec getCrl mock is product-sensitive, so the revocation tests actually exercise the product-derivation wiring. Refs CON-596 --- .../services/amd-snp/amd-crl.parser.spec.ts | 6 ++++ .../services/amd-snp/amd-crl.parser.ts | 30 ++++++++----------- .../services/amd-snp/amd-snp.service.spec.ts | 6 ++-- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.spec.ts b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.spec.ts index 861dcb2..4b619f1 100644 --- a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.spec.ts +++ b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.spec.ts @@ -102,6 +102,12 @@ describe(isCrlExpired.name, () => { expect(isCrlExpired(crl, new Date((crl.nextUpdate as Date).getTime() - 1000))).toBe(false); }); + + it("treats a CRL with no freshness bound as expired (fails closed)", () => { + const crl = parseAmdCrl(fixtures.cleanCrlDer); + + expect(isCrlExpired({ ...crl, nextUpdate: null }, new Date())).toBe(true); + }); }); function generateCrlFixtures() { diff --git a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.ts b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.ts index f4be360..dac39b6 100644 --- a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.ts +++ b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.ts @@ -74,23 +74,16 @@ export function normalizeSerial(hex: string): string { } function parseAsn1Time(tag: number, content: Buffer): Date { - const text = content.toString("latin1").trim(); const isUtc = tag === TAG.UTC_TIME; - let pos = isUtc ? 2 : 4; - const twoDigitYear = Number(text.slice(0, 2)); + // RFC 5280 requires the fully-specified Zulu form: YYMMDDHHMMSSZ / YYYYMMDDHHMMSSZ. Reject anything + // else so a malformed time fails closed (the caller treats a missing freshness bound as unverifiable). + const pattern = isUtc ? /^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/ : /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/; + const match = pattern.exec(content.toString("latin1")); + if (!match) throw new AmdCrlParseError("Malformed CRL time"); + const rawYear = Number(match[1]); // RFC 5280: UTCTime years 00–49 map to 2000–2049, 50–99 to 1950–1999. - const year = isUtc ? (twoDigitYear < 50 ? 2000 + twoDigitYear : 1900 + twoDigitYear) : Number(text.slice(0, 4)); - const read2 = () => { - const value = Number(text.slice(pos, pos + 2)); - pos += 2; - return value; - }; - const month = read2(); - const day = read2(); - const hour = read2(); - const minute = read2(); - const second = Number(text.slice(pos, pos + 2)) || 0; - return new Date(Date.UTC(year, month - 1, day, hour, minute, second)); + const year = isUtc ? (rawYear < 50 ? 2000 + rawYear : 1900 + rawYear) : rawYear; + return new Date(Date.UTC(year, Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]), Number(match[6]))); } /** @@ -170,7 +163,10 @@ export function isCertRevoked(crl: ParsedAmdCrl, cert: crypto.X509Certificate): return crl.revokedSerials.has(normalizeSerial(cert.serialNumber)); } -/** True when `now` is at or past the CRL's `nextUpdate` (a CRL without `nextUpdate` never expires). */ +/** + * True when the CRL has no usable freshness bound (missing/invalid `nextUpdate`) or `now` is at/past + * it. Fails closed: a CRL without a verifiable `nextUpdate` is treated as expired (→ revocation unknown). + */ export function isCrlExpired(crl: ParsedAmdCrl, now: Date): boolean { - return crl.nextUpdate !== null && now.getTime() >= crl.nextUpdate.getTime(); + return crl.nextUpdate === null || !Number.isFinite(crl.nextUpdate.getTime()) || now.getTime() >= crl.nextUpdate.getTime(); } diff --git a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.spec.ts b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.spec.ts index 56207f3..fa66e34 100644 --- a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.spec.ts +++ b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.spec.ts @@ -101,7 +101,8 @@ describe(AmdSnpService.name, () => { const nonce = crypto.randomBytes(64); const report = buildSignedReport(nonce); const embedded = Buffer.from([chain.vcekPem, chain.askPem, chain.arkPem].join("\n")).toString("base64"); - const { service, kdsClient } = setup({ withKds: true }); + // The embedded chain's ARK CN is ARK-Milan, so the service derives "Milan" and the CRL must match that. + const { service, kdsClient } = setup({ withKds: true, resolvingProduct: "Milan" }); const verdict = await service.verify({ report: report.toString("base64"), certChain: embedded, nonce: nonce.toString("base64") }); @@ -146,7 +147,8 @@ describe(AmdSnpService.name, () => { const resolving = input.resolvingProduct ?? "Genoa"; kdsClient.getCaChain.mockImplementation(async product => (input.withKds && product === resolving ? { ask: chain.askPem, ark: chain.arkPem } : null)); kdsClient.getVcek.mockImplementation(async product => (input.withKds && product === resolving ? chain.vcekDer : null)); - kdsClient.getCrl.mockImplementation(async () => { + kdsClient.getCrl.mockImplementation(async product => { + if (!input.withKds || product !== resolving) return null; if (input.crl === "missing") return null; if (input.crl === "error") throw new Error("AMD KDS unavailable"); return input.crl === "revoked" ? revokedCrlDer : cleanCrlDer; From d5ecc28664d4213e28aa9aa9f7709c06ff9935e0 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:44:02 -0400 Subject: [PATCH 3/5] fix(deployment): reject out-of-range CRL time components Address CodeRabbit follow-up: parseAsn1Time validated digit structure but Date.UTC silently rolled over out-of-range parts (month 13, day 00), yielding a plausible wrong date that isCrlExpired would not catch. Round-trip the parsed components and throw on mismatch so a malformed nextUpdate fails closed. Refs CON-596 --- .../services/amd-snp/amd-crl.parser.spec.ts | 25 +++++++++++++++++++ .../services/amd-snp/amd-crl.parser.ts | 20 ++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.spec.ts b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.spec.ts index 4b619f1..1394fa5 100644 --- a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.spec.ts +++ b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.spec.ts @@ -38,6 +38,13 @@ describe(parseAmdCrl.name, () => { it("throws AmdCrlParseError for bytes that are not a CRL", () => { expect(() => parseAmdCrl(Buffer.from("clearly not a DER-encoded CRL"))).toThrow(AmdCrlParseError); }); + + it("rejects an out-of-range time component instead of silently normalizing it", () => { + // Set nextUpdate's month to "13"; Date.UTC would roll it to next January without the round-trip guard. + const corrupted = withNextUpdateMonth(fixtures.cleanCrlDer, "13"); + + expect(() => parseAmdCrl(corrupted)).toThrow(AmdCrlParseError); + }); }); describe(verifyCrlSignature.name, () => { @@ -149,3 +156,21 @@ function generateCrlFixtures() { return { dir, arkCert, askCert, cleanCrlDer, revokedCrlDer }; } + +/** Returns a copy of the CRL DER with nextUpdate's 2-digit month overwritten, to forge an invalid time. */ +function withNextUpdateMonth(der: Buffer, month: string): Buffer { + const copy = Buffer.from(der); + let seen = 0; + for (let i = 0; i + 15 <= copy.length; i++) { + // UTCTime = tag 0x17, length 0x0d, then 13 ASCII bytes ending in 'Z' (0x5a): YYMMDDHHMMSSZ. + if (copy[i] === 0x17 && copy[i + 1] === 0x0d && copy[i + 14] === 0x5a) { + seen += 1; + // The first UTCTime is thisUpdate (skipped by the parser); the second is nextUpdate. + if (seen === 2) { + copy.write(month, i + 4, "latin1"); // month occupies the 3rd–4th content bytes + return copy; + } + } + } + throw new Error("nextUpdate UTCTime not found in fixture"); +} diff --git a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.ts b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.ts index dac39b6..e856036 100644 --- a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.ts +++ b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.ts @@ -83,7 +83,25 @@ function parseAsn1Time(tag: number, content: Buffer): Date { const rawYear = Number(match[1]); // RFC 5280: UTCTime years 00–49 map to 2000–2049, 50–99 to 1950–1999. const year = isUtc ? (rawYear < 50 ? 2000 + rawYear : 1900 + rawYear) : rawYear; - return new Date(Date.UTC(year, Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]), Number(match[6]))); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second)); + // Date.UTC silently rolls over out-of-range parts (month 13 → next year, day 00 → prior month); + // round-trip the components so such a time is rejected rather than yielding a plausible wrong date. + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day || + date.getUTCHours() !== hour || + date.getUTCMinutes() !== minute || + date.getUTCSeconds() !== second + ) { + throw new AmdCrlParseError("Malformed CRL time"); + } + return date; } /** From e45fe6e6b9d23ba37bc94cad800fac7c221e3953 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:09:16 -0400 Subject: [PATCH 4/5] refactor(deployment): tidy AMD CRL revocation code and dedupe CRL fixtures Quality cleanup of the AMD SEV-SNP CRL revocation path; no behavior change beyond the verifier tightening below: - verifyCrlSignature now accepts only RSA-PSS/SHA-384, AMD's actual algorithm. The ECDSA branch existed solely so a test fixture could avoid RSA; the service spec now mints an RSA ARK/ASK (P-384 VCEK) so its CRL is genuinely RSA-PSS-signed. - Build the verdict checks object per branch instead of annotating then mutating it. - Drop the unreachable non-finite nextUpdate guard in isCrlExpired (parseAsn1Time only ever yields null or a valid Date). - Extract the duplicated openssl CRL-generation scaffold from both specs into a shared amd-crl.test-fixtures helper. Refs CON-596 --- .../services/amd-snp/amd-crl.parser.spec.ts | 27 ++------- .../services/amd-snp/amd-crl.parser.ts | 18 +++--- .../services/amd-snp/amd-crl.test-fixtures.ts | 40 +++++++++++++ .../services/amd-snp/amd-snp.service.spec.ts | 56 +++++-------------- .../services/amd-snp/amd-snp.service.ts | 22 ++++---- 5 files changed, 78 insertions(+), 85 deletions(-) create mode 100644 apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.test-fixtures.ts diff --git a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.spec.ts b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.spec.ts index 1394fa5..690337e 100644 --- a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.spec.ts +++ b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.spec.ts @@ -1,11 +1,12 @@ import { execFileSync } from "node:child_process"; import { X509Certificate } from "node:crypto"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { AmdCrlParseError, isCertRevoked, isCrlExpired, normalizeSerial, parseAmdCrl, verifyCrlSignature } from "./amd-crl.parser"; +import { generateCrl } from "./amd-crl.test-fixtures"; // A real RSA ARK→ASK chain plus RSA-PSS CRLs (clean and one revoking the ASK), generated once via // openssl to mirror AMD KDS — which issues RSA-4096 ARKs and signs its CRLs with RSA-PSS/SHA-384. @@ -131,28 +132,8 @@ function generateCrlFixtures() { const arkCert = new X509Certificate(readFileSync(join(dir, "ark.pem"))); const askCert = new X509Certificate(readFileSync(join(dir, "ask.pem"))); - // Minimal CA scaffold so `openssl ca -gencrl` can emit a CRL signed by the ARK. - writeFileSync( - join(dir, "ca.cnf"), - ["[ca]", "default_ca = myca", "[myca]", "database = index.txt", "crlnumber = crlnumber", "certificate = ark.pem", "private_key = ark.key", "default_md = sha384", "default_crl_days = 30", ""].join("\n") - ); - // AMD signs CRLs with RSA-PSS; force the same here so the parser is exercised against the real algorithm. - const gencrl = (out: string) => ossl(["ca", "-config", "ca.cnf", "-gencrl", "-out", out, "-sigopt", "rsa_padding_mode:pss", "-sigopt", "rsa_pss_saltlen:digest"]); - const toDer = (pem: string, der: string) => { - ossl(["crl", "-in", pem, "-outform", "DER", "-out", der]); - return readFileSync(join(dir, der)); - }; - - writeFileSync(join(dir, "index.txt"), ""); - writeFileSync(join(dir, "crlnumber"), "1000\n"); - gencrl("crl_clean.pem"); - const cleanCrlDer = toDer("crl_clean.pem", "crl_clean.der"); - - // An index entry marks the ASK serial revoked; openssl emits it into the regenerated CRL. - writeFileSync(join(dir, "index.txt"), `R\t350101000000Z\t240101000000Z\t${askCert.serialNumber}\tunknown\t/CN=SEV-Milan\n`); - writeFileSync(join(dir, "crlnumber"), "1001\n"); - gencrl("crl_revoked.pem"); - const revokedCrlDer = toDer("crl_revoked.pem", "crl_revoked.der"); + const cleanCrlDer = generateCrl(dir, []); + const revokedCrlDer = generateCrl(dir, [askCert.serialNumber]); return { dir, arkCert, askCert, cleanCrlDer, revokedCrlDer }; } diff --git a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.ts b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.ts index e856036..be3dfe4 100644 --- a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.ts +++ b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.ts @@ -10,7 +10,7 @@ import crypto from "node:crypto"; * `node:crypto` exposes no CRL type, so we walk the `CertificateList` structure (RFC 5280 §5.1) by * hand: capture the signed `tbsCertList` bytes, the signature, `nextUpdate`, and the revoked serials, * then verify the signature with `node:crypto`. AMD signs these CRLs with RSA-PSS/SHA-384 (the ARK is - * RSA-4096); {@link verifyCrlSignature} also accepts an ECDSA issuer so test fixtures need not be RSA. + * RSA-4096), which is the only algorithm {@link verifyCrlSignature} accepts. */ export interface ParsedAmdCrl { @@ -159,17 +159,13 @@ export function parseAmdCrl(der: Buffer): ParsedAmdCrl { } /** - * Verifies the CRL signature against the issuer (the ARK). AMD uses RSA-PSS/SHA-384; an ECDSA issuer - * is also accepted (its signature is the DER form node expects by default). Any error → `false`, so an - * unverifiable CRL is never trusted. + * Verifies the CRL signature against the issuer (the ARK). AMD always signs with RSA-PSS/SHA-384, so + * that is the only accepted algorithm; any other issuer key or any error → `false`, so an unverifiable + * CRL is never trusted. */ export function verifyCrlSignature(crl: ParsedAmdCrl, issuer: crypto.X509Certificate): boolean { try { - const key = issuer.publicKey; - const options = - key.asymmetricKeyType === "rsa" - ? { key, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: crypto.constants.RSA_PSS_SALTLEN_AUTO } - : { key }; + const options = { key: issuer.publicKey, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: crypto.constants.RSA_PSS_SALTLEN_AUTO }; return crypto.verify("sha384", crl.tbsDer, options, crl.signature); } catch { return false; @@ -186,5 +182,7 @@ export function isCertRevoked(crl: ParsedAmdCrl, cert: crypto.X509Certificate): * it. Fails closed: a CRL without a verifiable `nextUpdate` is treated as expired (→ revocation unknown). */ export function isCrlExpired(crl: ParsedAmdCrl, now: Date): boolean { - return crl.nextUpdate === null || !Number.isFinite(crl.nextUpdate.getTime()) || now.getTime() >= crl.nextUpdate.getTime(); + // `parseAmdCrl` only ever yields `null` or a valid Date here (parseAsn1Time throws on a bad time), + // so there is no non-finite case to guard against. + return crl.nextUpdate === null || now.getTime() >= crl.nextUpdate.getTime(); } diff --git a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.test-fixtures.ts b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.test-fixtures.ts new file mode 100644 index 0000000..9cfcd7c --- /dev/null +++ b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.test-fixtures.ts @@ -0,0 +1,40 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** + * Generates a DER-encoded CRL signed by the ARK in `signerDir` (which must contain `ark.pem` + `ark.key`), + * revoking the given certificate serials — an empty list yields a clean CRL. Signed RSA-PSS/SHA-384 to + * mirror AMD KDS, the only algorithm {@link verifyCrlSignature} accepts. Runs in a throwaway temp dir. + */ +export function generateCrl(signerDir: string, revokedSerials: string[]): Buffer { + const dir = mkdtempSync(join(tmpdir(), "amd-crl-")); + try { + const ossl = (args: string[]) => execFileSync("openssl", args, { cwd: dir, stdio: ["ignore", "ignore", "pipe"] }); + + const entries = revokedSerials.map(serial => `R\t350101000000Z\t240101000000Z\t${serial}\tunknown\t/CN=SEV-Milan\n`).join(""); + writeFileSync(join(dir, "index.txt"), entries); + writeFileSync(join(dir, "crlnumber"), "1000\n"); + writeFileSync( + join(dir, "ca.cnf"), + [ + "[ca]", + "default_ca = myca", + "[myca]", + `database = ${join(dir, "index.txt")}`, + `crlnumber = ${join(dir, "crlnumber")}`, + `certificate = ${join(signerDir, "ark.pem")}`, + `private_key = ${join(signerDir, "ark.key")}`, + "default_md = sha384", + "default_crl_days = 30", + "" + ].join("\n") + ); + ossl(["ca", "-config", "ca.cnf", "-gencrl", "-out", "crl.pem", "-sigopt", "rsa_padding_mode:pss", "-sigopt", "rsa_pss_saltlen:digest"]); + ossl(["crl", "-in", "crl.pem", "-outform", "DER", "-out", "crl.der"]); + return readFileSync(join(dir, "crl.der")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} diff --git a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.spec.ts b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.spec.ts index fa66e34..ff6a665 100644 --- a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.spec.ts +++ b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.spec.ts @@ -1,26 +1,28 @@ import type { LoggerService } from "@akashnetwork/logging"; import { execFileSync } from "node:child_process"; import crypto, { X509Certificate } from "node:crypto"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { mock } from "vitest-mock-extended"; import { envSchema } from "../../config/env.config"; +import { generateCrl } from "./amd-crl.test-fixtures"; import type { AmdKdsClient } from "./amd-kds.client"; import { AmdSnpService } from "./amd-snp.service"; import { SNP_REPORT_MIN_LENGTH } from "./snp-report.parser"; -// A real P-384 ARK→ASK→VCEK chain generated once via openssl, plus the VCEK private key used to sign reports. -// Generating it at test time keeps the suite hermetic (no committed keys, no network). -let chain: ReturnType; +// A real ARK→ASK→VCEK chain generated once via openssl (RSA ARK/ASK as AMD issues them, P-384 VCEK that +// signs the report), plus the VCEK private key. Generating it at test time keeps the suite hermetic +// (no committed keys, no network). +let chain: ReturnType; // CRLs signed by the chain's ARK: one clean, one revoking the ASK. Drive the revocation-check paths. let cleanCrlDer: Buffer; let revokedCrlDer: Buffer; beforeAll(() => { - chain = generateP384Chain(); + chain = generateChain(); const askSerial = new X509Certificate(chain.askPem).serialNumber; cleanCrlDer = generateCrl(chain.dir, []); revokedCrlDer = generateCrl(chain.dir, [askSerial]); @@ -177,10 +179,11 @@ function buildSignedReport(nonce: Buffer): Buffer { return report; } -function generateP384Chain() { +function generateChain() { const dir = mkdtempSync(join(tmpdir(), "snp-chain-")); const ossl = (args: string[]) => execFileSync("openssl", args, { cwd: dir }); - const genKey = (name: string) => ossl(["ecparam", "-name", "secp384r1", "-genkey", "-noout", "-out", `${name}.key`]); + const genRsaKey = (name: string) => ossl(["genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", `${name}.key`]); + const genEcKey = (name: string) => ossl(["ecparam", "-name", "secp384r1", "-genkey", "-noout", "-out", `${name}.key`]); const selfSign = (name: string, cn: string) => ossl(["req", "-new", "-x509", "-key", `${name}.key`, "-out", `${name}.pem`, "-days", "2", "-subj", `/CN=${cn}`, "-sha384"]); const caSign = (name: string, cn: string, ca: string) => { @@ -189,12 +192,13 @@ function generateP384Chain() { }; const read = (name: string) => readFileSync(join(dir, name), "utf-8"); - // CN mirrors the real AMD ARK (`ARK-`) so the service can derive the product for CRL lookup. - genKey("ark"); + // ARK/ASK are RSA and the ARK CN mirrors the real AMD root (`ARK-`), so the service can derive + // the product for CRL lookup and verify the RSA-PSS CRL; the VCEK is P-384 because it signs the report. + genRsaKey("ark"); selfSign("ark", "ARK-Milan"); - genKey("ask"); + genRsaKey("ask"); caSign("ask", "SEV-Milan", "ark"); - genKey("vcek"); + genEcKey("vcek"); caSign("vcek", "VCEK", "ask"); return { @@ -206,33 +210,3 @@ function generateP384Chain() { vcekDer: Buffer.from(new X509Certificate(read("vcek.pem")).raw) }; } - -/** Generates a DER CRL signed by the chain's ARK (in `chainDir`), revoking the given serials (empty = clean). */ -function generateCrl(chainDir: string, revokedSerials: string[]): Buffer { - const dir = mkdtempSync(join(tmpdir(), "amd-crl-")); - const ossl = (args: string[]) => execFileSync("openssl", args, { cwd: dir, stdio: ["ignore", "ignore", "pipe"] }); - - const entries = revokedSerials.map(serial => `R\t350101000000Z\t240101000000Z\t${serial}\tunknown\t/CN=SEV-Milan\n`).join(""); - writeFileSync(join(dir, "index.txt"), entries); - writeFileSync(join(dir, "crlnumber"), "1000\n"); - writeFileSync( - join(dir, "ca.cnf"), - [ - "[ca]", - "default_ca = myca", - "[myca]", - `database = ${join(dir, "index.txt")}`, - `crlnumber = ${join(dir, "crlnumber")}`, - `certificate = ${join(chainDir, "ark.pem")}`, - `private_key = ${join(chainDir, "ark.key")}`, - "default_md = sha384", - "default_crl_days = 30", - "" - ].join("\n") - ); - ossl(["ca", "-config", "ca.cnf", "-gencrl", "-out", "crl.pem"]); - ossl(["crl", "-in", "crl.pem", "-outform", "DER", "-out", "crl.der"]); - const der = readFileSync(join(dir, "crl.der")); - rmSync(dir, { recursive: true, force: true }); - return der; -} diff --git a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.ts b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.ts index b3cc74c..9842aec 100644 --- a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.ts +++ b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-snp.service.ts @@ -55,7 +55,7 @@ export class AmdSnpService { const certChainValid = this.#verifyChain(chain); const signatureValid = certChainValid && this.#verifySignature(parsed, chain.vcek); const nonceMatch = this.#verifyNonce(parsed, input.nonce); - const checks: NonNullable = { certChainValid, signatureValid, nonceMatch }; + const checks = { certChainValid, signatureValid, nonceMatch }; if (!certChainValid || !signatureValid || !nonceMatch) { const reasons: string[] = []; @@ -67,15 +67,6 @@ export class AmdSnpService { // Authenticity is established; evaluate AMD revocation status before declaring the report valid. const revocation = await this.#checkRevocation(chain); - if (revocation !== "unknown") checks.notRevoked = revocation === "not-revoked"; - - if (revocation === "revoked") { - return this.#verdict( - "invalid", - "AMD SEV-SNP signing key revoked: the chip is genuine and nonce-bound, but AMD has revoked the signing key in its certificate chain, withdrawing trust in the hardware.", - checks - ); - } if (revocation === "unknown") { return this.#verdict( "unverifiable", @@ -83,10 +74,19 @@ export class AmdSnpService { checks ); } + + const revocationChecks = { ...checks, notRevoked: revocation === "not-revoked" }; + if (revocation === "revoked") { + return this.#verdict( + "invalid", + "AMD SEV-SNP signing key revoked: the chip is genuine and nonce-bound, but AMD has revoked the signing key in its certificate chain, withdrawing trust in the hardware.", + revocationChecks + ); + } return this.#verdict( "valid", "Genuine AMD SEV-SNP hardware: VCEK chains to the AMD root, report signature and nonce verified, and the signing key is not revoked by AMD's CRL.", - checks + revocationChecks ); } From d712a95f5b941dbe7f280b1e74c5f80ac964904d Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:59:32 -0400 Subject: [PATCH 5/5] test(deployment): add AMD KDS client unit tests Cover getVcek/getCaChain/getCrl 200, 404-to-null, and transport-error paths plus the per-instance chain/CRL cache, closing the KDS client coverage gap. --- .../services/amd-snp/amd-kds.client.spec.ts | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-kds.client.spec.ts diff --git a/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-kds.client.spec.ts b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-kds.client.spec.ts new file mode 100644 index 0000000..4bbe217 --- /dev/null +++ b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-kds.client.spec.ts @@ -0,0 +1,146 @@ +import type { HttpClient } from "@akashnetwork/http-sdk"; +import type { LoggerService } from "@akashnetwork/logging"; +import { AxiosError } from "axios"; +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import { AmdKdsClient, splitPemChain } from "./amd-kds.client"; + +const CERT_ASK = "-----BEGIN CERTIFICATE-----\nASK\n-----END CERTIFICATE-----"; +const CERT_ARK = "-----BEGIN CERTIFICATE-----\nARK\n-----END CERTIFICATE-----"; + +describe(AmdKdsClient.name, () => { + describe("getVcek", () => { + it("returns the DER-encoded VCEK and requests the chip-and-TCB path on a 200", async () => { + const { client, httpClient } = setup(); + httpClient.get.mockResolvedValue({ data: new Uint8Array([1, 2, 3]).buffer }); + + const result = await client.getVcek("Milan", report()); + + expect(result?.equals(Buffer.from([1, 2, 3]))).toBe(true); + expect(httpClient.get).toHaveBeenCalledWith("/vcek/v1/Milan/abcd?blSPL=1&teeSPL=2&snpSPL=3&ucodeSPL=4", { responseType: "arraybuffer" }); + }); + + it("returns null when KDS has no such chip (404)", async () => { + const { client, httpClient } = setup(); + httpClient.get.mockRejectedValue(httpError(404)); + + expect(await client.getVcek("Milan", report())).toBeNull(); + }); + + it("rethrows and logs a non-404 transport error", async () => { + const { client, httpClient, logger } = setup(); + httpClient.get.mockRejectedValue(httpError(500)); + + await expect(client.getVcek("Milan", report())).rejects.toThrow(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "AMD_KDS_VCEK_FETCH_FAILED", product: "Milan" })); + }); + }); + + describe("getCaChain", () => { + it("splits a 200 chain response into ASK then ARK", async () => { + const { client, httpClient } = setup(); + httpClient.get.mockResolvedValue({ data: `${CERT_ASK}\n${CERT_ARK}` }); + + expect(await client.getCaChain("Genoa")).toEqual({ ask: CERT_ASK, ark: CERT_ARK }); + }); + + it("returns null and logs when the chain has fewer than two certs", async () => { + const { client, httpClient, logger } = setup(); + httpClient.get.mockResolvedValue({ data: CERT_ASK }); + + expect(await client.getCaChain("Genoa")).toBeNull(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "AMD_KDS_CHAIN_MALFORMED", product: "Genoa" })); + }); + + it("returns null when the product is unknown (404)", async () => { + const { client, httpClient } = setup(); + httpClient.get.mockRejectedValue(httpError(404)); + + expect(await client.getCaChain("Genoa")).toBeNull(); + }); + + it("rethrows and logs a non-404 transport error", async () => { + const { client, httpClient, logger } = setup(); + httpClient.get.mockRejectedValue(httpError(503)); + + await expect(client.getCaChain("Genoa")).rejects.toThrow(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "AMD_KDS_CHAIN_FETCH_FAILED", product: "Genoa" })); + }); + + it("caches the chain so a repeat lookup skips the HTTP fetch", async () => { + const { client, httpClient } = setup(); + httpClient.get.mockResolvedValue({ data: `${CERT_ASK}\n${CERT_ARK}` }); + + await client.getCaChain("Genoa"); + await client.getCaChain("Genoa"); + + expect(httpClient.get).toHaveBeenCalledTimes(1); + }); + }); + + describe("getCrl", () => { + it("returns the DER-encoded CRL and requests the per-product CRL path on a 200", async () => { + const { client, httpClient } = setup(); + httpClient.get.mockResolvedValue({ data: new Uint8Array([9, 8, 7]).buffer }); + + const result = await client.getCrl("Milan"); + + expect(result?.equals(Buffer.from([9, 8, 7]))).toBe(true); + expect(httpClient.get).toHaveBeenCalledWith("/vcek/v1/Milan/crl", { responseType: "arraybuffer" }); + }); + + it("returns null when the product has no CRL (404)", async () => { + const { client, httpClient } = setup(); + httpClient.get.mockRejectedValue(httpError(404)); + + expect(await client.getCrl("Milan")).toBeNull(); + }); + + it("rethrows and logs a non-404 transport error", async () => { + const { client, httpClient, logger } = setup(); + httpClient.get.mockRejectedValue(httpError(500)); + + await expect(client.getCrl("Milan")).rejects.toThrow(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "AMD_KDS_CRL_FETCH_FAILED", product: "Milan" })); + }); + + it("caches the CRL so a repeat lookup skips the HTTP fetch", async () => { + const { client, httpClient } = setup(); + httpClient.get.mockResolvedValue({ data: new Uint8Array([1]).buffer }); + + await client.getCrl("Turin"); + await client.getCrl("Turin"); + + expect(httpClient.get).toHaveBeenCalledTimes(1); + }); + }); + + describe(splitPemChain.name, () => { + it("returns an empty array when the blob contains no PEM blocks", () => { + expect(splitPemChain("not a certificate")).toEqual([]); + }); + }); + + function setup() { + const httpClient = mock(); + const logger = mock(); + const client = new AmdKdsClient(httpClient, logger); + return { client, httpClient, logger }; + } +}); + +/** A minimal parsed report sufficient to build the VCEK request path (chipId `abcd`, TCB 1/2/3/4). */ +function report(): Parameters[1] { + return { chipId: Buffer.from("abcd", "hex"), reportedTcb: { bootloader: 1, tee: 2, snp: 3, microcode: 4 } }; +} + +function httpError(status: number): AxiosError { + return new AxiosError("HTTP Error", undefined, undefined, undefined, { + status, + data: {}, + statusText: "Error", + headers: {}, + config: {} as never + }); +}