Skip to content

Commit 32c7d5a

Browse files
authored
feat(attest): envelope verifier CLI — AMD cert chain, TCB, measurement, report_data (#9212) (#9251)
1 parent 3a3c1c2 commit 32c7d5a

16 files changed

Lines changed: 1577 additions & 0 deletions
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// Orchestrating verification core (#9212, epic #8534): "did this exact code run on this exact corpus inside
2+
// genuine SNP hardware" -- answerable by a third party who trusts nothing but AMD's own published root keys
3+
// and the code in this file. Pure with respect to IO (the CLI reads files/network, this module only takes
4+
// already-loaded bytes and PEM strings) so every failure class below is independently, deterministically
5+
// testable with real cryptography (see this file's companion test suite -- a synthetic-but-genuine 3-tier
6+
// PKI, never a mocked "verification" that can't actually fail).
7+
//
8+
// SECURITY-CRITICAL ORDERING: nothing the report itself claims (its TCB fields, its measurement, its
9+
// report_data) is trusted until BOTH the certificate chain is trusted AND the report's own signature has
10+
// verified against that chain's VCEK. Checking any report content first would mean a forged report with a
11+
// fabricated (but plausible-looking) measurement could pass checks 6-8 below by coincidence, before ever
12+
// being rejected for the invalid signature that actually damns it.
13+
import { buildAttestationReportData } from "@loopover/engine/calibration/attestation-envelope";
14+
import { isSampleAttestationReport } from "@loopover/engine/calibration/attester";
15+
import type { AttestationEnvelope } from "@loopover/engine/calibration/attestation-envelope";
16+
import { X509Certificate, verify as cryptoVerify } from "node:crypto";
17+
18+
import { encodeOid, findExtensionValue, parseDer, readSmallDerInteger } from "./verify-attested-run-der";
19+
import { parseSnpReport, type SnpTcbVersion } from "./verify-attested-run-report";
20+
21+
export type VerificationFailureClass =
22+
| "sample_attestation"
23+
| "envelope_invalid"
24+
| "malformed_report"
25+
| "chain_untrusted"
26+
| "signature_invalid"
27+
| "tcb_mismatch"
28+
| "measurement_mismatch"
29+
| "report_data_mismatch";
30+
31+
export type VerificationResult =
32+
| { verified: true }
33+
| { verified: false; failureClass: VerificationFailureClass; reason: string };
34+
35+
export type VerifyAttestedRunInput = {
36+
envelope: AttestationEnvelope;
37+
/** Raw SNP report bytes, i.e. `Buffer.from(envelope.attestationReport, "base64")` -- decoding is the
38+
* CLI's job so this module never has to guess an encoding. */
39+
rawReportBytes: Uint8Array;
40+
vcekCertPem: string;
41+
askCertPem: string;
42+
arkCertPem: string;
43+
/** The operator's own vendored, trusted root -- compared byte-for-byte (not merely "is self-signed") against
44+
* `arkCertPem`, so a caller can never be tricked by an attacker-supplied self-signed "ARK" that isn't
45+
* actually AMD's. */
46+
pinnedArkCertPem: string;
47+
/** Hex, from the tenant/operator's own manifest -- what the launch measurement is expected to be. */
48+
expectedMeasurementHex: string;
49+
corpusChecksum: string;
50+
headSha: string;
51+
baseSha: string;
52+
allowSample: boolean;
53+
};
54+
55+
const AMD_SVN_OIDS = {
56+
bootloaderSpl: encodeOid([1, 3, 6, 1, 4, 1, 3704, 1, 3, 1]),
57+
teeSpl: encodeOid([1, 3, 6, 1, 4, 1, 3704, 1, 3, 2]),
58+
snpSpl: encodeOid([1, 3, 6, 1, 4, 1, 3704, 1, 3, 3]),
59+
microcodeSpl: encodeOid([1, 3, 6, 1, 4, 1, 3704, 1, 3, 8]),
60+
} as const;
61+
62+
function toHex(bytes: Uint8Array): string {
63+
return Buffer.from(bytes).toString("hex");
64+
}
65+
66+
function pemToDer(pem: string): Uint8Array {
67+
const base64 = pem
68+
.split("\n")
69+
.filter((line) => line.length > 0 && !line.includes("BEGIN") && !line.includes("END"))
70+
.join("");
71+
return new Uint8Array(Buffer.from(base64, "base64"));
72+
}
73+
74+
/**
75+
* Read the four AMD KDS SVN OIDs (bootloader/TEE/SNP/microcode) off a VCEK certificate's raw DER, per
76+
* kds/kds.go's OID table in google/go-sev-guest. Any missing or malformed OID is a certificate this function
77+
* refuses to interpret -- it throws rather than substituting a default SVN, since a default here would mean
78+
* silently trusting an unattested claim.
79+
*/
80+
export function readVcekTcbFromCertificate(vcekCertDer: Uint8Array): SnpTcbVersion {
81+
const tree = parseDer(vcekCertDer);
82+
const readOne = (oid: Uint8Array, name: string): number => {
83+
const value = findExtensionValue(vcekCertDer, tree, oid);
84+
if (!value) throw new Error(`readVcekTcbFromCertificate: missing AMD SVN extension for ${name}`);
85+
return readSmallDerInteger(value);
86+
};
87+
return {
88+
bootloaderSpl: readOne(AMD_SVN_OIDS.bootloaderSpl, "bootloaderSpl"),
89+
teeSpl: readOne(AMD_SVN_OIDS.teeSpl, "teeSpl"),
90+
snpSpl: readOne(AMD_SVN_OIDS.snpSpl, "snpSpl"),
91+
microcodeSpl: readOne(AMD_SVN_OIDS.microcodeSpl, "microcodeSpl"),
92+
};
93+
}
94+
95+
/** `error.message` for a real Error, `String(error)` for anything else a `catch` clause could technically
96+
* hand back (a thrown string/object, however unusual in practice) -- isolated into its own function so both
97+
* arms are independently unit-testable rather than only reachable through whatever a specific call site
98+
* happens to throw today. */
99+
export function formatCaughtError(error: unknown): string {
100+
return error instanceof Error ? error.message : String(error);
101+
}
102+
103+
function tcbVersionsEqual(a: SnpTcbVersion, b: SnpTcbVersion): boolean {
104+
return a.bootloaderSpl === b.bootloaderSpl && a.teeSpl === b.teeSpl && a.snpSpl === b.snpSpl && a.microcodeSpl === b.microcodeSpl;
105+
}
106+
107+
function formatTcb(tcb: SnpTcbVersion): string {
108+
return `bootloader=${tcb.bootloaderSpl} tee=${tcb.teeSpl} snp=${tcb.snpSpl} microcode=${tcb.microcodeSpl}`;
109+
}
110+
111+
/**
112+
* Verify an attested run end to end. Never throws for an ordinarily-invalid input (a bad signature, a
113+
* mismatched measurement, an untrusted chain) -- those are all `{ verified: false, failureClass, reason }`.
114+
* Only a structurally impossible input (certificates that don't even parse as X.509, for instance) propagates
115+
* as a thrown error, since that is a caller-side bug (malformed PEM), not a fact about the attested run.
116+
*/
117+
export function verifyAttestedRun(input: VerifyAttestedRunInput): VerificationResult {
118+
if (!input.allowSample && isSampleAttestationReport(input.envelope.attestationReport)) {
119+
return {
120+
verified: false,
121+
failureClass: "sample_attestation",
122+
reason: "envelope is sample-attested (dev artifact, not evidence) -- pass --allow-sample to accept it anyway for local development",
123+
};
124+
}
125+
126+
let report: ReturnType<typeof parseSnpReport>;
127+
try {
128+
report = parseSnpReport(input.rawReportBytes);
129+
} catch (error) {
130+
return { verified: false, failureClass: "malformed_report", reason: formatCaughtError(error) };
131+
}
132+
133+
// Chain of trust: the operator's pinned root, byte-for-byte, is what "ARK" means here -- not merely
134+
// "some self-signed certificate". ARK -> ASK -> VCEK, each link a real signature check.
135+
const pinnedArkDer = pemToDer(input.pinnedArkCertPem);
136+
const suppliedArkDer = pemToDer(input.arkCertPem);
137+
if (toHex(pinnedArkDer) !== toHex(suppliedArkDer)) {
138+
return { verified: false, failureClass: "chain_untrusted", reason: "supplied ARK certificate does not match the pinned, vendored root" };
139+
}
140+
const ark = new X509Certificate(input.arkCertPem);
141+
const ask = new X509Certificate(input.askCertPem);
142+
const vcek = new X509Certificate(input.vcekCertPem);
143+
if (!ark.verify(ark.publicKey)) {
144+
return { verified: false, failureClass: "chain_untrusted", reason: "pinned ARK certificate does not verify against its own public key (not self-signed)" };
145+
}
146+
if (!ask.verify(ark.publicKey)) {
147+
return { verified: false, failureClass: "chain_untrusted", reason: "ASK certificate was not signed by the pinned ARK" };
148+
}
149+
if (!vcek.verify(ask.publicKey)) {
150+
return { verified: false, failureClass: "chain_untrusted", reason: "VCEK certificate was not signed by the verified ASK" };
151+
}
152+
153+
// The report's own signature, checked against the now-trusted VCEK's public key -- nothing about the
154+
// report's CONTENT (below) is meaningful until this passes.
155+
const vcekPublicKeyPem = vcek.publicKey.export({ type: "spki", format: "pem" });
156+
const signatureValid = verifySnpSignature(report.signedBytes, report.signatureIeeeP1363, vcekPublicKeyPem);
157+
if (!signatureValid) {
158+
return { verified: false, failureClass: "signature_invalid", reason: "report signature does not verify against the trusted VCEK public key" };
159+
}
160+
161+
const certTcb = readVcekTcbFromCertificate(pemToDer(input.vcekCertPem));
162+
if (!tcbVersionsEqual(certTcb, report.reportedTcb)) {
163+
return {
164+
verified: false,
165+
failureClass: "tcb_mismatch",
166+
reason: `report's reported_tcb (${formatTcb(report.reportedTcb)}) does not match the VCEK certificate's provisioned TCB (${formatTcb(certTcb)})`,
167+
};
168+
}
169+
170+
const measurementHex = toHex(report.measurement);
171+
if (measurementHex !== input.expectedMeasurementHex.toLowerCase()) {
172+
return {
173+
verified: false,
174+
failureClass: "measurement_mismatch",
175+
reason: `report measurement ${measurementHex} does not match the expected pinned digest ${input.expectedMeasurementHex.toLowerCase()}`,
176+
};
177+
}
178+
179+
const expectedReportData = buildAttestationReportData({
180+
corpusChecksum: input.corpusChecksum,
181+
headSha: input.headSha,
182+
baseSha: input.baseSha,
183+
runId: input.envelope.runId,
184+
});
185+
if (toHex(report.reportData) !== expectedReportData) {
186+
return {
187+
verified: false,
188+
failureClass: "report_data_mismatch",
189+
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",
190+
};
191+
}
192+
193+
return { verified: true };
194+
}
195+
196+
/** Real ECDSA-P384-SHA384 verification, isolated into its own function purely so the core orchestration
197+
* above reads as a flat sequence of named checks. */
198+
function verifySnpSignature(signedBytes: Uint8Array, signatureIeeeP1363: Uint8Array, vcekPublicKeyPem: string | Buffer): boolean {
199+
return cryptoVerify("sha384", signedBytes, { key: vcekPublicKeyPem, dsaEncoding: "ieee-p1363" }, signatureIeeeP1363);
200+
}

scripts/verify-attested-run-der.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// Minimal DER/BER reader (#9212, epic #8534) -- ONLY what's needed to pull a named X.509 extension's raw
2+
// value out of a certificate's DER bytes, and to read a small unsigned DER INTEGER out of that value. This is
3+
// deliberately NOT a general ASN.1 library: no dependency is added for it (a smaller, auditable trusted
4+
// computing base matters more here than convenience -- this module's whole job is verifying trust, so it
5+
// should not itself require trusting an unaudited third-party parser), and every function is scoped to the
6+
// exact shapes AMD's KDS certificates actually use, per the X.509 Extension grammar (RFC 5280 §4.1):
7+
//
8+
// Extension ::= SEQUENCE { extnID OBJECT IDENTIFIER, critical BOOLEAN DEFAULT FALSE, extnValue OCTET STRING }
9+
//
10+
// Every extension value AMD's KDS defines under 1.3.6.1.4.1.3704.1.3.* (the per-component SVN fields this
11+
// module exists to read) is a DER INTEGER 0-255 wrapped in that OCTET STRING -- verified byte-for-byte against
12+
// google/go-sev-guest's kds/kds.go asn1U8 (which does the equivalent Go-side unmarshal) and against a real,
13+
// live-fetched AMD KDS certificate's raw DER bytes (the OID encoding this module's tests pin was checked
14+
// against the actual bytes in a real Milan ASK certificate's SHA-384 AlgorithmIdentifier, not derived from
15+
// memory alone).
16+
17+
const TAG_INTEGER = 0x02;
18+
const TAG_OID = 0x06;
19+
const TAG_SEQUENCE = 0x30;
20+
const TAG_OCTET_STRING = 0x04;
21+
const LONG_LENGTH_MASK = 0x80;
22+
const LONG_LENGTH_COUNT_MASK = 0x7f;
23+
const OID_ARC_CONTINUATION_BIT = 0x80;
24+
const OID_ARC_VALUE_MASK = 0x7f;
25+
const OID_ARC_SHIFT_BITS = 7;
26+
27+
/** One parsed DER TLV: `tag`, the byte range of its VALUE (not including the tag/length header), and (for a
28+
* constructed tag -- SEQUENCE, SET, or a context-specific `[N]` wrapper) its immediate children, parsed one
29+
* level deep. Primitive tags (INTEGER, OID, OCTET STRING, ...) carry no children -- their bytes are read
30+
* directly by the typed helpers below. */
31+
export type DerNode = {
32+
tag: number;
33+
valueStart: number;
34+
valueEnd: number;
35+
children: DerNode[];
36+
};
37+
38+
const CONSTRUCTED_BIT = 0x20;
39+
40+
/** Parse one DER TLV starting at `offset`. Throws on truncated or malformed length encoding -- a caller
41+
* handling untrusted input should catch and treat that as "not a valid AMD certificate", never fall back
42+
* to a partial/best-effort read. Recurses into constructed tags (bit 0x20 set) one call at a time, so the
43+
* whole certificate is walked lazily rather than materializing a full parse tree up front. */
44+
function readTlv(buffer: Uint8Array, offset: number): { node: DerNode; nextOffset: number } {
45+
if (offset + 2 > buffer.length) throw new Error(`DER: truncated tag/length at offset ${offset}`);
46+
const tag = buffer[offset] as number;
47+
let lengthOffset = offset + 1;
48+
const firstLengthByte = buffer[lengthOffset] as number;
49+
let length: number;
50+
if ((firstLengthByte & LONG_LENGTH_MASK) === 0) {
51+
length = firstLengthByte;
52+
lengthOffset += 1;
53+
} else {
54+
const byteCount = firstLengthByte & LONG_LENGTH_COUNT_MASK;
55+
if (byteCount === 0) throw new Error(`DER: indefinite length not supported at offset ${offset}`);
56+
if (lengthOffset + 1 + byteCount > buffer.length) throw new Error(`DER: truncated long-form length at offset ${offset}`);
57+
length = 0;
58+
for (let i = 0; i < byteCount; i += 1) {
59+
length = length * 256 + (buffer[lengthOffset + 1 + i] as number);
60+
}
61+
lengthOffset += 1 + byteCount;
62+
}
63+
const valueStart = lengthOffset;
64+
const valueEnd = valueStart + length;
65+
if (valueEnd > buffer.length) throw new Error(`DER: value extends past buffer end at offset ${offset}`);
66+
67+
const children: DerNode[] = [];
68+
if ((tag & CONSTRUCTED_BIT) !== 0) {
69+
let childOffset = valueStart;
70+
while (childOffset < valueEnd) {
71+
const { node, nextOffset } = readTlv(buffer, childOffset);
72+
children.push(node);
73+
childOffset = nextOffset;
74+
}
75+
}
76+
return { node: { tag, valueStart, valueEnd, children }, nextOffset: valueEnd };
77+
}
78+
79+
/** Parse a complete, single top-level DER value (an X.509 certificate is exactly this: one SEQUENCE). */
80+
export function parseDer(buffer: Uint8Array): DerNode {
81+
const { node } = readTlv(buffer, 0);
82+
return node;
83+
}
84+
85+
/** DER-encode an OID's dotted arcs (RFC 5280's `OBJECT IDENTIFIER` rule: first two arcs collapse into one
86+
* byte as `40*arc0 + arc1`, every arc after that is base-128, most-significant-chunk-first, with the
87+
* continuation bit set on every byte but the last of a multi-byte arc). Used only to build the search key
88+
* this module compares against -- never to decode an arbitrary OID (this module never needs to render an
89+
* unknown extension's OID back to a dotted string, only to recognize a small, fixed set of expected ones). */
90+
export function encodeOid(arcs: readonly number[]): Uint8Array {
91+
if (arcs.length < 2) throw new Error("encodeOid: at least two arcs are required");
92+
const bytes: number[] = [(arcs[0] as number) * 40 + (arcs[1] as number)];
93+
for (const arc of arcs.slice(2)) {
94+
if (arc < 0) throw new Error(`encodeOid: negative arc ${arc}`);
95+
if (arc === 0) {
96+
bytes.push(0);
97+
continue;
98+
}
99+
const chunks: number[] = [];
100+
let remaining = arc;
101+
while (remaining > 0) {
102+
chunks.unshift(remaining & OID_ARC_VALUE_MASK);
103+
remaining = Math.floor(remaining / (OID_ARC_VALUE_MASK + 1));
104+
}
105+
for (let i = 0; i < chunks.length - 1; i += 1) chunks[i] = (chunks[i] as number) | OID_ARC_CONTINUATION_BIT;
106+
bytes.push(...chunks);
107+
}
108+
return new Uint8Array(bytes);
109+
}
110+
111+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
112+
if (a.length !== b.length) return false;
113+
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
114+
return true;
115+
}
116+
117+
/**
118+
* Find an X.509 extension's `extnValue` OCTET STRING contents, by OID, anywhere in a certificate's DER bytes.
119+
* Searches structurally (any SEQUENCE whose first child is an OID matching `targetOid`, followed by an
120+
* OCTET STRING -- optionally preceded by a BOOLEAN `critical` flag per the Extension grammar) rather than
121+
* assuming TBSCertificate's exact field layout, so it isn't sensitive to optional preceding fields
122+
* (issuerUniqueID/subjectUniqueID) this module doesn't otherwise need to understand.
123+
*
124+
* Returns `null` -- never throws -- when the extension isn't present; a genuinely malformed certificate
125+
* (one `parseDer` itself cannot walk) still throws from `parseDer`, which is the caller's signal to reject
126+
* the certificate outright rather than treat a parse failure as "extension absent".
127+
*/
128+
export function findExtensionValue(certificateDer: Uint8Array, node: DerNode, targetOid: Uint8Array): Uint8Array | null {
129+
if (node.tag === TAG_SEQUENCE && node.children.length >= 2) {
130+
const first = node.children[0] as DerNode;
131+
const second = node.children[1] as DerNode;
132+
if (first.tag === TAG_OID && bytesEqual(certificateDer.subarray(first.valueStart, first.valueEnd), targetOid)) {
133+
// extnValue is either children[1] (no critical flag present) or children[2] (critical flag present,
134+
// a BOOLEAN at children[1]) -- both are legal per the grammar's DEFAULT FALSE optional field.
135+
const extnValueNode = second.tag === TAG_OCTET_STRING ? second : node.children[2];
136+
if (extnValueNode && extnValueNode.tag === TAG_OCTET_STRING) {
137+
return certificateDer.subarray(extnValueNode.valueStart, extnValueNode.valueEnd);
138+
}
139+
}
140+
}
141+
for (const child of node.children) {
142+
const found = findExtensionValue(certificateDer, child, targetOid);
143+
if (found) return found;
144+
}
145+
return null;
146+
}
147+
148+
/**
149+
* Read a DER INTEGER (0-255 only -- every AMD KDS SVN extension this module reads is defined as exactly this
150+
* shape) from an extension's raw `extnValue` OCTET STRING contents (as returned by {@link findExtensionValue}
151+
* -- note that value is itself the DER encoding of the INTEGER, e.g. `02 01 07`, not a bare number). Rejects
152+
* a negative value, a value above 255, or a leftover/malformed encoding explicitly rather than silently
153+
* truncating -- an out-of-range SVN byte is a certificate this module should refuse to trust, not coerce.
154+
*/
155+
export function readSmallDerInteger(extnValue: Uint8Array): number {
156+
const { node, nextOffset } = readTlv(extnValue, 0);
157+
if (nextOffset !== extnValue.length) throw new Error("readSmallDerInteger: unexpected trailing bytes");
158+
if (node.tag !== TAG_INTEGER) throw new Error(`readSmallDerInteger: expected an INTEGER, got tag 0x${node.tag.toString(16)}`);
159+
const valueBytes = extnValue.subarray(node.valueStart, node.valueEnd);
160+
if (valueBytes.length === 0) throw new Error("readSmallDerInteger: empty INTEGER");
161+
// A DER INTEGER left-pads with a single 0x00 byte only when needed to keep the value non-negative (i.e.
162+
// when the next byte's high bit is set); at most one such padding byte is ever valid.
163+
const firstByte = valueBytes[0] as number;
164+
if ((firstByte & 0x80) !== 0) throw new Error("readSmallDerInteger: negative INTEGER is not a valid SVN");
165+
if (valueBytes.length > 2 || (valueBytes.length === 2 && firstByte !== 0)) {
166+
throw new Error(`readSmallDerInteger: INTEGER out of uint8 range (${valueBytes.length} value bytes)`);
167+
}
168+
return valueBytes.length === 2 ? (valueBytes[1] as number) : firstByte;
169+
}

0 commit comments

Comments
 (0)