create-evda 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,412 @@
1
+ // Is the VM the phone is about to talk to an Intel TDX confidential VM running EVÐA's image, with
2
+ // debug off, on a patched platform, and holding the TLS key this connection uses?
3
+ // (HQ-2083, Spec 08 section 8: "What the client checks before sending anything".)
4
+ //
5
+ // The phone runs this before it sends a hosted EVÐA anything. The claim it backs is "EVÐA's operators
6
+ // cannot read your record". It does not back "no one can": physical attacks on the server (TEE.fail,
7
+ // DDRop) sit outside what a quote can prove. This file is the reference the Swift port follows, and
8
+ // its tests run against a real production quote with its chain to Intel's real root, so the port can
9
+ // replay the same fixture.
10
+ //
11
+ // What it checks, all offline. Collateral is passed in and nothing here touches the network:
12
+ // 1. the quote is a TDX quote v4 with an ECDSA P-256 attestation key and Intel's QE vendor id;
13
+ // 2. the attestation key signs the header and TD report body;
14
+ // 3. the PCK leaf signs the QE report, and the leaf chains through Intel's PCK CA to the pinned
15
+ // Intel SGX root (lib/tdx-trust.json), every certificate valid at `now`;
16
+ // 4. the QE report's data is SHA-256(attestation key || QE auth data), so the key that signed the
17
+ // quote is the one the QE vouched for;
18
+ // 5. the QE is Intel's (QE identity: MRSIGNER, ISVPRODID, masked attributes and MISCSELECT), and
19
+ // Intel's TCB info for this FMSPC puts the platform at a level the policy allows; both
20
+ // documents are signed by Intel's TCB signing key, chained to the same root, and current at `now`;
21
+ // 6. the TD's debug bit is off;
22
+ // 7. MRTD and RTMR0 to RTMR3 equal the expected set;
23
+ // 8. the nonce is one this phone issued, unused, and young enough;
24
+ // 9. REPORTDATA equals reportDataFor(nonce, tlsPublicKey), which binds the quote to the TLS key the
25
+ // phone then pins.
26
+ //
27
+ // The binding, exactly: REPORTDATA (64 bytes) = SHA-512( "EVDA-TDX-REPORTDATA-v1" || 0x00 ||
28
+ // nonce (32 bytes) || SPKI DER of the TLS public key ). The nonce has a fixed length and SPKI DER
29
+ // carries its own length, so the concatenation is unambiguous. The VM computes the same value with
30
+ // the same function when it asks the TDX module for a quote.
31
+ //
32
+ // Not checked here yet: certificate revocation (Intel's PCK and root CRLs), quote v5 (refused as
33
+ // unknown rather than half-checked), the TD relaunch advice v5 quotes carry, and whether the expected
34
+ // measurements are themselves in Sigstore's log (lib/measurement-log.mjs checks a digest; tying the
35
+ // expected MRTD and RTMRs to a logged statement is a later slice).
36
+ //
37
+ // Every failure returns { ok: false, reason } in words; nothing a quote contains can make it throw.
38
+
39
+ import { createHash, createPublicKey, KeyObject, randomBytes, timingSafeEqual, verify as verifySig, X509Certificate } from "node:crypto";
40
+ import { readFileSync } from "node:fs";
41
+ import { derTlv as tlv, derChildren as children, derOid as oid } from "./measurement-log.mjs";
42
+
43
+ export const TDX_TRUST = JSON.parse(readFileSync(new URL("./tdx-trust.json", import.meta.url), "utf8"));
44
+
45
+ /** The domain tag in front of every REPORTDATA preimage. */
46
+ export const REPORTDATA_TAG = "EVDA-TDX-REPORTDATA-v1";
47
+
48
+ /** The measurement registers a policy must name, each 48 bytes as 96 lowercase hex characters. */
49
+ export const MEASUREMENT_NAMES = Object.freeze(["mrtd", "rtmr0", "rtmr1", "rtmr2", "rtmr3"]);
50
+
51
+ const HEADER_LEN = 48;
52
+ const BODY_LEN = 584;
53
+ const QE_REPORT_LEN = 384;
54
+ const TEE_TDX = 0x81;
55
+ const ATT_KEY_ECDSA_P256 = 2;
56
+ const CERT_QE_REPORT = 6;
57
+ const CERT_PCK_CHAIN = 5;
58
+ const OID_SGX_EXT = "1.2.840.113741.1.13.1";
59
+ const OID_SGX_TCB = `${OID_SGX_EXT}.2`;
60
+ const OID_SGX_FMSPC = `${OID_SGX_EXT}.4`;
61
+
62
+ const fail = (reason) => ({ ok: false, reason });
63
+ const hex = (b) => Buffer.from(b).toString("hex");
64
+ const sha256 = (b) => createHash("sha256").update(b).digest();
65
+
66
+ // ─── the binding ─────────────────────────────────────────────────────────────────────────────────
67
+
68
+ const spkiDer = (key) => {
69
+ if (Buffer.isBuffer(key) || key instanceof Uint8Array) return createPublicKey({ key: Buffer.from(key), format: "der", type: "spki" }).export({ type: "spki", format: "der" });
70
+ if (key instanceof KeyObject && key.type === "public") return key.export({ type: "spki", format: "der" });
71
+ return createPublicKey(key).export({ type: "spki", format: "der" });
72
+ };
73
+
74
+ /** The 64 bytes a TD must put in REPORTDATA to bind `nonce` (32 bytes) and `tlsPublicKey` to its quote. */
75
+ export function reportDataFor(nonce, tlsPublicKey) {
76
+ const n = Buffer.from(nonce);
77
+ if (n.length !== 32) throw new Error("the nonce must be 32 bytes");
78
+ return createHash("sha512").update(Buffer.concat([Buffer.from(REPORTDATA_TAG, "ascii"), Buffer.from([0]), n, spkiDer(tlsPublicKey)])).digest();
79
+ }
80
+
81
+ /**
82
+ * The phone's record of the nonces it has issued. A nonce is good once, within `ttlMs` of issue.
83
+ * `take` returns null when the nonce is fresh (and spends it), or a reason in words.
84
+ */
85
+ export function createNonceBook({ ttlMs = 60_000 } = {}) {
86
+ const issued = new Map();
87
+ const spent = new Set();
88
+ return {
89
+ issue(at = Date.now()) {
90
+ const n = randomBytes(32);
91
+ issued.set(hex(n), at);
92
+ return n;
93
+ },
94
+ take(nonce, at = Date.now()) {
95
+ const key = hex(nonce ?? []);
96
+ if (spent.has(key)) return "the nonce was already used";
97
+ if (!issued.has(key)) return "the nonce was not issued by this phone";
98
+ const issuedAt = issued.get(key);
99
+ issued.delete(key);
100
+ spent.add(key);
101
+ if (at - issuedAt > ttlMs || at < issuedAt) return `the nonce is stale (issued ${Math.round((at - issuedAt) / 1000)}s ago, limit ${ttlMs / 1000}s)`;
102
+ return null;
103
+ },
104
+ };
105
+ }
106
+
107
+ // ─── parsing ─────────────────────────────────────────────────────────────────────────────────────
108
+
109
+ /** Splits a TDX quote v4 into its parts. Throws on any length that does not add up. */
110
+ export function parseTdxQuote(raw) {
111
+ const q = Buffer.from(raw);
112
+ if (q.length < HEADER_LEN + 4) throw new Error("the quote is shorter than its header");
113
+ const version = q.readUInt16LE(0);
114
+ if (version !== 4) throw new Error(`quote version ${version} is not understood (only 4)`);
115
+ const header = {
116
+ version,
117
+ attestationKeyType: q.readUInt16LE(2),
118
+ teeType: q.readUInt32LE(4),
119
+ qeVendorId: hex(q.subarray(12, 28)),
120
+ };
121
+ if (q.length < HEADER_LEN + BODY_LEN + 4) throw new Error("the quote is truncated in its TD report body");
122
+ const b = q.subarray(HEADER_LEN, HEADER_LEN + BODY_LEN);
123
+ const at = (o, n) => b.subarray(o, o + n);
124
+ const body = {
125
+ teeTcbSvn: at(0, 16),
126
+ mrSeam: hex(at(16, 48)),
127
+ seamAttributes: hex(at(112, 8)),
128
+ tdAttributes: at(120, 8),
129
+ xfam: hex(at(128, 8)),
130
+ mrtd: hex(at(136, 48)),
131
+ mrConfigId: hex(at(184, 48)),
132
+ mrOwner: hex(at(232, 48)),
133
+ mrOwnerConfig: hex(at(280, 48)),
134
+ rtmr0: hex(at(328, 48)),
135
+ rtmr1: hex(at(376, 48)),
136
+ rtmr2: hex(at(424, 48)),
137
+ rtmr3: hex(at(472, 48)),
138
+ reportData: at(520, 64),
139
+ };
140
+ const signedLen = HEADER_LEN + BODY_LEN;
141
+ const sigLen = q.readUInt32LE(signedLen);
142
+ const s = q.subarray(signedLen + 4);
143
+ if (s.length < sigLen) throw new Error("the quote is truncated in its signature data");
144
+ if (sigLen < 64 + 64 + 6) throw new Error("the quote's signature data is too short");
145
+ const quoteSignature = s.subarray(0, 64);
146
+ const attestationKey = s.subarray(64, 128);
147
+ const certType = s.readUInt16LE(128);
148
+ const certLen = s.readUInt32LE(130);
149
+ if (certType !== CERT_QE_REPORT) throw new Error(`certification data type ${certType} is not a QE report (6)`);
150
+ const c = s.subarray(134, 134 + certLen);
151
+ if (c.length !== certLen || 134 + certLen > sigLen) throw new Error("the quote is truncated in its certification data");
152
+ if (c.length < QE_REPORT_LEN + 64 + 2) throw new Error("the QE report certification data is too short");
153
+ const qeReport = c.subarray(0, QE_REPORT_LEN);
154
+ const qeReportSignature = c.subarray(QE_REPORT_LEN, QE_REPORT_LEN + 64);
155
+ const authLen = c.readUInt16LE(QE_REPORT_LEN + 64);
156
+ let o = QE_REPORT_LEN + 66;
157
+ if (o + authLen + 6 > c.length) throw new Error("the quote is truncated in its QE auth data");
158
+ const qeAuthData = c.subarray(o, o + authLen);
159
+ o += authLen;
160
+ const innerType = c.readUInt16LE(o);
161
+ const innerLen = c.readUInt32LE(o + 2);
162
+ if (innerType !== CERT_PCK_CHAIN) throw new Error(`the QE's certification data type ${innerType} is not a PCK chain (5)`);
163
+ const chainBytes = c.subarray(o + 6, o + 6 + innerLen);
164
+ if (chainBytes.length !== innerLen) throw new Error("the quote is truncated in its PCK chain");
165
+ const r = qeReport;
166
+ return {
167
+ header,
168
+ body,
169
+ signed: q.subarray(0, signedLen),
170
+ quoteSignature,
171
+ attestationKey,
172
+ qeReport: {
173
+ raw: qeReport,
174
+ miscSelect: r.readUInt32LE(16),
175
+ attributes: r.subarray(48, 64),
176
+ mrSigner: hex(r.subarray(128, 160)),
177
+ isvProdId: r.readUInt16LE(256),
178
+ isvSvn: r.readUInt16LE(258),
179
+ reportData: r.subarray(320, 384),
180
+ },
181
+ qeReportSignature,
182
+ qeAuthData,
183
+ pckChainPem: chainBytes.toString("utf8").replace(/\0+$/, ""),
184
+ };
185
+ }
186
+
187
+ const pemCerts = (pem) => (String(pem).match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).map((p) => new X509Certificate(p));
188
+
189
+ const uint = (buf) => { let v = 0; for (const x of buf) v = v * 256 + x; return v; };
190
+
191
+ /** The SGX TCB component SVNs, PCESVN and FMSPC from a PCK certificate's Intel extension. */
192
+ export function pckExtensions(der) {
193
+ const buf = Buffer.from(der);
194
+ const tbs = children(buf, tlv(buf, 0))[0];
195
+ const extWrap = children(buf, tbs).find((c) => c.tag === 0xa3);
196
+ if (!extWrap) throw new Error("the PCK certificate has no extensions");
197
+ for (const ext of children(buf, children(buf, extWrap)[0])) {
198
+ const parts = children(buf, ext);
199
+ if (oid(buf.subarray(parts[0].start, parts[0].end)) !== OID_SGX_EXT) continue;
200
+ const value = parts.at(-1);
201
+ const raw = buf.subarray(value.start, value.end);
202
+ const out = { cpuSvn: [], pceSvn: null, fmspc: null };
203
+ for (const item of children(raw, tlv(raw, 0))) {
204
+ const [idNode, valNode] = children(raw, item);
205
+ const id = oid(raw.subarray(idNode.start, idNode.end));
206
+ if (id === OID_SGX_FMSPC) out.fmspc = hex(raw.subarray(valNode.start, valNode.end));
207
+ if (id === OID_SGX_TCB) {
208
+ for (const comp of children(raw, valNode)) {
209
+ const [cid, cval] = children(raw, comp);
210
+ const n = Number(oid(raw.subarray(cid.start, cid.end)).split(".").at(-1));
211
+ if (n >= 1 && n <= 16) out.cpuSvn[n - 1] = uint(raw.subarray(cval.start, cval.end));
212
+ if (n === 17) out.pceSvn = uint(raw.subarray(cval.start, cval.end));
213
+ }
214
+ }
215
+ }
216
+ if (out.cpuSvn.filter((v) => v !== undefined).length !== 16 || out.pceSvn === null || !out.fmspc) throw new Error("the PCK certificate's SGX extension is incomplete");
217
+ return out;
218
+ }
219
+ throw new Error("the PCK certificate has no SGX extension");
220
+ }
221
+
222
+ // ─── signatures and chains ───────────────────────────────────────────────────────────────────────
223
+
224
+ const rawP256 = (xy) => createPublicKey({ key: { kty: "EC", crv: "P-256", x: xy.subarray(0, 32).toString("base64url"), y: xy.subarray(32, 64).toString("base64url") }, format: "jwk" });
225
+ const p1363 = (data, key, sig) => verifySig("sha256", data, { key, dsaEncoding: "ieee-p1363" }, sig);
226
+
227
+ function checkChainToRoot(certs, root, at, what) {
228
+ const all = [...certs, root];
229
+ for (let i = 0; i < all.length; i++) {
230
+ const issuer = all[Math.min(i + 1, all.length - 1)];
231
+ if (!all[i].checkIssued(issuer) || !all[i].verify(issuer.publicKey)) return `the ${what} chain does not reach the pinned Intel root (breaks at ${all[i].subject.split("\n").find((l) => l.startsWith("CN=")) ?? "a certificate"})`;
232
+ if (at < new Date(all[i].validFrom) || at > new Date(all[i].validTo)) return `a certificate in the ${what} chain is not valid at ${at.toISOString()}`;
233
+ if (i > 0 && !all[i].ca) return `the ${what} chain has a non-CA certificate as an issuer`;
234
+ }
235
+ return null;
236
+ }
237
+
238
+ /** Splits an Intel PCS response into the exact signed bytes, the parsed document and the signature. */
239
+ function signedDocument(text, field) {
240
+ const t = String(text ?? "").replace(/\s+$/, "");
241
+ const m = new RegExp(`^\\{"${field}":([\\s\\S]+),"signature":"([0-9a-fA-F]{128})"\\}$`).exec(t);
242
+ if (!m) return null;
243
+ return { body: Buffer.from(m[1], "utf8"), doc: JSON.parse(m[1]), signature: Buffer.from(m[2], "hex") };
244
+ }
245
+
246
+ function checkCollateralDoc({ text, chainPem, field, root, at, what }) {
247
+ const d = signedDocument(text, field);
248
+ if (!d) return { error: `the ${what} is not an Intel PCS response` };
249
+ const certs = pemCerts(chainPem);
250
+ if (certs.length === 0) return { error: `the ${what} has no issuer chain` };
251
+ const signer = certs[0];
252
+ const chainProblem = checkChainToRoot(certs.filter((c) => !c.raw.equals(root.raw)), root, at, what);
253
+ if (chainProblem) return { error: chainProblem };
254
+ if (!p1363(d.body, signer.publicKey, d.signature)) return { error: `the ${what} signature does not verify under Intel's TCB signing key` };
255
+ const issue = Date.parse(d.doc.issueDate);
256
+ const next = Date.parse(d.doc.nextUpdate);
257
+ if (!(issue <= at.getTime() && at.getTime() <= next)) return { error: `the ${what} is not current at ${at.toISOString()} (valid ${d.doc.issueDate} to ${d.doc.nextUpdate})` };
258
+ return { doc: d.doc };
259
+ }
260
+
261
+ const masked = (value, mask) => Buffer.from(value.map((b, i) => b & mask[i]));
262
+
263
+ // Intel's TCB level algorithm (the PCS TDX TCB info spec, as go-tdx-guest implements it): the first
264
+ // level, in Intel's descending order, that the PCK's SGX SVNs, its PCESVN and the TD's TEE_TCB_SVN all
265
+ // meet. When TEE_TCB_SVN[1] names a TDX module version, indexes 0 and 1 are judged instead by the
266
+ // matching TDX module identity.
267
+ function matchTcbLevel(tcbInfo, pck, teeTcbSvn) {
268
+ const from = teeTcbSvn[1] > 0 ? 2 : 0;
269
+ for (const level of tcbInfo.tcbLevels ?? []) {
270
+ const sgx = level.tcb?.sgxtcbcomponents ?? [];
271
+ const tdx = level.tcb?.tdxtcbcomponents ?? [];
272
+ if (sgx.length !== 16 || tdx.length !== 16) continue;
273
+ if (!pck.cpuSvn.every((v, i) => v >= sgx[i].svn)) continue;
274
+ if (pck.pceSvn < level.tcb.pcesvn) continue;
275
+ if (!teeTcbSvn.every((v, i) => i < from || v >= tdx[i].svn)) continue;
276
+ return level;
277
+ }
278
+ return null;
279
+ }
280
+
281
+ function matchModuleLevel(tcbInfo, teeTcbSvn) {
282
+ const id = `TDX_${teeTcbSvn[1].toString(16).padStart(2, "0").toUpperCase()}`;
283
+ const identity = (tcbInfo.tdxModuleIdentities ?? []).find((m) => String(m.id).toUpperCase() === id);
284
+ if (!identity) return { error: `Intel's TCB info has no identity for TDX module ${id}` };
285
+ const level = (identity.tcbLevels ?? []).find((l) => teeTcbSvn[0] >= l.tcb?.isvsvn);
286
+ if (!level) return { error: `TDX module ${id} at SVN ${teeTcbSvn[0]} matches no level Intel lists` };
287
+ return { id, level };
288
+ }
289
+
290
+ /**
291
+ * Verifies a TDX quote's signatures, its chain to the pinned Intel root, the QE's identity and the
292
+ * platform's TCB level against Intel's signed collateral. Policy on the TD itself (debug, measurements,
293
+ * nonce, TLS binding) is verifyTdxAttestation's.
294
+ *
295
+ * collateral: { tcbInfo, tcbInfoIssuerChain, qeIdentity, qeIdentityIssuerChain } as Intel PCS serves them.
296
+ * minTcb: { statuses: ["UpToDate", ...], tcbEvaluationDataNumber } (statuses defaults to UpToDate only).
297
+ */
298
+ export function verifyTdxQuote({ quote, collateral, minTcb, now, trust = TDX_TRUST } = {}) {
299
+ try {
300
+ return checkQuote({ quote, collateral, minTcb, now, trust });
301
+ } catch (err) {
302
+ return fail(`the quote could not be read: ${err.message}`);
303
+ }
304
+ }
305
+
306
+ function checkQuote({ quote, collateral, minTcb, now, trust }) {
307
+ if (!quote) return fail("there is no quote");
308
+ if (now === undefined) return fail("no time was given to check certificates and collateral against");
309
+ if (!collateral?.tcbInfo || !collateral?.qeIdentity) return fail("no Intel collateral was given, so the TCB cannot be judged");
310
+ if (!minTcb || !Number.isSafeInteger(minTcb.tcbEvaluationDataNumber)) return fail("no minimum TCB was named");
311
+ const at = new Date(now);
312
+ const q = parseTdxQuote(quote);
313
+
314
+ // 1. What kind of quote this is.
315
+ if (q.header.teeType !== TEE_TDX) return fail(`the quote is for TEE type 0x${q.header.teeType.toString(16)}, not TDX`);
316
+ if (q.header.attestationKeyType !== ATT_KEY_ECDSA_P256) return fail(`attestation key type ${q.header.attestationKeyType} is not ECDSA P-256`);
317
+ if (q.header.qeVendorId !== trust.intel.qeVendorId) return fail("the quote is not from Intel's quoting enclave");
318
+
319
+ // 2. The attestation key signed the header and the TD report.
320
+ if (!p1363(q.signed, rawP256(q.attestationKey), q.quoteSignature)) return fail("the quote signature does not verify under its attestation key");
321
+
322
+ // 3. The PCK leaf signed the QE report, and chains to the pinned Intel root.
323
+ const root = new X509Certificate(Buffer.from(trust.intel.rootDer, "base64"));
324
+ const certs = pemCerts(q.pckChainPem);
325
+ if (certs.length < 2) return fail("the quote's PCK chain is incomplete");
326
+ const chain = certs.filter((c) => !c.raw.equals(root.raw));
327
+ if (chain.length !== 2) return fail("the quote's PCK chain does not end at the pinned Intel root");
328
+ const chainProblem = checkChainToRoot(chain, root, at, "PCK");
329
+ if (chainProblem) return fail(chainProblem);
330
+ const pck = chain[0];
331
+ if (pck.ca) return fail("the PCK certificate is a CA");
332
+ if (!p1363(q.qeReport.raw, pck.publicKey, q.qeReportSignature)) return fail("the QE report signature does not verify under the PCK certificate");
333
+
334
+ // 4. The QE vouched for this attestation key.
335
+ const want = Buffer.concat([sha256(Buffer.concat([q.attestationKey, q.qeAuthData])), Buffer.alloc(32)]);
336
+ if (!want.equals(q.qeReport.reportData)) return fail("the QE report does not bind the attestation key that signed the quote");
337
+
338
+ // 5. Intel's collateral: the QE is Intel's, and the platform's TCB level is one the policy allows.
339
+ const qe = checkCollateralDoc({ text: collateral.qeIdentity, chainPem: collateral.qeIdentityIssuerChain, field: "enclaveIdentity", root, at, what: "QE identity" });
340
+ if (qe.error) return fail(qe.error);
341
+ const id = qe.doc;
342
+ if (id.id !== "TD_QE") return fail(`the QE identity is for ${id.id}, not TD_QE`);
343
+ if ((q.qeReport.miscSelect & parseInt(id.miscselectMask, 16)) !== parseInt(id.miscselect, 16)) return fail("the QE's MISCSELECT does not match Intel's QE identity");
344
+ if (!masked([...q.qeReport.attributes], Buffer.from(id.attributesMask, "hex")).equals(Buffer.from(id.attributes, "hex"))) return fail("the QE's attributes do not match Intel's QE identity");
345
+ if (q.qeReport.mrSigner !== id.mrsigner.toLowerCase()) return fail("the QE was not signed by Intel (MRSIGNER differs)");
346
+ if (q.qeReport.isvProdId !== id.isvprodid) return fail("the QE's product id does not match Intel's QE identity");
347
+ const qeLevel = (id.tcbLevels ?? []).find((l) => l.tcb?.isvsvn <= q.qeReport.isvSvn);
348
+ if (qeLevel?.tcbStatus !== "UpToDate") return fail(`the QE's TCB is ${qeLevel?.tcbStatus ?? "not in Intel's list"}`);
349
+
350
+ const ti = checkCollateralDoc({ text: collateral.tcbInfo, chainPem: collateral.tcbInfoIssuerChain, field: "tcbInfo", root, at, what: "TCB info" });
351
+ if (ti.error) return fail(ti.error);
352
+ const tcbInfo = ti.doc;
353
+ if (tcbInfo.id !== "TDX" || tcbInfo.version !== 3) return fail("the TCB info is not TDX TCB info version 3");
354
+ const ext = pckExtensions(pck.raw);
355
+ if (tcbInfo.fmspc.toLowerCase() !== ext.fmspc) return fail(`the TCB info is for FMSPC ${tcbInfo.fmspc}, and this platform is ${ext.fmspc}`);
356
+ const teeTcbSvn = [...q.body.teeTcbSvn];
357
+ if (tcbInfo.tcbEvaluationDataNumber < minTcb.tcbEvaluationDataNumber) return fail(`Intel's TCB evaluation ${tcbInfo.tcbEvaluationDataNumber} is older than the minimum ${minTcb.tcbEvaluationDataNumber}`);
358
+ const level = matchTcbLevel(tcbInfo, ext, teeTcbSvn);
359
+ if (!level) return fail("the platform matches no TCB level in Intel's TCB info, so it is below every level Intel supports");
360
+ const statuses = minTcb.statuses ?? ["UpToDate"];
361
+ if (!statuses.includes(level.tcbStatus)) return fail(`the platform's TCB is ${level.tcbStatus} (${level.tcbDate}), below the minimum (${statuses.join(" or ")})`);
362
+ let module = null;
363
+ if (teeTcbSvn[1] > 0) {
364
+ const m = matchModuleLevel(tcbInfo, teeTcbSvn);
365
+ if (m.error) return fail(m.error);
366
+ if (!statuses.includes(m.level.tcbStatus)) return fail(`TDX module ${m.id} is ${m.level.tcbStatus}, below the minimum (${statuses.join(" or ")})`);
367
+ module = { id: m.id, status: m.level.tcbStatus };
368
+ }
369
+
370
+ return {
371
+ ok: true,
372
+ body: q.body,
373
+ tcb: { status: level.tcbStatus, date: level.tcbDate, evaluationDataNumber: tcbInfo.tcbEvaluationDataNumber, fmspc: ext.fmspc, module, advisoryIds: level.advisoryIDs ?? [] },
374
+ };
375
+ }
376
+
377
+ /**
378
+ * The phone's whole check before it sends a hosted EVÐA anything. Returns { ok: true, measurements,
379
+ * tcb, tlsPublicKeySha256 } (the hash is the key the phone pins), or { ok: false, reason }.
380
+ *
381
+ * nonce: the 32 bytes the phone sent; nonces: the createNonceBook that issued it (spent here, pass or fail).
382
+ * tlsPublicKey: the TLS server key of this connection (KeyObject, PEM, or SPKI DER).
383
+ * expected: { mrtd, rtmr0, rtmr1, rtmr2, rtmr3 } as hex. Every one must be named.
384
+ */
385
+ export function verifyTdxAttestation({ quote, nonce, nonces, tlsPublicKey, expected, minTcb, collateral, now, trust = TDX_TRUST } = {}) {
386
+ try {
387
+ if (!nonces?.take) return fail("no nonce book was given, so freshness cannot be checked");
388
+ const at = now === undefined ? undefined : new Date(now).getTime();
389
+ const stale = nonces.take(nonce, at);
390
+ if (stale) return fail(stale);
391
+ if (!tlsPublicKey) return fail("no TLS key was given, so the quote cannot be bound to this connection");
392
+ if (!expected || MEASUREMENT_NAMES.some((n) => !/^[0-9a-f]{96}$/.test(expected[n] ?? ""))) return fail(`the expected measurements must name ${MEASUREMENT_NAMES.join(", ")} as 96 hex characters each`);
393
+
394
+ const r = verifyTdxQuote({ quote, collateral, minTcb, now, trust });
395
+ if (!r.ok) return r;
396
+
397
+ // 6. Debug off. TDATTRIBUTES bit 0 is TUD.DEBUG: a debug TD lets the host read its memory.
398
+ if (r.body.tdAttributes[0] & 1) return fail("the TD runs with debug on, so its host can read its memory");
399
+
400
+ // 7. The code is the code expected.
401
+ for (const n of MEASUREMENT_NAMES) if (r.body[n] !== expected[n]) return fail(`${n.toUpperCase()} is ${r.body[n].slice(0, 16)}..., not the expected ${expected[n].slice(0, 16)}...`);
402
+
403
+ // 9. The quote is for this nonce and this TLS key.
404
+ const want = reportDataFor(nonce, tlsPublicKey);
405
+ if (!timingSafeEqual(want, r.body.reportData)) return fail("REPORTDATA does not bind this nonce and this TLS key");
406
+
407
+ const measurements = Object.fromEntries(MEASUREMENT_NAMES.map((n) => [n, r.body[n]]));
408
+ return { ok: true, measurements, tcb: r.tcb, tlsPublicKeySha256: sha256(spkiDer(tlsPublicKey)).toString("hex") };
409
+ } catch (err) {
410
+ return fail(`the attestation could not be checked: ${err.message}`);
411
+ }
412
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "_comment": "The roots a phone trusts for an Intel TDX quote (HQ-2083). Fetched 2026-10-18 from Intel and checked equal to the root go-tdx-guest pins (verify/trusted_root.pem).",
3
+ "intel": {
4
+ "source": "https://certificates.trustedservices.intel.com/Intel_SGX_Provisioning_Certification_RootCA.cer",
5
+ "subject": "CN=Intel SGX Root CA, O=Intel Corporation, L=Santa Clara, ST=CA, C=US",
6
+ "sha256": "44a0196b2b99f889b8e149e95b807a350e7424964399e885a7cbb8ccfab674d3",
7
+ "rootDer": "MIICjzCCAjSgAwIBAgIUImUM1lqdNInzg7SVUr9QGzknBqwwCgYIKoZIzj0EAwIwaDEaMBgGA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENvcnBvcmF0aW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJBgNVBAYTAlVTMB4XDTE4MDUyMTEwNDUxMFoXDTQ5MTIzMTIzNTk1OVowaDEaMBgGA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENvcnBvcmF0aW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJBgNVBAYTAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEC6nEwMDIYZOj/iPWsCzaEKi71OiOSLRFhWGjbnBVJfVnkY4u3IjkDYYL0MxO4mqsyYjlBalTVYxFP2sJBK5zlKOBuzCBuDAfBgNVHSMEGDAWgBQiZQzWWp00ifODtJVSv1AbOScGrDBSBgNVHR8ESzBJMEegRaBDhkFodHRwczovL2NlcnRpZmljYXRlcy50cnVzdGVkc2VydmljZXMuaW50ZWwuY29tL0ludGVsU0dYUm9vdENBLmRlcjAdBgNVHQ4EFgQUImUM1lqdNInzg7SVUr9QGzknBqwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwCgYIKoZIzj0EAwIDSQAwRgIhAOW/5QkR+S9CiSDcNoowLuPRLsWGf/Yi7GSX94BgwTwgAiEA4J0lrHoMs+Xo5o/sX6O9QWxHRAvZUGOdRQ7cvqRXaqI=",
8
+ "qeVendorId": "939a7233f79c4ca9940a0db3957f0607"
9
+ }
10
+ }