Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions scripts/verify-attested-run-core.ts
Original file line number Diff line number Diff line change
@@ -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<typeof parseSnpReport>;
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);
}
169 changes: 169 additions & 0 deletions scripts/verify-attested-run-der.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading