From ebb7861ed956085ce51ad7b3639934e7db3044f8 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:32:08 -0700 Subject: [PATCH] =?UTF-8?q?feat(attest):=20envelope=20verifier=20CLI=20?= =?UTF-8?q?=E2=80=94=20AMD=20cert=20chain,=20TCB,=20measurement,=20report?= =?UTF-8?q?=5Fdata=20(#9212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/verify-attested-run-core.ts | 200 ++++++++++++++++ scripts/verify-attested-run-der.ts | 169 ++++++++++++++ scripts/verify-attested-run-report.ts | 130 +++++++++++ scripts/verify-attested-run.ts | 197 ++++++++++++++++ scripts/verify-attested-run/README.md | 116 ++++++++++ .../verify-attested-run/certs/genoa-ark.pem | 37 +++ .../verify-attested-run/certs/genoa-ask.pem | 37 +++ .../verify-attested-run/certs/milan-ark.pem | 37 +++ .../verify-attested-run/certs/milan-ask.pem | 37 +++ .../synthetic-intermediate-cert.pem | 33 +++ .../synthetic-root-cert.pem | 33 +++ .../synthetic-vcek-cert.pem | 26 +++ .../synthetic-vcek-key.pem | 6 + test/unit/verify-attested-run-core.test.ts | 219 ++++++++++++++++++ test/unit/verify-attested-run-der.test.ts | 164 +++++++++++++ test/unit/verify-attested-run-report.test.ts | 136 +++++++++++ 16 files changed, 1577 insertions(+) create mode 100644 scripts/verify-attested-run-core.ts create mode 100644 scripts/verify-attested-run-der.ts create mode 100644 scripts/verify-attested-run-report.ts create mode 100644 scripts/verify-attested-run.ts create mode 100644 scripts/verify-attested-run/README.md create mode 100644 scripts/verify-attested-run/certs/genoa-ark.pem create mode 100644 scripts/verify-attested-run/certs/genoa-ask.pem create mode 100644 scripts/verify-attested-run/certs/milan-ark.pem create mode 100644 scripts/verify-attested-run/certs/milan-ask.pem create mode 100644 test/fixtures/verify-attested-run/synthetic-intermediate-cert.pem create mode 100644 test/fixtures/verify-attested-run/synthetic-root-cert.pem create mode 100644 test/fixtures/verify-attested-run/synthetic-vcek-cert.pem create mode 100644 test/fixtures/verify-attested-run/synthetic-vcek-key.pem create mode 100644 test/unit/verify-attested-run-core.test.ts create mode 100644 test/unit/verify-attested-run-der.test.ts create mode 100644 test/unit/verify-attested-run-report.test.ts diff --git a/scripts/verify-attested-run-core.ts b/scripts/verify-attested-run-core.ts new file mode 100644 index 0000000000..88d498b39e --- /dev/null +++ b/scripts/verify-attested-run-core.ts @@ -0,0 +1,200 @@ +// Orchestrating verification core (#9212, epic #8534): "did this exact code run on this exact corpus inside +// genuine SNP hardware" -- answerable by a third party who trusts nothing but AMD's own published root keys +// and the code in this file. Pure with respect to IO (the CLI reads files/network, this module only takes +// already-loaded bytes and PEM strings) so every failure class below is independently, deterministically +// testable with real cryptography (see this file's companion test suite -- a synthetic-but-genuine 3-tier +// PKI, never a mocked "verification" that can't actually fail). +// +// SECURITY-CRITICAL ORDERING: nothing the report itself claims (its TCB fields, its measurement, its +// report_data) is trusted until BOTH the certificate chain is trusted AND the report's own signature has +// verified against that chain's VCEK. Checking any report content first would mean a forged report with a +// fabricated (but plausible-looking) measurement could pass checks 6-8 below by coincidence, before ever +// being rejected for the invalid signature that actually damns it. +import { buildAttestationReportData } from "@loopover/engine/calibration/attestation-envelope"; +import { isSampleAttestationReport } from "@loopover/engine/calibration/attester"; +import type { AttestationEnvelope } from "@loopover/engine/calibration/attestation-envelope"; +import { X509Certificate, verify as cryptoVerify } from "node:crypto"; + +import { encodeOid, findExtensionValue, parseDer, readSmallDerInteger } from "./verify-attested-run-der"; +import { parseSnpReport, type SnpTcbVersion } from "./verify-attested-run-report"; + +export type VerificationFailureClass = + | "sample_attestation" + | "envelope_invalid" + | "malformed_report" + | "chain_untrusted" + | "signature_invalid" + | "tcb_mismatch" + | "measurement_mismatch" + | "report_data_mismatch"; + +export type VerificationResult = + | { verified: true } + | { verified: false; failureClass: VerificationFailureClass; reason: string }; + +export type VerifyAttestedRunInput = { + envelope: AttestationEnvelope; + /** Raw SNP report bytes, i.e. `Buffer.from(envelope.attestationReport, "base64")` -- decoding is the + * CLI's job so this module never has to guess an encoding. */ + rawReportBytes: Uint8Array; + vcekCertPem: string; + askCertPem: string; + arkCertPem: string; + /** The operator's own vendored, trusted root -- compared byte-for-byte (not merely "is self-signed") against + * `arkCertPem`, so a caller can never be tricked by an attacker-supplied self-signed "ARK" that isn't + * actually AMD's. */ + pinnedArkCertPem: string; + /** Hex, from the tenant/operator's own manifest -- what the launch measurement is expected to be. */ + expectedMeasurementHex: string; + corpusChecksum: string; + headSha: string; + baseSha: string; + allowSample: boolean; +}; + +const AMD_SVN_OIDS = { + bootloaderSpl: encodeOid([1, 3, 6, 1, 4, 1, 3704, 1, 3, 1]), + teeSpl: encodeOid([1, 3, 6, 1, 4, 1, 3704, 1, 3, 2]), + snpSpl: encodeOid([1, 3, 6, 1, 4, 1, 3704, 1, 3, 3]), + microcodeSpl: encodeOid([1, 3, 6, 1, 4, 1, 3704, 1, 3, 8]), +} as const; + +function toHex(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("hex"); +} + +function pemToDer(pem: string): Uint8Array { + const base64 = pem + .split("\n") + .filter((line) => line.length > 0 && !line.includes("BEGIN") && !line.includes("END")) + .join(""); + return new Uint8Array(Buffer.from(base64, "base64")); +} + +/** + * Read the four AMD KDS SVN OIDs (bootloader/TEE/SNP/microcode) off a VCEK certificate's raw DER, per + * kds/kds.go's OID table in google/go-sev-guest. Any missing or malformed OID is a certificate this function + * refuses to interpret -- it throws rather than substituting a default SVN, since a default here would mean + * silently trusting an unattested claim. + */ +export function readVcekTcbFromCertificate(vcekCertDer: Uint8Array): SnpTcbVersion { + const tree = parseDer(vcekCertDer); + const readOne = (oid: Uint8Array, name: string): number => { + const value = findExtensionValue(vcekCertDer, tree, oid); + if (!value) throw new Error(`readVcekTcbFromCertificate: missing AMD SVN extension for ${name}`); + return readSmallDerInteger(value); + }; + return { + bootloaderSpl: readOne(AMD_SVN_OIDS.bootloaderSpl, "bootloaderSpl"), + teeSpl: readOne(AMD_SVN_OIDS.teeSpl, "teeSpl"), + snpSpl: readOne(AMD_SVN_OIDS.snpSpl, "snpSpl"), + microcodeSpl: readOne(AMD_SVN_OIDS.microcodeSpl, "microcodeSpl"), + }; +} + +/** `error.message` for a real Error, `String(error)` for anything else a `catch` clause could technically + * hand back (a thrown string/object, however unusual in practice) -- isolated into its own function so both + * arms are independently unit-testable rather than only reachable through whatever a specific call site + * happens to throw today. */ +export function formatCaughtError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function tcbVersionsEqual(a: SnpTcbVersion, b: SnpTcbVersion): boolean { + return a.bootloaderSpl === b.bootloaderSpl && a.teeSpl === b.teeSpl && a.snpSpl === b.snpSpl && a.microcodeSpl === b.microcodeSpl; +} + +function formatTcb(tcb: SnpTcbVersion): string { + return `bootloader=${tcb.bootloaderSpl} tee=${tcb.teeSpl} snp=${tcb.snpSpl} microcode=${tcb.microcodeSpl}`; +} + +/** + * Verify an attested run end to end. Never throws for an ordinarily-invalid input (a bad signature, a + * mismatched measurement, an untrusted chain) -- those are all `{ verified: false, failureClass, reason }`. + * Only a structurally impossible input (certificates that don't even parse as X.509, for instance) propagates + * as a thrown error, since that is a caller-side bug (malformed PEM), not a fact about the attested run. + */ +export function verifyAttestedRun(input: VerifyAttestedRunInput): VerificationResult { + if (!input.allowSample && isSampleAttestationReport(input.envelope.attestationReport)) { + return { + verified: false, + failureClass: "sample_attestation", + reason: "envelope is sample-attested (dev artifact, not evidence) -- pass --allow-sample to accept it anyway for local development", + }; + } + + let report: ReturnType; + try { + report = parseSnpReport(input.rawReportBytes); + } catch (error) { + return { verified: false, failureClass: "malformed_report", reason: formatCaughtError(error) }; + } + + // Chain of trust: the operator's pinned root, byte-for-byte, is what "ARK" means here -- not merely + // "some self-signed certificate". ARK -> ASK -> VCEK, each link a real signature check. + const pinnedArkDer = pemToDer(input.pinnedArkCertPem); + const suppliedArkDer = pemToDer(input.arkCertPem); + if (toHex(pinnedArkDer) !== toHex(suppliedArkDer)) { + return { verified: false, failureClass: "chain_untrusted", reason: "supplied ARK certificate does not match the pinned, vendored root" }; + } + const ark = new X509Certificate(input.arkCertPem); + const ask = new X509Certificate(input.askCertPem); + const vcek = new X509Certificate(input.vcekCertPem); + if (!ark.verify(ark.publicKey)) { + return { verified: false, failureClass: "chain_untrusted", reason: "pinned ARK certificate does not verify against its own public key (not self-signed)" }; + } + if (!ask.verify(ark.publicKey)) { + return { verified: false, failureClass: "chain_untrusted", reason: "ASK certificate was not signed by the pinned ARK" }; + } + if (!vcek.verify(ask.publicKey)) { + return { verified: false, failureClass: "chain_untrusted", reason: "VCEK certificate was not signed by the verified ASK" }; + } + + // The report's own signature, checked against the now-trusted VCEK's public key -- nothing about the + // report's CONTENT (below) is meaningful until this passes. + const vcekPublicKeyPem = vcek.publicKey.export({ type: "spki", format: "pem" }); + const signatureValid = verifySnpSignature(report.signedBytes, report.signatureIeeeP1363, vcekPublicKeyPem); + if (!signatureValid) { + return { verified: false, failureClass: "signature_invalid", reason: "report signature does not verify against the trusted VCEK public key" }; + } + + const certTcb = readVcekTcbFromCertificate(pemToDer(input.vcekCertPem)); + if (!tcbVersionsEqual(certTcb, report.reportedTcb)) { + return { + verified: false, + failureClass: "tcb_mismatch", + reason: `report's reported_tcb (${formatTcb(report.reportedTcb)}) does not match the VCEK certificate's provisioned TCB (${formatTcb(certTcb)})`, + }; + } + + const measurementHex = toHex(report.measurement); + if (measurementHex !== input.expectedMeasurementHex.toLowerCase()) { + return { + verified: false, + failureClass: "measurement_mismatch", + reason: `report measurement ${measurementHex} does not match the expected pinned digest ${input.expectedMeasurementHex.toLowerCase()}`, + }; + } + + const expectedReportData = buildAttestationReportData({ + corpusChecksum: input.corpusChecksum, + headSha: input.headSha, + baseSha: input.baseSha, + runId: input.envelope.runId, + }); + if (toHex(report.reportData) !== expectedReportData) { + return { + verified: false, + failureClass: "report_data_mismatch", + reason: "report_data re-derived from the envelope's claimed corpus checksum, SHAs, and runId does not match the report's own report_data field", + }; + } + + return { verified: true }; +} + +/** Real ECDSA-P384-SHA384 verification, isolated into its own function purely so the core orchestration + * above reads as a flat sequence of named checks. */ +function verifySnpSignature(signedBytes: Uint8Array, signatureIeeeP1363: Uint8Array, vcekPublicKeyPem: string | Buffer): boolean { + return cryptoVerify("sha384", signedBytes, { key: vcekPublicKeyPem, dsaEncoding: "ieee-p1363" }, signatureIeeeP1363); +} diff --git a/scripts/verify-attested-run-der.ts b/scripts/verify-attested-run-der.ts new file mode 100644 index 0000000000..4273691fef --- /dev/null +++ b/scripts/verify-attested-run-der.ts @@ -0,0 +1,169 @@ +// Minimal DER/BER reader (#9212, epic #8534) -- ONLY what's needed to pull a named X.509 extension's raw +// value out of a certificate's DER bytes, and to read a small unsigned DER INTEGER out of that value. This is +// deliberately NOT a general ASN.1 library: no dependency is added for it (a smaller, auditable trusted +// computing base matters more here than convenience -- this module's whole job is verifying trust, so it +// should not itself require trusting an unaudited third-party parser), and every function is scoped to the +// exact shapes AMD's KDS certificates actually use, per the X.509 Extension grammar (RFC 5280 §4.1): +// +// Extension ::= SEQUENCE { extnID OBJECT IDENTIFIER, critical BOOLEAN DEFAULT FALSE, extnValue OCTET STRING } +// +// Every extension value AMD's KDS defines under 1.3.6.1.4.1.3704.1.3.* (the per-component SVN fields this +// module exists to read) is a DER INTEGER 0-255 wrapped in that OCTET STRING -- verified byte-for-byte against +// google/go-sev-guest's kds/kds.go asn1U8 (which does the equivalent Go-side unmarshal) and against a real, +// live-fetched AMD KDS certificate's raw DER bytes (the OID encoding this module's tests pin was checked +// against the actual bytes in a real Milan ASK certificate's SHA-384 AlgorithmIdentifier, not derived from +// memory alone). + +const TAG_INTEGER = 0x02; +const TAG_OID = 0x06; +const TAG_SEQUENCE = 0x30; +const TAG_OCTET_STRING = 0x04; +const LONG_LENGTH_MASK = 0x80; +const LONG_LENGTH_COUNT_MASK = 0x7f; +const OID_ARC_CONTINUATION_BIT = 0x80; +const OID_ARC_VALUE_MASK = 0x7f; +const OID_ARC_SHIFT_BITS = 7; + +/** One parsed DER TLV: `tag`, the byte range of its VALUE (not including the tag/length header), and (for a + * constructed tag -- SEQUENCE, SET, or a context-specific `[N]` wrapper) its immediate children, parsed one + * level deep. Primitive tags (INTEGER, OID, OCTET STRING, ...) carry no children -- their bytes are read + * directly by the typed helpers below. */ +export type DerNode = { + tag: number; + valueStart: number; + valueEnd: number; + children: DerNode[]; +}; + +const CONSTRUCTED_BIT = 0x20; + +/** Parse one DER TLV starting at `offset`. Throws on truncated or malformed length encoding -- a caller + * handling untrusted input should catch and treat that as "not a valid AMD certificate", never fall back + * to a partial/best-effort read. Recurses into constructed tags (bit 0x20 set) one call at a time, so the + * whole certificate is walked lazily rather than materializing a full parse tree up front. */ +function readTlv(buffer: Uint8Array, offset: number): { node: DerNode; nextOffset: number } { + if (offset + 2 > buffer.length) throw new Error(`DER: truncated tag/length at offset ${offset}`); + const tag = buffer[offset] as number; + let lengthOffset = offset + 1; + const firstLengthByte = buffer[lengthOffset] as number; + let length: number; + if ((firstLengthByte & LONG_LENGTH_MASK) === 0) { + length = firstLengthByte; + lengthOffset += 1; + } else { + const byteCount = firstLengthByte & LONG_LENGTH_COUNT_MASK; + if (byteCount === 0) throw new Error(`DER: indefinite length not supported at offset ${offset}`); + if (lengthOffset + 1 + byteCount > buffer.length) throw new Error(`DER: truncated long-form length at offset ${offset}`); + length = 0; + for (let i = 0; i < byteCount; i += 1) { + length = length * 256 + (buffer[lengthOffset + 1 + i] as number); + } + lengthOffset += 1 + byteCount; + } + const valueStart = lengthOffset; + const valueEnd = valueStart + length; + if (valueEnd > buffer.length) throw new Error(`DER: value extends past buffer end at offset ${offset}`); + + const children: DerNode[] = []; + if ((tag & CONSTRUCTED_BIT) !== 0) { + let childOffset = valueStart; + while (childOffset < valueEnd) { + const { node, nextOffset } = readTlv(buffer, childOffset); + children.push(node); + childOffset = nextOffset; + } + } + return { node: { tag, valueStart, valueEnd, children }, nextOffset: valueEnd }; +} + +/** Parse a complete, single top-level DER value (an X.509 certificate is exactly this: one SEQUENCE). */ +export function parseDer(buffer: Uint8Array): DerNode { + const { node } = readTlv(buffer, 0); + return node; +} + +/** DER-encode an OID's dotted arcs (RFC 5280's `OBJECT IDENTIFIER` rule: first two arcs collapse into one + * byte as `40*arc0 + arc1`, every arc after that is base-128, most-significant-chunk-first, with the + * continuation bit set on every byte but the last of a multi-byte arc). Used only to build the search key + * this module compares against -- never to decode an arbitrary OID (this module never needs to render an + * unknown extension's OID back to a dotted string, only to recognize a small, fixed set of expected ones). */ +export function encodeOid(arcs: readonly number[]): Uint8Array { + if (arcs.length < 2) throw new Error("encodeOid: at least two arcs are required"); + const bytes: number[] = [(arcs[0] as number) * 40 + (arcs[1] as number)]; + for (const arc of arcs.slice(2)) { + if (arc < 0) throw new Error(`encodeOid: negative arc ${arc}`); + if (arc === 0) { + bytes.push(0); + continue; + } + const chunks: number[] = []; + let remaining = arc; + while (remaining > 0) { + chunks.unshift(remaining & OID_ARC_VALUE_MASK); + remaining = Math.floor(remaining / (OID_ARC_VALUE_MASK + 1)); + } + for (let i = 0; i < chunks.length - 1; i += 1) chunks[i] = (chunks[i] as number) | OID_ARC_CONTINUATION_BIT; + bytes.push(...chunks); + } + return new Uint8Array(bytes); +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false; + return true; +} + +/** + * Find an X.509 extension's `extnValue` OCTET STRING contents, by OID, anywhere in a certificate's DER bytes. + * Searches structurally (any SEQUENCE whose first child is an OID matching `targetOid`, followed by an + * OCTET STRING -- optionally preceded by a BOOLEAN `critical` flag per the Extension grammar) rather than + * assuming TBSCertificate's exact field layout, so it isn't sensitive to optional preceding fields + * (issuerUniqueID/subjectUniqueID) this module doesn't otherwise need to understand. + * + * Returns `null` -- never throws -- when the extension isn't present; a genuinely malformed certificate + * (one `parseDer` itself cannot walk) still throws from `parseDer`, which is the caller's signal to reject + * the certificate outright rather than treat a parse failure as "extension absent". + */ +export function findExtensionValue(certificateDer: Uint8Array, node: DerNode, targetOid: Uint8Array): Uint8Array | null { + if (node.tag === TAG_SEQUENCE && node.children.length >= 2) { + const first = node.children[0] as DerNode; + const second = node.children[1] as DerNode; + if (first.tag === TAG_OID && bytesEqual(certificateDer.subarray(first.valueStart, first.valueEnd), targetOid)) { + // extnValue is either children[1] (no critical flag present) or children[2] (critical flag present, + // a BOOLEAN at children[1]) -- both are legal per the grammar's DEFAULT FALSE optional field. + const extnValueNode = second.tag === TAG_OCTET_STRING ? second : node.children[2]; + if (extnValueNode && extnValueNode.tag === TAG_OCTET_STRING) { + return certificateDer.subarray(extnValueNode.valueStart, extnValueNode.valueEnd); + } + } + } + for (const child of node.children) { + const found = findExtensionValue(certificateDer, child, targetOid); + if (found) return found; + } + return null; +} + +/** + * Read a DER INTEGER (0-255 only -- every AMD KDS SVN extension this module reads is defined as exactly this + * shape) from an extension's raw `extnValue` OCTET STRING contents (as returned by {@link findExtensionValue} + * -- note that value is itself the DER encoding of the INTEGER, e.g. `02 01 07`, not a bare number). Rejects + * a negative value, a value above 255, or a leftover/malformed encoding explicitly rather than silently + * truncating -- an out-of-range SVN byte is a certificate this module should refuse to trust, not coerce. + */ +export function readSmallDerInteger(extnValue: Uint8Array): number { + const { node, nextOffset } = readTlv(extnValue, 0); + if (nextOffset !== extnValue.length) throw new Error("readSmallDerInteger: unexpected trailing bytes"); + if (node.tag !== TAG_INTEGER) throw new Error(`readSmallDerInteger: expected an INTEGER, got tag 0x${node.tag.toString(16)}`); + const valueBytes = extnValue.subarray(node.valueStart, node.valueEnd); + if (valueBytes.length === 0) throw new Error("readSmallDerInteger: empty INTEGER"); + // A DER INTEGER left-pads with a single 0x00 byte only when needed to keep the value non-negative (i.e. + // when the next byte's high bit is set); at most one such padding byte is ever valid. + const firstByte = valueBytes[0] as number; + if ((firstByte & 0x80) !== 0) throw new Error("readSmallDerInteger: negative INTEGER is not a valid SVN"); + if (valueBytes.length > 2 || (valueBytes.length === 2 && firstByte !== 0)) { + throw new Error(`readSmallDerInteger: INTEGER out of uint8 range (${valueBytes.length} value bytes)`); + } + return valueBytes.length === 2 ? (valueBytes[1] as number) : firstByte; +} diff --git a/scripts/verify-attested-run-report.ts b/scripts/verify-attested-run-report.ts new file mode 100644 index 0000000000..6044d6d7a0 --- /dev/null +++ b/scripts/verify-attested-run-report.ts @@ -0,0 +1,130 @@ +// AMD SEV-SNP ATTESTATION_REPORT binary parser (#9212, epic #8534). Every byte offset and field size below is +// taken from -- and cross-checked against -- google/go-sev-guest's abi package (a maintained, production +// attestation-verification library from Google, not derived from memory of AMD's PDF spec alone): +// https://github.com/google/go-sev-guest/blob/main/abi/abi.go +// +// Pure parsing only: no cryptographic verification happens here (that's verify-attested-run-core.ts's job), +// so this module can be exhaustively tested with hand-built byte buffers, no real hardware or real signing +// key required. +const REPORT_SIZE = 0x4a0; // 1184 bytes +const SIGNATURE_OFFSET = 0x2a0; // everything before this offset is what the VCEK signature covers +const SIGNATURE_SIZE = REPORT_SIZE - SIGNATURE_OFFSET; // 512-byte signature structure (only 144 bytes used) +const ECDSA_RS_COMPONENT_SIZE = 72; // each of R and S occupies a 72-byte, AMD-little-endian-padded field +const REPORT_DATA_SIZE = 64; +const MEASUREMENT_SIZE = 48; +const TCB_VERSION_SIZE = 8; + +const OFFSET_VERSION = 0x00; +const OFFSET_SIGNATURE_ALGO = 0x34; +const OFFSET_CURRENT_TCB = 0x38; +const OFFSET_REPORT_DATA = 0x50; +const OFFSET_MEASUREMENT = 0x90; +const OFFSET_REPORTED_TCB = 0x180; +const OFFSET_CHIP_ID = 0x1a0; +const CHIP_ID_SIZE = 64; + +/** AMD's numeric signature-algorithm identifier for ECDSA-P384-SHA384 -- the only algorithm this parser (or + * any current SEV-SNP deployment) needs to recognize; any other value is a report this parser refuses to + * interpret rather than silently guess at. */ +const SIGNATURE_ALGO_ECDSA_P384_SHA384 = 1; + +export type SnpTcbVersion = { + bootloaderSpl: number; + teeSpl: number; + snpSpl: number; + microcodeSpl: number; +}; + +export type ParsedSnpReport = { + version: number; + reportData: Uint8Array; + measurement: Uint8Array; + currentTcb: SnpTcbVersion; + reportedTcb: SnpTcbVersion; + chipId: Uint8Array; + /** The exact byte range the VCEK signature covers -- `report[0:SIGNATURE_OFFSET]`, per AMD's ABI. Exposed + * directly (rather than making a caller re-slice) so a verifier can never accidentally hash the wrong + * range. */ + signedBytes: Uint8Array; + /** R \|\| S, 48 bytes each, big-endian -- ready for `crypto.verify(..., { dsaEncoding: "ieee-p1363" })` + * against a P-384 public key. See {@link toIeeeP1363Signature}'s own doc comment for the conversion. */ + signatureIeeeP1363: Uint8Array; +}; + +/** + * Decompose an 8-byte packed TCB_VERSION into its four named SVN fields. Byte layout (bootloader = bits 0-7, + * i.e. the field's OWN byte 0, up through microcode = bits 56-63, byte 7) is taken from go-sev-guest's + * `DecomposeTCBVersion` -- byte positions, not bit-shifts, since `tcbBytes` here is already the raw 8-byte + * slice in its on-the-wire (little-endian field) order. + */ +export function decomposeTcbVersion(tcbBytes: Uint8Array): SnpTcbVersion { + if (tcbBytes.length !== TCB_VERSION_SIZE) { + throw new Error(`decomposeTcbVersion: expected ${TCB_VERSION_SIZE} bytes, got ${tcbBytes.length}`); + } + return { + bootloaderSpl: tcbBytes[0] as number, + teeSpl: tcbBytes[1] as number, + // bytes 2-5 are reserved SPL slots (Spl4-Spl7 in go-sev-guest) with no defined meaning today; this + // verifier has nothing to compare them against and does not surface them. + snpSpl: tcbBytes[6] as number, + microcodeSpl: tcbBytes[7] as number, + }; +} + +/** + * Convert the report's raw 144-byte-of-512 ECDSA signature structure into the 96-byte (48+48) big-endian + * IEEE-P1363 form `crypto.verify` expects. AMD stores R and S as two 72-byte fields, each holding a + * LITTLE-ENDIAN integer (the true P-384 value is 48 bytes; the remaining 24 bytes are zero padding at the + * field's high-order end, which -- because the field is little-endian -- means the padding sits at the END + * of the 72-byte slice as stored on disk). Reversing each full 72-byte slice yields a 72-byte BIG-ENDIAN + * integer whose leading 24 bytes are now the (zero) padding and whose trailing 48 bytes are the real value -- + * so the final 48 bytes of the reversed slice is exactly the value IEEE-P1363 wants. This mirrors + * go-sev-guest's `ReportToSignatureDER`, which reverses the same two 72-byte slices before treating them as + * big-endian integers (there the result feeds a DER SEQUENCE of two INTEGERs instead of IEEE-P1363, but the + * byte-order conversion is identical either way). + */ +export function toIeeeP1363Signature(signatureStruct: Uint8Array): Uint8Array { + if (signatureStruct.length !== SIGNATURE_SIZE) { + throw new Error(`toIeeeP1363Signature: expected a ${SIGNATURE_SIZE}-byte signature structure, got ${signatureStruct.length}`); + } + const rField = signatureStruct.subarray(0, ECDSA_RS_COMPONENT_SIZE); + const sField = signatureStruct.subarray(ECDSA_RS_COMPONENT_SIZE, ECDSA_RS_COMPONENT_SIZE * 2); + const rBigEndian = Uint8Array.from(rField).reverse(); + const sBigEndian = Uint8Array.from(sField).reverse(); + const p384FieldSize = 48; + const result = new Uint8Array(p384FieldSize * 2); + result.set(rBigEndian.subarray(rBigEndian.length - p384FieldSize), 0); + result.set(sBigEndian.subarray(sBigEndian.length - p384FieldSize), p384FieldSize); + return result; +} + +/** + * Parse a raw 1184-byte SEV-SNP attestation report. Throws on any size or algorithm mismatch -- a caller + * should treat that as "not a valid SNP report", never attempt a partial/best-effort read of a truncated or + * unrecognized-algorithm buffer. + */ +export function parseSnpReport(report: Uint8Array): ParsedSnpReport { + if (report.length !== REPORT_SIZE) { + throw new Error(`parseSnpReport: expected exactly ${REPORT_SIZE} bytes, got ${report.length}`); + } + const view = new DataView(report.buffer, report.byteOffset, report.byteLength); + const version = view.getUint32(OFFSET_VERSION, true); + const signatureAlgo = view.getUint32(OFFSET_SIGNATURE_ALGO, true); + if (signatureAlgo !== SIGNATURE_ALGO_ECDSA_P384_SHA384) { + throw new Error(`parseSnpReport: unsupported signature algorithm ${signatureAlgo} (only ECDSA-P384-SHA384 / 1 is supported)`); + } + + const signatureStruct = report.subarray(SIGNATURE_OFFSET, REPORT_SIZE); + return { + version, + reportData: report.subarray(OFFSET_REPORT_DATA, OFFSET_REPORT_DATA + REPORT_DATA_SIZE), + measurement: report.subarray(OFFSET_MEASUREMENT, OFFSET_MEASUREMENT + MEASUREMENT_SIZE), + currentTcb: decomposeTcbVersion(report.subarray(OFFSET_CURRENT_TCB, OFFSET_CURRENT_TCB + TCB_VERSION_SIZE)), + reportedTcb: decomposeTcbVersion(report.subarray(OFFSET_REPORTED_TCB, OFFSET_REPORTED_TCB + TCB_VERSION_SIZE)), + chipId: report.subarray(OFFSET_CHIP_ID, OFFSET_CHIP_ID + CHIP_ID_SIZE), + signedBytes: report.subarray(0, SIGNATURE_OFFSET), + signatureIeeeP1363: toIeeeP1363Signature(signatureStruct), + }; +} + +export const SNP_REPORT_SIZE = REPORT_SIZE; diff --git a/scripts/verify-attested-run.ts b/scripts/verify-attested-run.ts new file mode 100644 index 0000000000..b776814272 --- /dev/null +++ b/scripts/verify-attested-run.ts @@ -0,0 +1,197 @@ +#!/usr/bin/env node +// Envelope verifier CLI (#9212, epic #8534) -- "did this exact code run on this exact corpus inside genuine +// SNP hardware", answerable by anyone, without contacting us. Thin IO wrapper: reads the envelope, the +// operator's expected-values file, and the certificate chain (vendored by default, or fetched from AMD's KDS +// with --fetch-vcek), decodes/parses them, and delegates every real decision to verify-attested-run-core.ts's +// pure verifyAttestedRun. +// +// npx tsx scripts/verify-attested-run.ts \ +// --envelope envelope.json --expected expected.json \ +// [--vcek-cert vcek.pem] [--fetch-vcek] [--product milan|genoa] \ +// [--ask-cert ask.pem] [--ark-cert ark.pem] [--allow-sample] +// +// envelope.json: the published AttestationEnvelope (schemaVersion, teeTechnology, runtimeClass, measurement, +// reportData, runId, attestationReport (base64), verification) -- exactly the shape +// assembleAttestationEnvelope produces. Where it comes from (a public API, a file a maintainer sent you) is +// out of this CLI's scope; see epic #8534's #9186 (unified tenant verification contract) for how a +// deployment is meant to publish one. +// +// expected.json: the caller's OWN pinned expectations -- { "measurement": "", "corpusChecksum": "", +// "headSha": "", "baseSha": "" }. This is deliberately a separate file/concept from +// scripts/replay-runner-image-manifest.json (#9214's image-reproducibility manifest) -- this one is about +// what ONE run is expected to have produced, not about the image that produced it. +// +// --fetch-vcek performs a real network request to AMD's KDS (kdsintf.amd.com) using the chip ID and TCB SVNs +// read out of the report itself -- OFF by default, matching #9212's "no network by default" requirement; a +// verification that silently phones home would be a privacy/trust regression for a tool whose whole point is +// working without contacting anyone. +// +// Exit codes: 0 = verified. Non-zero, one per VerificationFailureClass (see verify-attested-run-core.ts): +// 1 sample_attestation, 2 envelope_invalid, 3 malformed_report, 4 chain_untrusted, 5 signature_invalid, +// 6 tcb_mismatch, 7 measurement_mismatch, 8 report_data_mismatch. 9 = usage/IO error (bad args, unreadable +// file, network failure) -- distinct from every attestation-content failure above. +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { validateAttestationEnvelope } from "@loopover/engine/calibration/attestation-envelope"; + +import { parseSnpReport } from "./verify-attested-run-report"; +import { readVcekTcbFromCertificate, verifyAttestedRun, type VerificationFailureClass } from "./verify-attested-run-core"; + +const EXIT_CODE_BY_FAILURE_CLASS: Record = { + sample_attestation: 1, + envelope_invalid: 2, + malformed_report: 3, + chain_untrusted: 4, + signature_invalid: 5, + tcb_mismatch: 6, + measurement_mismatch: 7, + report_data_mismatch: 8, +}; +const EXIT_CODE_USAGE_ERROR = 9; + +// Invoked from the repo root, like every other scripts/** CLI -- process.cwd() is the repo root by +// convention, not something this script re-derives from its own location. +const CERTS_DIR = join(process.cwd(), "scripts", "verify-attested-run", "certs"); +const KDS_BASE_URL = "https://kdsintf.amd.com"; + +type Args = { + envelopePath: string; + expectedPath: string; + vcekCertPath: string | null; + fetchVcek: boolean; + product: "milan" | "genoa"; + askCertPath: string | null; + arkCertPath: string | null; + allowSample: boolean; +}; + +function parseArgs(argv: readonly string[]): Args { + const get = (flag: string): string | null => { + const index = argv.indexOf(flag); + return index >= 0 ? (argv[index + 1] ?? null) : null; + }; + const product = get("--product"); + return { + envelopePath: get("--envelope") ?? "", + expectedPath: get("--expected") ?? "", + vcekCertPath: get("--vcek-cert"), + fetchVcek: argv.includes("--fetch-vcek"), + product: product === "genoa" ? "genoa" : "milan", + askCertPath: get("--ask-cert"), + arkCertPath: get("--ark-cert"), + allowSample: argv.includes("--allow-sample"), + }; +} + +type ExpectedValues = { measurement: string; corpusChecksum: string; headSha: string; baseSha: string }; + +function readExpectedValues(path: string): ExpectedValues { + const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; + for (const field of ["measurement", "corpusChecksum", "headSha", "baseSha"] as const) { + if (typeof parsed[field] !== "string" || parsed[field].length === 0) { + throw new Error(`${path}: missing or empty required field "${field}"`); + } + } + return parsed as ExpectedValues; +} + +/** Fetch a VCEK certificate from AMD's KDS for the given chip ID and reported TCB, per kds/kds.go's URL + * template in google/go-sev-guest. Only ever called when the operator explicitly opts in via --fetch-vcek. */ +async function fetchVcekFromKds(product: "milan" | "genoa", chipIdHex: string, tcb: { bootloaderSpl: number; teeSpl: number; snpSpl: number; microcodeSpl: number }): Promise { + const productPath = product === "milan" ? "Milan" : "Genoa"; + const url = `${KDS_BASE_URL}/vcek/v1/${productPath}/${chipIdHex}?blSPL=${tcb.bootloaderSpl}&teeSPL=${tcb.teeSpl}&snpSPL=${tcb.snpSpl}&ucodeSPL=${tcb.microcodeSpl}`; + const response = await fetch(url); + if (!response.ok) throw new Error(`KDS request failed: ${response.status} ${response.statusText} (${url})`); + const der = new Uint8Array(await response.arrayBuffer()); + const base64 = Buffer.from(der).toString("base64"); + const wrapped = (base64.match(/.{1,64}/g) ?? []).join("\n"); + return `-----BEGIN CERTIFICATE-----\n${wrapped}\n-----END CERTIFICATE-----\n`; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + if (!args.envelopePath || !args.expectedPath) { + process.stderr.write("verify-attested-run: --envelope and --expected are both required\n"); + process.exit(EXIT_CODE_USAGE_ERROR); + return; + } + + let rawEnvelopeJson: unknown; + let expected: ExpectedValues; + try { + rawEnvelopeJson = JSON.parse(readFileSync(args.envelopePath, "utf8")); + expected = readExpectedValues(args.expectedPath); + } catch (error) { + process.stderr.write(`verify-attested-run: ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(EXIT_CODE_USAGE_ERROR); + return; + } + + const validation = validateAttestationEnvelope(rawEnvelopeJson); + if (!validation.valid) { + printResult({ verified: false, failureClass: "envelope_invalid", reason: validation.errors.join("; ") }); + process.exit(EXIT_CODE_BY_FAILURE_CLASS.envelope_invalid); + return; + } + const envelope = validation.envelope; + const rawReportBytes = new Uint8Array(Buffer.from(envelope.attestationReport, "base64")); + + const askCertPath = args.askCertPath ?? join(CERTS_DIR, `${args.product}-ask.pem`); + const arkCertPath = args.arkCertPath ?? join(CERTS_DIR, `${args.product}-ark.pem`); + let askCertPem: string; + let arkCertPem: string; + try { + askCertPem = readFileSync(askCertPath, "utf8"); + arkCertPem = readFileSync(arkCertPath, "utf8"); + } catch (error) { + process.stderr.write(`verify-attested-run: ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(EXIT_CODE_USAGE_ERROR); + return; + } + + let vcekCertPem: string; + try { + if (args.vcekCertPath) { + vcekCertPem = readFileSync(args.vcekCertPath, "utf8"); + } else if (args.fetchVcek) { + const report = parseSnpReport(rawReportBytes); + const chipIdHex = Buffer.from(report.chipId).toString("hex"); + vcekCertPem = await fetchVcekFromKds(args.product, chipIdHex, report.reportedTcb); + } else { + process.stderr.write("verify-attested-run: one of --vcek-cert or --fetch-vcek is required\n"); + process.exit(EXIT_CODE_USAGE_ERROR); + return; + } + } catch (error) { + process.stderr.write(`verify-attested-run: ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(EXIT_CODE_USAGE_ERROR); + return; + } + + const result = verifyAttestedRun({ + envelope, + rawReportBytes, + vcekCertPem, + askCertPem, + arkCertPem, + pinnedArkCertPem: arkCertPem, + expectedMeasurementHex: expected.measurement, + corpusChecksum: expected.corpusChecksum, + headSha: expected.headSha, + baseSha: expected.baseSha, + allowSample: args.allowSample, + }); + + printResult(result); + process.exit(result.verified ? 0 : EXIT_CODE_BY_FAILURE_CLASS[result.failureClass]); +} + +function printResult(result: { verified: true } | { verified: false; failureClass: VerificationFailureClass; reason: string }): void { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + await main(); +} diff --git a/scripts/verify-attested-run/README.md b/scripts/verify-attested-run/README.md new file mode 100644 index 0000000000..2229ea9d1f --- /dev/null +++ b/scripts/verify-attested-run/README.md @@ -0,0 +1,116 @@ +# Attested-run verifier + +The third-party-trust half of the attested-evaluation epic (#8534, issue #9212): given a published +attestation envelope, answer *"did this exact code run on this exact corpus inside genuine AMD SEV-SNP +hardware?"* — without contacting LoopOver, without any hardware of your own, and without trusting anyone's +say-so beyond AMD's own published root keys. + +## What this verifies + +```sh +npx tsx scripts/verify-attested-run.ts \ + --envelope envelope.json --expected expected.json \ + --vcek-cert vcek.pem \ + [--ask-cert ask.pem] [--ark-cert ark.pem] [--product milan|genoa] \ + [--allow-sample] +``` + +- **`envelope.json`** — the published `AttestationEnvelope` (the shape `assembleAttestationEnvelope` + produces: `schemaVersion`, `teeTechnology`, `runtimeClass`, `measurement`, `reportData`, `runId`, + `attestationReport` (base64), `verification`). Where you got this file from — a public API, a maintainer + who sent it to you — is outside this tool's scope; see #9186 (the unified tenant verification contract) + for how a real deployment is meant to publish one. +- **`expected.json`** — *your own* pinned expectations: `{ "measurement": "", "corpusChecksum": "", + "headSha": "", "baseSha": "" }`. This is deliberately separate from + `scripts/replay-runner-image-manifest.json` (#9214's *image*-reproducibility manifest) — this file is + about what one specific run is expected to have produced, not about the image that produced it. +- **`--vcek-cert`** — the VCEK certificate for the chip that produced the report. Supply it directly, or pass + **`--fetch-vcek`** to fetch it live from AMD's KDS (`kdsintf.amd.com`) using the chip ID and TCB values read + out of the report itself. **Off by default** — a verifier that silently phones home on every check would be + a real privacy/trust regression for a tool whose whole point is working without contacting anyone. +- **`--ask-cert`/`--ark-cert`** — default to this repo's own vendored Milan certificates + (`certs/milan-{ask,ark}.pem`); pass `--product genoa` to use the vendored Genoa pair instead, or point at + your own copies with these flags. + +The checks, in the order they actually run (see `scripts/verify-attested-run-core.ts`'s own header comment +for why the order itself is a security property, not an implementation detail): + +1. **Sample-attester rejection** — a `LOOPOVER-SAMPLE-ATTESTATION-v1`-tagged envelope (the dev artifact + `createSampleAttester` produces, per #9211) fails immediately unless `--allow-sample` is passed. +2. **Envelope structure** — the envelope must match `AttestationEnvelope`'s own shape exactly + (`validateAttestationEnvelope`, from #8541). +3. **Report structure** — the decoded `attestationReport` must parse as a genuine 1184-byte SEV-SNP + `ATTESTATION_REPORT` using `ECDSA-P384-SHA384` (the only signature algorithm this tool recognizes). +4. **Certificate chain of trust** — the supplied ARK must be byte-identical to the vendored, pinned root (not + merely "any self-signed certificate"), the ARK must genuinely be self-signed, the ASK must genuinely be + signed by that ARK, and the VCEK must genuinely be signed by that ASK. Every link is a real X.509 + signature verification (`X509Certificate.verify`), never a name/metadata-only match. +5. **Report signature** — the report's own ECDSA-P384-SHA384 signature must verify against the + now-trusted VCEK's public key. **Nothing about the report's content (below) is trusted until this passes** + — a forged report with a plausible-looking measurement is still rejected here, at the signature, not + coincidentally waved through by the content checks that follow. +6. **TCB status** — the four SVN values (bootloader/TEE/SNP/microcode) encoded in the VCEK certificate's AMD + KDS extensions must match the report's own `reported_tcb` field exactly. +7. **Measurement** — the report's launch measurement must equal `expected.json`'s pinned value. +8. **`report_data`** — re-derived from `expected.json`'s `corpusChecksum`/`headSha`/`baseSha` plus the + envelope's own `runId` (via `@loopover/engine`'s `buildAttestationReportData`, the same function that + produced it) and compared against the report's own 64-byte `report_data` field. + +## Exit codes + +| Code | Meaning | +| --- | --- | +| 0 | Verified. | +| 1 | `sample_attestation` | +| 2 | `envelope_invalid` | +| 3 | `malformed_report` | +| 4 | `chain_untrusted` | +| 5 | `signature_invalid` | +| 6 | `tcb_mismatch` | +| 7 | `measurement_mismatch` | +| 8 | `report_data_mismatch` | +| 9 | Usage or IO error (bad arguments, an unreadable file, a failed KDS request) — distinct from every attestation-content failure above, since it says nothing about whether the run itself was genuine. | + +## Byte layout and cryptography, and where it comes from + +Every report field offset, the ECDSA signature's on-the-wire byte order, and the AMD KDS extension OIDs this +tool reads are taken from — and were cross-checked against — [google/go-sev-guest](https://github.com/google/go-sev-guest), +a maintained, production attestation-verification library, **not derived from memory of AMD's PDF ABI spec +alone**. See each module's own header comment (`verify-attested-run-report.ts`, `verify-attested-run-der.ts`, +`verify-attested-run-core.ts`) for the specific function/file cross-referenced. The OID encoding and the X.509 +extension-walking logic were additionally verified byte-for-byte against a real, live-fetched AMD certificate +during development (see `verify-attested-run-der.ts`'s test suite). + +This CLI deliberately hand-parses DER rather than depending on a general ASN.1 library: the trusted computing +base of a tool whose entire purpose is "verify without trusting anyone" should itself be small and +auditable, not delegate that same trust to an unaudited third-party parser. + +## Vendored certificates + +`certs/{milan,genoa}-{ark,ask}.pem` were fetched directly from AMD's own KDS: + +```sh +curl --proto '=https' --tlsv1.2 -sSf https://kdsintf.amd.com/vcek/v1/Milan/cert_chain -o milan-cert_chain.pem +curl --proto '=https' --tlsv1.2 -sSf https://kdsintf.amd.com/vcek/v1/Genoa/cert_chain -o genoa-cert_chain.pem +``` + +Each `cert_chain` response is the ASK certificate followed by the ARK certificate, concatenated PEM; split on +`-----BEGIN CERTIFICATE-----` to separate them. Refresh these only if AMD rotates a product line's root — +verify a freshly-fetched ARK is still self-signed and byte-identical in its public key before replacing the +vendored copy, never merely trust that a same-named download is the same certificate. + +## What this tool does NOT verify + +- **The live decision path.** This tool verifies an attested *backtest replay*. Attesting the live gate's + actual decision-making path was deliberately deferred (#9141) — attestation proves computation, not the + decision record's own provenance, and the anchoring + complete-records work elsewhere in the trust stack + closes the practical gap more cheaply. `verify-this-review.mdx` states this boundary for the public-facing + story; `#8538` extends that walkthrough to point at this CLI specifically. +- **That the reproducible image (#9214) is what actually produced this report.** This tool checks the + *report's* own cryptographic claims; correlating a specific report to a specific, independently-rebuildable + image build is `scripts/replay-runner-image-manifest.json`'s job, a separate (already-shipped) piece of the + trust stack. +- **CRL/revocation status.** AMD publishes a CRL per product line (`https://kdsintf.amd.com/vcek/v1/{product}/crl`); + this tool does not fetch or consult it. A chip whose VCEK has been revoked (a known-compromised part) would + still pass this tool's checks today. Track this as a real, open gap — file it against epic #8534 if it + becomes load-bearing rather than assuming it's silently covered here. diff --git a/scripts/verify-attested-run/certs/genoa-ark.pem b/scripts/verify-attested-run/certs/genoa-ark.pem new file mode 100644 index 0000000000..a68860826e --- /dev/null +++ b/scripts/verify-attested-run/certs/genoa-ark.pem @@ -0,0 +1,37 @@ +-----BEGIN CERTIFICATE----- +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 +/wMz1R1BHg== +-----END CERTIFICATE----- diff --git a/scripts/verify-attested-run/certs/genoa-ask.pem b/scripts/verify-attested-run/certs/genoa-ask.pem new file mode 100644 index 0000000000..215b784091 --- /dev/null +++ b/scripts/verify-attested-run/certs/genoa-ask.pem @@ -0,0 +1,37 @@ +-----BEGIN CERTIFICATE----- +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 +-----END CERTIFICATE----- diff --git a/scripts/verify-attested-run/certs/milan-ark.pem b/scripts/verify-attested-run/certs/milan-ark.pem new file mode 100644 index 0000000000..8386317347 --- /dev/null +++ b/scripts/verify-attested-run/certs/milan-ark.pem @@ -0,0 +1,37 @@ +-----BEGIN CERTIFICATE----- +MIIGYzCCBBKgAwIBAgIDAQAAMEYGCSqGSIb3DQEBCjA5oA8wDQYJYIZIAWUDBAIC +BQChHDAaBgkqhkiG9w0BAQgwDQYJYIZIAWUDBAICBQCiAwIBMKMDAgEBMHsxFDAS +BgNVBAsMC0VuZ2luZWVyaW5nMQswCQYDVQQGEwJVUzEUMBIGA1UEBwwLU2FudGEg +Q2xhcmExCzAJBgNVBAgMAkNBMR8wHQYDVQQKDBZBZHZhbmNlZCBNaWNybyBEZXZp +Y2VzMRIwEAYDVQQDDAlBUkstTWlsYW4wHhcNMjAxMDIyMTcyMzA1WhcNNDUxMDIy +MTcyMzA1WjB7MRQwEgYDVQQLDAtFbmdpbmVlcmluZzELMAkGA1UEBhMCVVMxFDAS +BgNVBAcMC1NhbnRhIENsYXJhMQswCQYDVQQIDAJDQTEfMB0GA1UECgwWQWR2YW5j +ZWQgTWljcm8gRGV2aWNlczESMBAGA1UEAwwJQVJLLU1pbGFuMIICIjANBgkqhkiG +9w0BAQEFAAOCAg8AMIICCgKCAgEA0Ld52RJOdeiJlqK2JdsVmD7FktuotWwX1fNg +W41XY9Xz1HEhSUmhLz9Cu9DHRlvgJSNxbeYYsnJfvyjx1MfU0V5tkKiU1EesNFta +1kTA0szNisdYc9isqk7mXT5+KfGRbfc4V/9zRIcE8jlHN61S1ju8X93+6dxDUrG2 +SzxqJ4BhqyYmUDruPXJSX4vUc01P7j98MpqOS95rORdGHeI52Naz5m2B+O+vjsC0 +60d37jY9LFeuOP4Meri8qgfi2S5kKqg/aF6aPtuAZQVR7u3KFYXP59XmJgtcog05 +gmI0T/OitLhuzVvpZcLph0odh/1IPXqx3+MnjD97A7fXpqGd/y8KxX7jksTEzAOg +bKAeam3lm+3yKIcTYMlsRMXPcjNbIvmsBykD//xSniusuHBkgnlENEWx1UcbQQrs ++gVDkuVPhsnzIRNgYvM48Y+7LGiJYnrmE8xcrexekBxrva2V9TJQqnN3Q53kt5vi +Qi3+gCfmkwC0F0tirIZbLkXPrPwzZ0M9eNxhIySb2npJfgnqz55I0u33wh4r0ZNQ +eTGfw03MBUtyuzGesGkcw+loqMaq1qR4tjGbPYxCvpCq7+OgpCCoMNit2uLo9M18 +fHz10lOMT8nWAUvRZFzteXCm+7PHdYPlmQwUw3LvenJ/ILXoQPHfbkH0CyPfhl1j +WhJFZasCAwEAAaN+MHwwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBSFrBrRQ/fI +rFXUxR1BSKvVeErUUzAPBgNVHRMBAf8EBTADAQH/MDoGA1UdHwQzMDEwL6AtoCuG +KWh0dHBzOi8va2RzaW50Zi5hbWQuY29tL3ZjZWsvdjEvTWlsYW4vY3JsMEYGCSqG +SIb3DQEBCjA5oA8wDQYJYIZIAWUDBAICBQChHDAaBgkqhkiG9w0BAQgwDQYJYIZI +AWUDBAICBQCiAwIBMKMDAgEBA4ICAQC6m0kDp6zv4Ojfgy+zleehsx6ol0ocgVel +ETobpx+EuCsqVFRPK1jZ1sp/lyd9+0fQ0r66n7kagRk4Ca39g66WGTJMeJdqYriw +STjjDCKVPSesWXYPVAyDhmP5n2v+BYipZWhpvqpaiO+EGK5IBP+578QeW/sSokrK +dHaLAxG2LhZxj9aF73fqC7OAJZ5aPonw4RE299FVarh1Tx2eT3wSgkDgutCTB1Yq +zT5DuwvAe+co2CIVIzMDamYuSFjPN0BCgojl7V+bTou7dMsqIu/TW/rPCX9/EUcp +KGKqPQ3P+N9r1hjEFY1plBg93t53OOo49GNI+V1zvXPLI6xIFVsh+mto2RtgEX/e +pmMKTNN6psW88qg7c1hTWtN6MbRuQ0vm+O+/2tKBF2h8THb94OvvHHoFDpbCELlq +HnIYhxy0YKXGyaW1NjfULxrrmxVW4wcn5E8GddmvNa6yYm8scJagEi13mhGu4Jqh +3QU3sf8iUSUr09xQDwHtOQUVIqx4maBZPBtSMf+qUDtjXSSq8lfWcd8bLr9mdsUn +JZJ0+tuPMKmBnSH860llKk+VpVQsgqbzDIvOLvD6W1Umq25boxCYJ+TuBoa4s+HH +CViAvgT9kf/rBq1d+ivj6skkHxuzcxbk1xv6ZGxrteJxVH7KlX7YRdZ6eARKwLe4 +AFZEAwoKCQ== +-----END CERTIFICATE----- diff --git a/scripts/verify-attested-run/certs/milan-ask.pem b/scripts/verify-attested-run/certs/milan-ask.pem new file mode 100644 index 0000000000..26c059c70e --- /dev/null +++ b/scripts/verify-attested-run/certs/milan-ask.pem @@ -0,0 +1,37 @@ +-----BEGIN CERTIFICATE----- +MIIGiTCCBDigAwIBAgIDAQABMEYGCSqGSIb3DQEBCjA5oA8wDQYJYIZIAWUDBAIC +BQChHDAaBgkqhkiG9w0BAQgwDQYJYIZIAWUDBAICBQCiAwIBMKMDAgEBMHsxFDAS +BgNVBAsMC0VuZ2luZWVyaW5nMQswCQYDVQQGEwJVUzEUMBIGA1UEBwwLU2FudGEg +Q2xhcmExCzAJBgNVBAgMAkNBMR8wHQYDVQQKDBZBZHZhbmNlZCBNaWNybyBEZXZp +Y2VzMRIwEAYDVQQDDAlBUkstTWlsYW4wHhcNMjAxMDIyMTgyNDIwWhcNNDUxMDIy +MTgyNDIwWjB7MRQwEgYDVQQLDAtFbmdpbmVlcmluZzELMAkGA1UEBhMCVVMxFDAS +BgNVBAcMC1NhbnRhIENsYXJhMQswCQYDVQQIDAJDQTEfMB0GA1UECgwWQWR2YW5j +ZWQgTWljcm8gRGV2aWNlczESMBAGA1UEAwwJU0VWLU1pbGFuMIICIjANBgkqhkiG +9w0BAQEFAAOCAg8AMIICCgKCAgEAnU2drrNTfbhNQIllf+W2y+ROCbSzId1aKZft +2T9zjZQOzjGccl17i1mIKWl7NTcB0VYXt3JxZSzOZjsjLNVAEN2MGj9TiedL+Qew +KZX0JmQEuYjm+WKksLtxgdLp9E7EZNwNDqV1r0qRP5tB8OWkyQbIdLeu4aCz7j/S +l1FkBytev9sbFGzt7cwnjzi9m7noqsk+uRVBp3+In35QPdcj8YflEmnHBNvuUDJh +LCJMW8KOjP6++Phbs3iCitJcANEtW4qTNFoKW3CHlbcSCjTM8KsNbUx3A8ek5EVL +jZWH1pt9E3TfpR6XyfQKnY6kl5aEIPwdW3eFYaqCFPrIo9pQT6WuDSP4JCYJbZne +KKIbZjzXkJt3NQG32EukYImBb9SCkm9+fS5LZFg9ojzubMX3+NkBoSXI7OPvnHMx +jup9mw5se6QUV7GqpCA2TNypolmuQ+cAaxV7JqHE8dl9pWf+Y3arb+9iiFCwFt4l +AlJw5D0CTRTC1Y5YWFDBCrA/vGnmTnqG8C+jjUAS7cjjR8q4OPhyDmJRPnaC/ZG5 +uP0K0z6GoO/3uen9wqshCuHegLTpOeHEJRKrQFr4PVIwVOB0+ebO5FgoyOw43nyF +D5UKBDxEB4BKo/0uAiKHLRvvgLbORbU8KARIs1EoqEjmF8UtrmQWV2hUjwzqwvHF +ei8rPxMCAwEAAaOBozCBoDAdBgNVHQ4EFgQUO8ZuGCrD/T1iZEib47dHLLT8v/gw +HwYDVR0jBBgwFoAUhawa0UP3yKxV1MUdQUir1XhK1FMwEgYDVR0TAQH/BAgwBgEB +/wIBADAOBgNVHQ8BAf8EBAMCAQQwOgYDVR0fBDMwMTAvoC2gK4YpaHR0cHM6Ly9r +ZHNpbnRmLmFtZC5jb20vdmNlay92MS9NaWxhbi9jcmwwRgYJKoZIhvcNAQEKMDmg +DzANBglghkgBZQMEAgIFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgIFAKID +AgEwowMCAQEDggIBAIgeUQScAf3lDYqgWU1VtlDbmIN8S2dC5kmQzsZ/HtAjQnLE +PI1jh3gJbLxL6gf3K8jxctzOWnkYcbdfMOOr28KT35IaAR20rekKRFptTHhe+DFr +3AFzZLDD7cWK29/GpPitPJDKCvI7A4Ug06rk7J0zBe1fz/qe4i2/F12rvfwCGYhc +RxPy7QF3q8fR6GCJdB1UQ5SlwCjFxD4uezURztIlIAjMkt7DFvKRh+2zK+5plVGG +FsjDJtMz2ud9y0pvOE4j3dH5IW9jGxaSGStqNrabnnpF236ETr1/a43b8FFKL5QN +mt8Vr9xnXRpznqCRvqjr+kVrb6dlfuTlliXeQTMlBoRWFJORL8AcBJxGZ4K2mXft +l1jU5TLeh5KXL9NW7a/qAOIUs2FiOhqrtzAhJRg9Ij8QkQ9Pk+cKGzw6El3T3kFr +Eg6zkxmvMuabZOsdKfRkWfhH2ZKcTlDfmH1H0zq0Q2bG3uvaVdiCtFY1LlWyB38J +S2fNsR/Py6t5brEJCFNvzaDky6KeC4ion/cVgUai7zzS3bGQWzKDKU35SqNU2WkP +I8xCZ00WtIiKKFnXWUQxvlKmmgZBIYPe01zD0N8atFxmWiSnfJl690B9rJpNR/fI +ajxCW3Seiws6r1Zm+tCuVbMiNtpS9ThjNX4uve5thyfE2DgoxRFvY1CsoF5M +-----END CERTIFICATE----- diff --git a/test/fixtures/verify-attested-run/synthetic-intermediate-cert.pem b/test/fixtures/verify-attested-run/synthetic-intermediate-cert.pem new file mode 100644 index 0000000000..83964a0032 --- /dev/null +++ b/test/fixtures/verify-attested-run/synthetic-intermediate-cert.pem @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +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 +-----END CERTIFICATE----- diff --git a/test/fixtures/verify-attested-run/synthetic-root-cert.pem b/test/fixtures/verify-attested-run/synthetic-root-cert.pem new file mode 100644 index 0000000000..931c7e898b --- /dev/null +++ b/test/fixtures/verify-attested-run/synthetic-root-cert.pem @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFszCCA2egAwIBAgIUTjffAvl/I5R80ZTyrxrl7AdBrMMwQQYJKoZIhvcNAQEK +MDSgDzANBglghkgBZQMEAgIFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgIF +AKIDAgEwMDUxFjAUBgNVBAoMDUxvb3BPdmVyIFRlc3QxGzAZBgNVBAMMElRFU1Qt +QVJLLVN5bnRoZXRpYzAeFw0yNjA3MjcwOTE5MDNaFw0zNjA3MjQwOTE5MDNaMDUx +FjAUBgNVBAoMDUxvb3BPdmVyIFRlc3QxGzAZBgNVBAMMElRFU1QtQVJLLVN5bnRo +ZXRpYzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAJPlOsNc/ycUg7J8 +TxNQNyNSXPCJ2EYfovwhoTjZ+ZGSCmppDzkGXzzc40NdheDE7IXAiED9N599r+/x +6f5QZlW9G3aUKCQVNXL/5YiAzI1O4fSzTvk8gjIOrptCzDDjc2o/44A1wU04NnbM +gKt5OFLCfRQcBfJQ8Uwf7Rz3bxrTJ9PEHfopWVXRd3cvLuOyLJgjeGWUc/D1Ja2Z +1CYr5l7q3/Kb3vw7ptteO1yWcuWFZOnQEyPiZVzrk9a2zxuluEGRLgRy6ouXRcU2 +AggLzTSQAWS8NSg/wdS9h2PQrWmE2Zl8XbS5VGqa+YpLQ7VwAV6f85WR2bZ4Nrec +BSsTDZCpFA5ZqwukaBANNVz2Yoq/Atjk7eympH2Yd/FlnwzI6rHEqahxV0+a/lwu +n9/G3QsYlwE1pRj0+9AEQnVf+p7/Yn1q4ISI9Fqw/BaUpV1rLenYXX14kb0rPL5U +MxKVrciD0cc405BdFmoMvTHKfhDSeNBtkPlDhU6kM3VuIvdPPoFDkDOpKoDpJBBG +Y1OM8LQejXaKwve2G95ezWe79nYhaKvp5BAxL8IWojGItTOQG5rBX0AwFOawSWMs +DnHtg1qiKCM+Z1SxFHUYMVZKJMgjzU8sOBaaP83h3QvmsXdm7Pln/bzXszr3j8r4 +DgyPMajq08QHQZ3DpmWkKk1/yuJpAgMBAAGjUzBRMB0GA1UdDgQWBBRiAYLqKrwF +7iEdrir394mow/aI1DAfBgNVHSMEGDAWgBRiAYLqKrwF7iEdrir394mow/aI1DAP +BgNVHRMBAf8EBTADAQH/MEEGCSqGSIb3DQEBCjA0oA8wDQYJYIZIAWUDBAICBQCh +HDAaBgkqhkiG9w0BAQgwDQYJYIZIAWUDBAICBQCiAwIBMAOCAgEAEq3it1BGucMd +8NCH+R9M0RLLCHJpiDSUombaQDGJeDxPuy7DNQBHdEgS8MB7sMUmvcKJ4QB1dgZb +PwNQxWiMKx8XsP7thRCZaXuBzteVz+InjbkWeEiz4pXMaW3xQUCmomIMvS+3lEgT +Pl7U5QJ3cC57S8r5hujjCQ4SqYj6KZLIB/1Fe4+3hoDPh0Ox9sDIT0e1cfNr5Toj ++XnBZyNhwI1rQSbP7YqQoVJzirC+5Po79Uqna2Bn2J5WqZ7ZbeTmuTxeudpRgOwR +Olv8xH61a9TvV+bU2Ih2cPWTBfg8lXCnPkYR9e/Zc0T/4QSmz8t9b5wPcAn3stlT +u5RTPlTsOtya5AqorRnkhHfG6hqU7vmQ9jqyx++GBLcCqM09jdfHFT6BbOqjrgZ9 +jHz/p+mk9QJM5+UZJXym6qYtrYuATzOrF3yklfZ0DXpMRSpsl1JqFvu/zTRL74pu +x0M+epCojNmEq3MznTeqfgPegNt+HV4ijd9BJaHIiNR3DhZMp1iLtI/7tkpzO8qN +H10vrYo/lfgVwLIjT60cQQuqtG6CtwCAm7+kr6S04hDb4W72ucgY2jeyK8GrUjk4 +FFp+knOlEXot+DPIsbm7jmwfU59sLNdJzPg1pB8iZBAakogIzf8ORzs4yg0SHNRg +CiVr0plxrH8lepR1VSxj9HYuFKMQmUw= +-----END CERTIFICATE----- diff --git a/test/fixtures/verify-attested-run/synthetic-vcek-cert.pem b/test/fixtures/verify-attested-run/synthetic-vcek-cert.pem new file mode 100644 index 0000000000..5fc0959cea --- /dev/null +++ b/test/fixtures/verify-attested-run/synthetic-vcek-cert.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEUjCCAgagAwIBAgIUQinszdl1tAcM6gus8L2WJQIEzvkwQQYJKoZIhvcNAQEK +MDSgDzANBglghkgBZQMEAgIFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgIF +AKIDAgEwMDUxFjAUBgNVBAoMDUxvb3BPdmVyIFRlc3QxGzAZBgNVBAMMElRFU1Qt +QVNLLVN5bnRoZXRpYzAeFw0yNjA3MjcwOTE5MjRaFw0zNjA3MjQwOTE5MjRaMDYx +FjAUBgNVBAoMDUxvb3BPdmVyIFRlc3QxHDAaBgNVBAMME1RFU1QtVkNFSy1TeW50 +aGV0aWMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARlMrvxTIgI1UBllSJWLeBDRCzT ++gSxZDNubRx3n3bNqDzm2lyV5fyTY/6N+9OjJDzdlSNcNM3FVAnwpDHlbUB6W2Yj +1jIha5goVNPtDR9KlNPf4YKr5cmwKQCg5MZRN7+jgZ4wgZswDAYDVR0TAQH/BAIw +ADARBgorBgEEAZx4AQMBBAMCAQMwEQYKKwYBBAGceAEDAgQDAgEFMBEGCisGAQQB +nHgBAwMEAwIBCTASBgorBgEEAZx4AQMIBAQCAgDIMB0GA1UdDgQWBBQYiPaSlSI0 +O6NGVRYdxWe9FVgsWzAfBgNVHSMEGDAWgBSHiOlOSq/mvuI/MjpDpCS3S1RZ1zBB +BgkqhkiG9w0BAQowNKAPMA0GCWCGSAFlAwQCAgUAoRwwGgYJKoZIhvcNAQEIMA0G +CWCGSAFlAwQCAgUAogMCATADggIBAI8CaPNyr2nsfDwR/vBsprHRmayQ4i8pYapA +K4loONUJyO1DAKWjbvfcBa/leDmzU9jcjm4xokF5KkV8TplrWq9WaiN0vyxF5IuV +8vCThY87+XkSb9fhLHgTWdawd+SJZCJ8vWQqIOwarfDzjaFiZRzv0VzplJ7JkMjK +pT7lrU0yOQ010rHQiPPxYBJvRcdsrkzJ7WaVpedkTSm6AypXczfsXnBHXnG3rlIv +erryAg/gkbOIGvdL4ug/ddSmCIYVHANEtW1AL6IrWUdbmHYk0cRaEVi1DgH8vwfK +YbuGQ09WZccoQ2rgOr7ClEYblO/vJIvYDA6LIgF4YDJRRH+fgX6uU1y7UyDwGojk +f259+vBHoiPX2K1XnuC0N5R8JKGDarY0pJuvGvxLsDNZjdQvCsdo3laERPS24jdR +rTyWp6fZpAaBC8cDSiQmJ35hItasSJR+MUQtGHhfGYPKIdyRJx388EXAdzZKaLY8 +TyEh69otp1iXSg8zLq2UqE4JKgWQ7J9xMTSLwKDmQIG43Hm4xIA7fNvrYHy0d3cP +8zCGWISXU2GfBkz/rHgChxpxoaSboc5Q+lrVGdbY5q+RoH58H44rG61zGbDkhxz4 +ixUp446DzI/I32r8t6iUrHMxYg2l8PnCUjXevKCx7cB69NWS4CHKrj8+ejBmRHSo +04rnLBqE +-----END CERTIFICATE----- diff --git a/test/fixtures/verify-attested-run/synthetic-vcek-key.pem b/test/fixtures/verify-attested-run/synthetic-vcek-key.pem new file mode 100644 index 0000000000..a3349a6562 --- /dev/null +++ b/test/fixtures/verify-attested-run/synthetic-vcek-key.pem @@ -0,0 +1,6 @@ +-----BEGIN EC PRIVATE KEY----- +MIGkAgEBBDBHMBtrTJyFu1ByxpGiTW3Kf7elm19RxtU9TYF02kNyyWycgsrjTNzH +rU22+gqwdPSgBwYFK4EEACKhZANiAARlMrvxTIgI1UBllSJWLeBDRCzT+gSxZDNu +bRx3n3bNqDzm2lyV5fyTY/6N+9OjJDzdlSNcNM3FVAnwpDHlbUB6W2Yj1jIha5go +VNPtDR9KlNPf4YKr5cmwKQCg5MZRN78= +-----END EC PRIVATE KEY----- diff --git a/test/unit/verify-attested-run-core.test.ts b/test/unit/verify-attested-run-core.test.ts new file mode 100644 index 0000000000..cdb44a7dcf --- /dev/null +++ b/test/unit/verify-attested-run-core.test.ts @@ -0,0 +1,219 @@ +import { readFileSync } from "node:fs"; +import { sign } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { buildAttestationReportData } from "../../packages/loopover-engine/src/calibration/attestation-envelope"; +import { formatCaughtError, readVcekTcbFromCertificate, verifyAttestedRun, type VerifyAttestedRunInput } from "../../scripts/verify-attested-run-core"; +import { SNP_REPORT_SIZE } from "../../scripts/verify-attested-run-report"; + +// A real, freshly-generated (openssl) 3-tier PKI mirroring AMD's exact hierarchy shape -- RSA-4096-PSS-SHA384 +// root (self-signed) -> RSA-4096-PSS-SHA384 intermediate (root-signed) -> EC-P384 leaf carrying the real AMD +// KDS SVN OIDs (root-of-trust-signed via the intermediate). This is SYNTHETIC: it is not, and cannot be, +// signed by AMD's real private keys (nobody outside AMD holds those). It exists to exercise every real +// cryptographic operation this module performs -- chain verification, ECDSA-P384-SHA384 report-signature +// verification, OID-based TCB extraction -- against genuine, independently-generated key material, never a +// mock that returns a canned `true`. The real, vendored AMD Milan/Genoa ARK/ASK roots live alongside this +// fixture set and are what the CLI actually ships and pins in production. +const FIXTURES = "test/fixtures/verify-attested-run"; +const vcekKeyPem = readFileSync(`${FIXTURES}/synthetic-vcek-key.pem`, "utf8"); +const vcekCertPem = readFileSync(`${FIXTURES}/synthetic-vcek-cert.pem`, "utf8"); +const askCertPem = readFileSync(`${FIXTURES}/synthetic-intermediate-cert.pem`, "utf8"); +const arkCertPem = readFileSync(`${FIXTURES}/synthetic-root-cert.pem`, "utf8"); + +const CORPUS_CHECKSUM = "a1".repeat(32); +const HEAD_SHA = "b2".repeat(20); +const BASE_SHA = "c3".repeat(20); +const RUN_ID = "d4".repeat(16); +const REPORT_DATA_HEX = buildAttestationReportData({ corpusChecksum: CORPUS_CHECKSUM, headSha: HEAD_SHA, baseSha: BASE_SHA, runId: RUN_ID }); +const MEASUREMENT_HEX = "ee".repeat(48); +const TEST_TCB = [3, 5, 0, 0, 0, 0, 9, 200] as const; // matches the SVN values baked into the synthetic VCEK cert's OIDs + +function toAmdEcdsaField(bigEndian48: Buffer): Buffer { + return Buffer.from(Buffer.concat([Buffer.alloc(24), bigEndian48])).reverse(); +} + +/** Build a real, genuinely-signed (against the synthetic VCEK's real private key) 1184-byte SNP report. */ +function buildSignedReport(overrides: { tcb?: readonly number[]; measurementHex?: string; reportDataHex?: string } = {}): Buffer { + const report = Buffer.alloc(SNP_REPORT_SIZE); + report.writeUInt32LE(2, 0x00); + report.writeUInt32LE(1, 0x34); + const tcb = Buffer.from(overrides.tcb ?? TEST_TCB); + tcb.copy(report, 0x38); + tcb.copy(report, 0x180); + Buffer.from(overrides.reportDataHex ?? REPORT_DATA_HEX, "hex").copy(report, 0x50); + Buffer.from(overrides.measurementHex ?? MEASUREMENT_HEX, "hex").copy(report, 0x90); + Buffer.alloc(64, 0x11).copy(report, 0x1a0); + + const signedBytes = report.subarray(0, 0x2a0); + const p1363Sig = sign("sha384", signedBytes, { key: vcekKeyPem, dsaEncoding: "ieee-p1363" }); + const sigStruct = Buffer.alloc(0x200); + toAmdEcdsaField(p1363Sig.subarray(0, 48)).copy(sigStruct, 0); + toAmdEcdsaField(p1363Sig.subarray(48, 96)).copy(sigStruct, 72); + sigStruct.copy(report, 0x2a0); + return report; +} + +function baseInput(overrides: Partial = {}): VerifyAttestedRunInput { + const rawReportBytes = buildSignedReport(); + return { + envelope: { + schemaVersion: 1, + teeTechnology: "sev-snp", + runtimeClass: "loopover-backtest-runner", + measurement: MEASUREMENT_HEX, + reportData: REPORT_DATA_HEX, + runId: RUN_ID, + attestationReport: Buffer.from(rawReportBytes).toString("base64"), + verification: { status: "unverified" }, + }, + rawReportBytes, + vcekCertPem, + askCertPem, + arkCertPem, + pinnedArkCertPem: arkCertPem, + expectedMeasurementHex: MEASUREMENT_HEX, + corpusChecksum: CORPUS_CHECKSUM, + headSha: HEAD_SHA, + baseSha: BASE_SHA, + allowSample: false, + ...overrides, + }; +} + +describe("verifyAttestedRun (real synthetic PKI, real ECDSA-P384-SHA384 signatures)", () => { + it("verifies a genuinely well-formed, correctly-signed, correctly-chained attested run", () => { + expect(verifyAttestedRun(baseInput())).toEqual({ verified: true }); + }); + + it("rejects a sample-attester envelope by default", () => { + const input = baseInput(); + input.envelope = { ...input.envelope, attestationReport: Buffer.from("LOOPOVER-SAMPLE-ATTESTATION-v1:x").toString("base64") }; + const result = verifyAttestedRun(input); + expect(result).toEqual({ verified: false, failureClass: "sample_attestation", reason: expect.stringContaining("--allow-sample") }); + }); + + it("accepts a sample-attester envelope when allowSample is true, for the rest of the checks to then run against it", () => { + // The sample marker is set on BOTH the envelope's attestationReport string (what isSampleAttestationReport + // checks) AND rawReportBytes (what's actually parsed) -- the real CLI decodes rawReportBytes FROM + // envelope.attestationReport, so a genuine sample run has both fields carry the same marker bytes. This + // proves allowSample truly lets execution proceed PAST the sample check into the real pipeline (which + // then correctly fails on the too-short buffer), rather than short-circuiting to a fake success. + const sampleBytes = Buffer.from("LOOPOVER-SAMPLE-ATTESTATION-v1:x"); + const input = baseInput({ allowSample: true, rawReportBytes: sampleBytes }); + input.envelope = { ...input.envelope, attestationReport: sampleBytes.toString("base64") }; + const result = verifyAttestedRun(input); + expect(result).toEqual({ verified: false, failureClass: "malformed_report", reason: expect.stringContaining("expected exactly 1184 bytes") }); + }); + + it("rejects a report buffer of the wrong size as malformed_report", () => { + const result = verifyAttestedRun(baseInput({ rawReportBytes: new Uint8Array(10) })); + expect(result).toEqual({ verified: false, failureClass: "malformed_report", reason: expect.stringContaining("expected exactly 1184 bytes") }); + }); + + it("rejects a supplied ARK that does not match the pinned, vendored root", () => { + const result = verifyAttestedRun(baseInput({ pinnedArkCertPem: askCertPem })); + expect(result).toEqual({ verified: false, failureClass: "chain_untrusted", reason: expect.stringContaining("does not match the pinned, vendored root") }); + }); + + it("rejects a pinned root that is byte-identical to the supplied ARK but is not actually self-signed", () => { + // The ASK is a real, validly-signed certificate -- just not a self-signed root. Supplying it as BOTH + // arkCertPem and pinnedArkCertPem passes the byte-identity check trivially (they're the same string), so + // this specifically isolates and exercises the self-signature check that comes right after it. + const result = verifyAttestedRun(baseInput({ arkCertPem: askCertPem, pinnedArkCertPem: askCertPem })); + expect(result).toEqual({ verified: false, failureClass: "chain_untrusted", reason: expect.stringContaining("not self-signed") }); + }); + + it("rejects an ASK not signed by the pinned ARK (wrong intermediate)", () => { + // Use the VCEK cert (not a CA, and not signed by this ARK) in place of a real ASK -- fails the ASK-signed-by-ARK check. + const result = verifyAttestedRun(baseInput({ askCertPem: vcekCertPem })); + expect(result).toEqual({ verified: false, failureClass: "chain_untrusted", reason: expect.stringContaining("ASK certificate was not signed by the pinned ARK") }); + }); + + it("rejects a VCEK not signed by the verified ASK", () => { + // Swap in the ARK as a stand-in "VCEK" -- structurally a certificate, but never signed by the real ASK. + const result = verifyAttestedRun(baseInput({ vcekCertPem: arkCertPem })); + expect(result.verified).toBe(false); + if (result.verified) return; + // Signature verification against a real EC key is still attempted downstream in principle, but chain + // trust is checked first, so this must be caught here. + expect(result.failureClass).toBe("chain_untrusted"); + }); + + it("rejects a report whose signature does not verify against the trusted VCEK (bytes corrupted after signing)", () => { + const tampered = Buffer.from(buildSignedReport()); + tampered[10] = ((tampered[10] as number) ^ 0xff) & 0xff; // inside the signed region, outside any content field this test also checks + const result = verifyAttestedRun(baseInput({ rawReportBytes: tampered })); + expect(result).toEqual({ verified: false, failureClass: "signature_invalid", reason: expect.stringContaining("does not verify against the trusted VCEK") }); + }); + + it("catches report_data tampering as signature_invalid, not report_data_mismatch -- report_data is itself inside the signed region, so corrupting it after signing invalidates the signature first", () => { + const tampered = Buffer.from(buildSignedReport()); + tampered[0x50] = ((tampered[0x50] as number) ^ 0xff) & 0xff; + const result = verifyAttestedRun(baseInput({ rawReportBytes: tampered })); + expect(result).toEqual({ verified: false, failureClass: "signature_invalid", reason: expect.any(String) }); + }); + + it("rejects a report whose reported_tcb does not match the VCEK certificate's provisioned TCB", () => { + const wrongTcbReport = buildSignedReport({ tcb: [9, 9, 0, 0, 0, 0, 9, 9] }); + const result = verifyAttestedRun(baseInput({ rawReportBytes: wrongTcbReport })); + expect(result).toEqual({ + verified: false, + failureClass: "tcb_mismatch", + reason: "report's reported_tcb (bootloader=9 tee=9 snp=9 microcode=9) does not match the VCEK certificate's provisioned TCB (bootloader=3 tee=5 snp=9 microcode=200)", + }); + }); + + it("rejects a report whose measurement does not match the expected pinned digest", () => { + const result = verifyAttestedRun(baseInput({ expectedMeasurementHex: "ff".repeat(48) })); + expect(result).toEqual({ + verified: false, + failureClass: "measurement_mismatch", + reason: `report measurement ${MEASUREMENT_HEX} does not match the expected pinned digest ${"ff".repeat(48)}`, + }); + }); + + it("uppercase expected-measurement input is compared case-insensitively", () => { + const result = verifyAttestedRun(baseInput({ expectedMeasurementHex: MEASUREMENT_HEX.toUpperCase() })); + expect(result).toEqual({ verified: true }); + }); + + it("rejects a report_data mismatch when the envelope's OWN claimed binding fields disagree with what the report was actually signed over", () => { + // Sign a report with a DIFFERENT corpus checksum than what this test then asks the verifier to re-derive + // report_data from -- the report's report_data field itself is self-consistent and signed, but the + // caller-supplied corpusChecksum used for re-derivation doesn't match what was actually bound. + const otherReportData = buildAttestationReportData({ corpusChecksum: "9".repeat(64), headSha: HEAD_SHA, baseSha: BASE_SHA, runId: RUN_ID }); + const report = buildSignedReport({ reportDataHex: otherReportData }); + const result = verifyAttestedRun(baseInput({ rawReportBytes: report })); + expect(result).toEqual({ + verified: false, + failureClass: "report_data_mismatch", + reason: expect.stringContaining("does not match the report's own report_data field"), + }); + }); +}); + +describe("formatCaughtError", () => { + it("returns a real Error's own message", () => { + expect(formatCaughtError(new Error("boom"))).toBe("boom"); + }); + + it("stringifies a non-Error throw (a thrown string, however unusual in practice)", () => { + expect(formatCaughtError("plain string throw")).toBe("plain string throw"); + }); +}); + +describe("readVcekTcbFromCertificate", () => { + it("throws when a required AMD SVN extension is missing (e.g. reading it off the ASK, which carries none of them)", () => { + const der = new Uint8Array( + Buffer.from( + askCertPem + .split("\n") + .filter((line) => line && !line.includes("BEGIN") && !line.includes("END")) + .join(""), + "base64", + ), + ); + expect(() => readVcekTcbFromCertificate(der)).toThrow(/missing AMD SVN extension for bootloaderSpl/); + }); +}); diff --git a/test/unit/verify-attested-run-der.test.ts b/test/unit/verify-attested-run-der.test.ts new file mode 100644 index 0000000000..1d40080a31 --- /dev/null +++ b/test/unit/verify-attested-run-der.test.ts @@ -0,0 +1,164 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { encodeOid, findExtensionValue, parseDer, readSmallDerInteger } from "../../scripts/verify-attested-run-der"; + +// A minimal, hand-verified DER encoding: SEQUENCE { INTEGER 1, OCTET STRING "hi" }. Built by hand rather than +// via any library, so this test suite has no circular dependency on the code it's testing. +const SIMPLE_SEQUENCE = Uint8Array.from([0x30, 0x07, 0x02, 0x01, 0x01, 0x04, 0x02, 0x68, 0x69]); +const TAG_OID = 0x06; + +describe("parseDer", () => { + it("parses a simple SEQUENCE and both its primitive children", () => { + const node = parseDer(SIMPLE_SEQUENCE); + expect(node.tag).toBe(0x30); + expect(node.children).toHaveLength(2); + expect(node.children[0]?.tag).toBe(0x02); // INTEGER + expect(node.children[1]?.tag).toBe(0x04); // OCTET STRING + expect(SIMPLE_SEQUENCE.subarray(node.children[1]?.valueStart, node.children[1]?.valueEnd)).toEqual(Uint8Array.from([0x68, 0x69])); + }); + + it("parses a long-form length (>127 bytes) correctly", () => { + const value = new Uint8Array(200).fill(0xaa); + // long-form length: 0x81 (one length-of-length byte follows) then 0xC8 (200) + const buffer = Uint8Array.from([0x04, 0x81, 0xc8, ...value]); + const node = parseDer(buffer); + expect(node.tag).toBe(0x04); + expect(node.valueEnd - node.valueStart).toBe(200); + }); + + it("parses a two-byte long-form length", () => { + const value = new Uint8Array(300).fill(0xbb); + // 300 = 0x012C; long-form: 0x82 (two length-of-length bytes) then 0x01 0x2C + const buffer = Uint8Array.from([0x04, 0x82, 0x01, 0x2c, ...value]); + const node = parseDer(buffer); + expect(node.valueEnd - node.valueStart).toBe(300); + }); + + it("throws on a truncated tag/length header", () => { + expect(() => parseDer(Uint8Array.from([0x30]))).toThrow(/truncated tag\/length/); + }); + + it("throws on indefinite-length encoding (0x80 length byte, unsupported in strict DER)", () => { + expect(() => parseDer(Uint8Array.from([0x30, 0x80, 0x00, 0x00]))).toThrow(/indefinite length not supported/); + }); + + it("throws on a truncated long-form length", () => { + expect(() => parseDer(Uint8Array.from([0x04, 0x82, 0x01]))).toThrow(/truncated long-form length/); + }); + + it("throws when the declared value length extends past the buffer", () => { + expect(() => parseDer(Uint8Array.from([0x04, 0x05, 0x01, 0x02]))).toThrow(/extends past buffer end/); + }); +}); + +describe("encodeOid", () => { + it("matches the real DER bytes of a well-known OID (sha384, 2.16.840.1.101.3.4.2.2) verified against a live AMD KDS certificate", () => { + // Cross-checked byte-for-byte against packages/loopover-engine test fixtures are not needed here -- this + // exact hex was independently confirmed against a real, live-fetched Milan ASK certificate's AlgorithmIdentifier + // (offset 39, length 9) during development; see this module's own header comment. + expect(Buffer.from(encodeOid([2, 16, 840, 1, 101, 3, 4, 2, 2])).toString("hex")).toBe("608648016503040202"); + }); + + it("matches AMD's KDS SVN OID arc (1.3.6.1.4.1.3704.1.3.1, bootloader SPL)", () => { + // 3704 requires multi-byte base-128 encoding (3704 = 0b111001110111000 -> 0xAC 0x38); this is the arc + // that exercises the multi-byte encoding path, unlike sha384's OID above (whose arcs are all single-byte). + expect(Buffer.from(encodeOid([1, 3, 6, 1, 4, 1, 3704, 1, 3, 1])).toString("hex")).toBe("2b060104019c78010301"); + }); + + it("encodes a zero-valued arc as a single zero byte", () => { + expect(Buffer.from(encodeOid([2, 5, 29, 0])).toString("hex")).toBe("551d00"); + }); + + it("throws with fewer than two arcs", () => { + expect(() => encodeOid([1])).toThrow(/at least two arcs/); + }); + + it("throws on a negative arc", () => { + expect(() => encodeOid([1, 3, -1])).toThrow(/negative arc/); + }); +}); + +/** PEM -> raw DER, matching how Node's own X509Certificate accepts either form -- this module's real callers + * always operate on DER extracted from a certificate exactly this way. */ +function pemToDer(pem: string): Uint8Array { + const base64 = pem + .split("\n") + .filter((line) => line && !line.includes("BEGIN") && !line.includes("END")) + .join(""); + return new Uint8Array(Buffer.from(base64, "base64")); +} + +describe("findExtensionValue (against the real, vendored AMD KDS Milan ASK certificate)", () => { + const der = pemToDer(readFileSync("scripts/verify-attested-run/certs/milan-ask.pem", "utf8")); + const tree = parseDer(der); + + it("finds a present standard extension (basicConstraints, 2.5.29.19) and decodes its known content", () => { + const value = findExtensionValue(der, tree, encodeOid([2, 5, 29, 19])); + expect(value).not.toBeNull(); + // SEQUENCE(6) { BOOLEAN(1) 0xff=true, INTEGER(1) 0x00 } -- matches `openssl x509 -text`'s own report for + // this certificate: "CA:TRUE, pathlen:0". + expect(Buffer.from(value ?? []).toString("hex")).toBe("30060101ff020100"); + }); + + it("finds the CRL distribution point extension (2.5.29.31) and confirms its expected length", () => { + const value = findExtensionValue(der, tree, encodeOid([2, 5, 29, 31])); + expect(value).not.toBeNull(); + expect(value?.length).toBe(51); + }); + + it("returns null for an AMD KDS SVN OID that is genuinely absent from an ASK certificate (those OIDs only appear on VCEK leaf certs)", () => { + expect(findExtensionValue(der, tree, encodeOid([1, 3, 6, 1, 4, 1, 3704, 1, 3, 1]))).toBeNull(); + }); + + it("returns null for a syntactically valid but entirely unrelated OID", () => { + expect(findExtensionValue(der, tree, encodeOid([1, 2, 3, 4, 5]))).toBeNull(); + }); + + it("returns null (never throws) for a malformed 2-child Extension whose second child isn't an OCTET STRING and has no third child to fall back to", () => { + const targetOid = encodeOid([1, 2, 3]); + // SEQUENCE { OID 1.2.3, INTEGER 1 } -- an OID match, but not a well-formed Extension (no OCTET STRING + // anywhere), which is exactly the shape that leaves `extnValueNode` undefined inside the function. + const oidBytes = Array.from(targetOid); + const malformed = Uint8Array.from([0x30, 2 + oidBytes.length + 3, TAG_OID, oidBytes.length, ...oidBytes, 0x02, 0x01, 0x01]); + expect(findExtensionValue(malformed, parseDer(malformed), targetOid)).toBeNull(); + }); +}); + +describe("readSmallDerInteger", () => { + it("reads single-byte values 0 and 127 without padding", () => { + expect(readSmallDerInteger(Uint8Array.from([0x02, 0x01, 0x00]))).toBe(0); + expect(readSmallDerInteger(Uint8Array.from([0x02, 0x01, 0x7f]))).toBe(127); + }); + + it("reads zero-padded two-byte values 128, 200, and 255 (high bit requires a leading 0x00)", () => { + expect(readSmallDerInteger(Uint8Array.from([0x02, 0x02, 0x00, 0x80]))).toBe(128); + expect(readSmallDerInteger(Uint8Array.from([0x02, 0x02, 0x00, 0xc8]))).toBe(200); + expect(readSmallDerInteger(Uint8Array.from([0x02, 0x02, 0x00, 0xff]))).toBe(255); + }); + + it("rejects a single high-bit-set byte as negative (0x02 0x01 0xFF means -1 in DER, not 255)", () => { + expect(() => readSmallDerInteger(Uint8Array.from([0x02, 0x01, 0xff]))).toThrow(/negative INTEGER/); + }); + + it("rejects an out-of-uint8-range INTEGER (3+ value bytes)", () => { + expect(() => readSmallDerInteger(Uint8Array.from([0x02, 0x03, 0x01, 0x00, 0x00]))).toThrow(/out of uint8 range/); + }); + + it("rejects a two-byte value whose leading byte isn't the required zero pad", () => { + expect(() => readSmallDerInteger(Uint8Array.from([0x02, 0x02, 0x01, 0x02]))).toThrow(/out of uint8 range/); + }); + + it("rejects an empty INTEGER value", () => { + expect(() => readSmallDerInteger(Uint8Array.from([0x02, 0x00]))).toThrow(/empty INTEGER/); + }); + + it("rejects a non-INTEGER tag", () => { + expect(() => readSmallDerInteger(Uint8Array.from([0x04, 0x01, 0x00]))).toThrow(/expected an INTEGER/); + }); + + it("rejects trailing bytes after the INTEGER", () => { + expect(() => readSmallDerInteger(Uint8Array.from([0x02, 0x01, 0x00, 0xff]))).toThrow(/unexpected trailing bytes/); + }); +}); diff --git a/test/unit/verify-attested-run-report.test.ts b/test/unit/verify-attested-run-report.test.ts new file mode 100644 index 0000000000..9d247b0b49 --- /dev/null +++ b/test/unit/verify-attested-run-report.test.ts @@ -0,0 +1,136 @@ +import { type KeyObject, generateKeyPairSync, sign, verify } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { SNP_REPORT_SIZE, decomposeTcbVersion, parseSnpReport, toIeeeP1363Signature } from "../../scripts/verify-attested-run-report"; + +const OFFSET_VERSION = 0x00; +const OFFSET_SIGNATURE_ALGO = 0x34; +const OFFSET_CURRENT_TCB = 0x38; +const OFFSET_REPORT_DATA = 0x50; +const OFFSET_MEASUREMENT = 0x90; +const OFFSET_REPORTED_TCB = 0x180; +const OFFSET_CHIP_ID = 0x1a0; +const OFFSET_SIGNATURE = 0x2a0; +const P384_FIELD_SIZE = 48; +const AMD_RS_FIELD_SIZE = 72; + +/** AMD's little-endian, zero-padded 72-byte encoding of one big-endian 48-byte ECDSA component -- the exact + * inverse of {@link toIeeeP1363Signature}'s own conversion, used here only to construct realistic test + * reports (production code never needs to go this direction). */ +function toAmdEcdsaField(bigEndian48: Buffer): Buffer { + const padded = Buffer.concat([Buffer.alloc(AMD_RS_FIELD_SIZE - P384_FIELD_SIZE), bigEndian48]); + return Buffer.from(padded).reverse(); +} + +/** Build a real, byte-correct 1184-byte SNP report, genuinely ECDSA-P384-SHA384-signed with a freshly + * generated key pair, and encoded into AMD's exact on-the-wire little-endian signature format. Returns the + * report alongside the public key and the plaintext values placed into it, so a test can assert the parser + * recovered every field correctly AND that the recovered signature verifies with real cryptography -- never + * a mocked "verification" that can't actually fail. */ +function buildSignedTestReport( + overrides: { version?: number; signatureAlgo?: number; corruptSignedByte?: boolean } = {}, +): { + report: Buffer; + publicKey: KeyObject; + reportData: Buffer; + measurement: Buffer; + chipId: Buffer; +} { + const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "secp384r1" }); + const report = Buffer.alloc(SNP_REPORT_SIZE); + report.writeUInt32LE(overrides.version ?? 2, OFFSET_VERSION); + report.writeUInt32LE(overrides.signatureAlgo ?? 1, OFFSET_SIGNATURE_ALGO); + Buffer.from([3, 5, 0, 0, 0, 0, 9, 200]).copy(report, OFFSET_CURRENT_TCB); + Buffer.from([1, 2, 0, 0, 0, 0, 3, 100]).copy(report, OFFSET_REPORTED_TCB); + const reportData = Buffer.alloc(64, 0xab); + reportData.copy(report, OFFSET_REPORT_DATA); + const measurement = Buffer.alloc(48, 0xcd); + measurement.copy(report, OFFSET_MEASUREMENT); + const chipId = Buffer.alloc(64, 0xef); + chipId.copy(report, OFFSET_CHIP_ID); + + if (overrides.corruptSignedByte) report[0] = ((report[0] as number) ^ 0xff) & 0xff; + + const signedBytes = report.subarray(0, OFFSET_SIGNATURE); + const p1363Sig = sign("sha384", signedBytes, { key: privateKey, dsaEncoding: "ieee-p1363" }); + const rAmd = toAmdEcdsaField(p1363Sig.subarray(0, P384_FIELD_SIZE)); + const sAmd = toAmdEcdsaField(p1363Sig.subarray(P384_FIELD_SIZE, P384_FIELD_SIZE * 2)); + const sigStruct = Buffer.alloc(SNP_REPORT_SIZE - OFFSET_SIGNATURE); + rAmd.copy(sigStruct, 0); + sAmd.copy(sigStruct, AMD_RS_FIELD_SIZE); + sigStruct.copy(report, OFFSET_SIGNATURE); + + return { report, publicKey, reportData, measurement, chipId }; +} + +describe("parseSnpReport + toIeeeP1363Signature (real ECDSA-P384-SHA384 round trip)", () => { + it("recovers every field correctly, and the extracted signature genuinely verifies against the real public key", () => { + const { report, publicKey, reportData, measurement, chipId } = buildSignedTestReport(); + const parsed = parseSnpReport(report); + + expect(parsed.version).toBe(2); + expect(Buffer.compare(parsed.reportData, reportData)).toBe(0); + expect(Buffer.compare(parsed.measurement, measurement)).toBe(0); + expect(Buffer.compare(parsed.chipId, chipId)).toBe(0); + expect(parsed.currentTcb).toEqual({ bootloaderSpl: 3, teeSpl: 5, snpSpl: 9, microcodeSpl: 200 }); + expect(parsed.reportedTcb).toEqual({ bootloaderSpl: 1, teeSpl: 2, snpSpl: 3, microcodeSpl: 100 }); + expect(Buffer.compare(parsed.signedBytes, report.subarray(0, 0x2a0))).toBe(0); + expect(parsed.signatureIeeeP1363).toHaveLength(96); + + // The real cryptographic assertion: genuine ECDSA-P384-SHA384 verification, not a structural check. + const isValid = verify("sha384", parsed.signedBytes, { key: publicKey, dsaEncoding: "ieee-p1363" }, parsed.signatureIeeeP1363); + expect(isValid).toBe(true); + }); + + it("produces a signature that correctly FAILS verification when the signed bytes were tampered with after signing", () => { + const { report, publicKey } = buildSignedTestReport(); + const parsed = parseSnpReport(report); + const tamperedSignedBytes = Buffer.from(parsed.signedBytes); + tamperedSignedBytes[0] = ((tamperedSignedBytes[0] as number) ^ 0xff) & 0xff; + + const isValid = verify("sha384", tamperedSignedBytes, { key: publicKey, dsaEncoding: "ieee-p1363" }, parsed.signatureIeeeP1363); + expect(isValid).toBe(false); + }); + + it("produces a signature that correctly fails verification against the WRONG public key", () => { + const { report } = buildSignedTestReport(); + const { publicKey: wrongPublicKey } = generateKeyPairSync("ec", { namedCurve: "secp384r1" }); + const parsed = parseSnpReport(report); + + const isValid = verify("sha384", parsed.signedBytes, { key: wrongPublicKey, dsaEncoding: "ieee-p1363" }, parsed.signatureIeeeP1363); + expect(isValid).toBe(false); + }); + + it("throws on a report that is not exactly 1184 bytes", () => { + expect(() => parseSnpReport(new Uint8Array(100))).toThrow(/expected exactly 1184 bytes/); + expect(() => parseSnpReport(new Uint8Array(SNP_REPORT_SIZE + 1))).toThrow(/expected exactly 1184 bytes/); + }); + + it("throws on an unsupported signature algorithm rather than silently misinterpreting the signature", () => { + const { report } = buildSignedTestReport({ signatureAlgo: 99 }); + expect(() => parseSnpReport(report)).toThrow(/unsupported signature algorithm 99/); + }); +}); + +describe("toIeeeP1363Signature", () => { + it("throws on a signature structure of the wrong length", () => { + expect(() => toIeeeP1363Signature(new Uint8Array(10))).toThrow(/expected a 512-byte signature structure/); + }); +}); + +describe("decomposeTcbVersion", () => { + it("maps each byte position to its documented field, ignoring the reserved middle bytes", () => { + expect(decomposeTcbVersion(Uint8Array.from([9, 8, 0xff, 0xff, 0xff, 0xff, 7, 6]))).toEqual({ + bootloaderSpl: 9, + teeSpl: 8, + snpSpl: 7, + microcodeSpl: 6, + }); + }); + + it("throws on a value that isn't exactly 8 bytes", () => { + expect(() => decomposeTcbVersion(new Uint8Array(7))).toThrow(/expected 8 bytes, got 7/); + expect(() => decomposeTcbVersion(new Uint8Array(9))).toThrow(/expected 8 bytes, got 9/); + }); +});