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..690337e --- /dev/null +++ b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.spec.ts @@ -0,0 +1,157 @@ +import { execFileSync } from "node:child_process"; +import { X509Certificate } from "node:crypto"; +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. +// 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); + }); + + 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, () => { + 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); + }); + + 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() { + 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"))); + + const cleanCrlDer = generateCrl(dir, []); + const revokedCrlDer = generateCrl(dir, [askCert.serialNumber]); + + 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 new file mode 100644 index 0000000..be3dfe4 --- /dev/null +++ b/apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.ts @@ -0,0 +1,188 @@ +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), which is the only algorithm {@link verifyCrlSignature} accepts. + */ + +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 isUtc = tag === TAG.UTC_TIME; + // 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 ? (rawYear < 50 ? 2000 + rawYear : 1900 + rawYear) : rawYear; + 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; +} + +/** + * 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 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 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; + } +} + +/** 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 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 { + // `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-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 + }); +} 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..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 @@ -8,16 +8,24 @@ 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]); }); afterAll(() => { @@ -33,7 +41,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 () => { @@ -94,7 +103,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") }); @@ -102,11 +112,49 @@ 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 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; + }); const config = { ...envSchema.parse({}), AMD_SNP_PRODUCTS: input.products ?? [resolving] }; const service = new AmdSnpService(kdsClient, config, mock()); @@ -131,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) => { @@ -143,11 +192,13 @@ function generateP384Chain() { }; const read = (name: string) => readFileSync(join(dir, name), "utf-8"); - genKey("ark"); - selfSign("ark", "ARK"); - genKey("ask"); - caSign("ask", "ASK", "ark"); - genKey("vcek"); + // 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"); + genRsaKey("ask"); + caSign("ask", "SEV-Milan", "ark"); + genEcKey("vcek"); caSign("vcek", "VCEK", "ask"); return { 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..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 @@ -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. */ @@ -54,19 +57,37 @@ export class AmdSnpService { const nonceMatch = this.#verifyNonce(parsed, input.nonce); const checks = { certChainValid, signatureValid, nonceMatch }; - if (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") { return this.#verdict( - "valid", - "Genuine AMD SEV-SNP hardware: VCEK chains to the AMD root, report signature and nonce verified. Revocation not checked.", + "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 ); } - 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); + 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.", + revocationChecks + ); } 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); }