create-evda 0.1.9 → 0.1.11
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.
- package/CHANGELOG.md +95 -0
- package/README.md +12 -5
- package/bin/evda.mjs +129 -6
- package/create-evda.mjs +14 -11
- package/lib/adopt.mjs +516 -0
- package/lib/agents.mjs +1 -1
- package/lib/attested-host.mjs +287 -0
- package/lib/attested-wrap.mjs +147 -0
- package/lib/checkout-freshness.mjs +88 -0
- package/lib/circles.mjs +4 -2
- package/lib/clients.mjs +11 -0
- package/lib/connect.mjs +1 -1
- package/lib/connecting-apps.mjs +56 -0
- package/lib/connector-tools.mjs +85 -0
- package/lib/corrections.mjs +34 -0
- package/lib/credentials-migrate.mjs +120 -0
- package/lib/day-file.mjs +121 -12
- package/lib/day-writer.mjs +39 -5
- package/lib/doctor.mjs +60 -5
- package/lib/every-chat.mjs +7 -11
- package/lib/folders.mjs +2 -1
- package/lib/gather-apply.mjs +133 -24
- package/lib/gather.mjs +120 -19
- package/lib/html-notes.mjs +124 -0
- package/lib/keep-hook.mjs +84 -12
- package/lib/keychain.mjs +38 -1
- package/lib/manifest.mjs +3 -1
- package/lib/mcp-http.mjs +19 -1
- package/lib/mcp.mjs +358 -56
- package/lib/measurement-log.mjs +3 -0
- package/lib/migrate-people.mjs +54 -20
- package/lib/person-folders.mjs +90 -0
- package/lib/plugin-hook.mjs +109 -0
- package/lib/plugin.mjs +208 -0
- package/lib/presets.mjs +28 -6
- package/lib/prompt-hook.mjs +115 -4
- package/lib/publish.mjs +55 -10
- package/lib/readme.mjs +145 -0
- package/lib/record-walk.mjs +9 -0
- package/lib/record.mjs +54 -0
- package/lib/routines.mjs +13 -2
- package/lib/takeout.mjs +169 -0
- package/lib/tdx-attestation.mjs +432 -0
- package/lib/tdx-trust.json +10 -0
- package/lib/tools.mjs +669 -59
- package/lib/touches.mjs +427 -0
- package/lib/update-check.mjs +30 -0
- package/lib/yaml.mjs +8 -4
- package/package.json +2 -1
- package/templates/AGENTS.md +56 -6
- package/templates/evda-digest.md +2 -0
package/lib/takeout.mjs
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Google Takeout's sidecars (HQ-1368). Takeout writes a `.json` file beside each photo and each Keep
|
|
2
|
+
// note, holding what the app knew about it: when a photo was taken, its caption, when a note was made
|
|
3
|
+
// and last edited. Gathered as files of their own they became captures while their photo or note went
|
|
4
|
+
// somewhere else (the 2026-09-27 trial). So a gather pairs each sidecar with the file it describes,
|
|
5
|
+
// folds the sidecar's date and caption into that file's entry, and never places the sidecar itself.
|
|
6
|
+
//
|
|
7
|
+
// The names Takeout gives them, all paired here:
|
|
8
|
+
// IMG_1.jpg.json the older name
|
|
9
|
+
// IMG_1.jpg.supplemental-metadata.json the newer one
|
|
10
|
+
// IMG_1.jpg.supplemental-metad.json, .supp.json cut short: Takeout caps a name near 51 characters
|
|
11
|
+
// <the first 40-odd characters of a long name>.json the photo's own name cut short
|
|
12
|
+
// IMG_1.jpg(1).json, IMG_1.jpg.supplemental-metadata(1).json the sidecar of IMG_1(1).jpg
|
|
13
|
+
// Note.json a Keep note's, beside Note.html
|
|
14
|
+
// A photo's `-edited` copy has no sidecar of its own and shares its original's.
|
|
15
|
+
//
|
|
16
|
+
// What is folded, and what is not: a photo's date taken (`photoTakenTime.timestamp`, UTC epoch seconds,
|
|
17
|
+
// kept to the second) and whether it has a caption; a Keep note's created and edited times. Its place
|
|
18
|
+
// (`geoData`) and the people Google tagged in it are never read into the proposal or the record. The
|
|
19
|
+
// caption is the owner's words, so the proposal only says there is one; apply reads it again from the
|
|
20
|
+
// sidecar, unchanged since the gather, and keeps it on the record's gathered list beside the photo.
|
|
21
|
+
//
|
|
22
|
+
// pairSidecars(files, read) { folded: Map<rel, facts>, sidecars: [{ rel, why, of }] }
|
|
23
|
+
// captionOf(buf) the caption a photo sidecar holds, or null
|
|
24
|
+
//
|
|
25
|
+
// EVDA_GATHER_FALSIFY=sidecars-as-entries turns the pairing off, so the tests can show their checks fail
|
|
26
|
+
// without it (TAKEOUT_FALSIFY_MODES).
|
|
27
|
+
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
import { basename, dirname, extname } from "node:path";
|
|
30
|
+
import { secretShape } from "./references.mjs";
|
|
31
|
+
|
|
32
|
+
export const TAKEOUT_FALSIFY_MODES = ["sidecars-as-entries"];
|
|
33
|
+
const falsified = (mode) => (process.env.EVDA_GATHER_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
34
|
+
const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
35
|
+
|
|
36
|
+
// The files a sidecar describes: what Takeout exports from Photos, and Keep's notes.
|
|
37
|
+
const MEDIA_EXT = new Set([".jpg", ".jpeg", ".png", ".gif", ".heic", ".heif", ".webp", ".tif", ".tiff", ".bmp", ".dng", ".raw", ".cr2", ".nef", ".arw", ".mp4", ".mov", ".m4v", ".avi", ".3gp", ".mkv", ".mpg", ".webm", ".mp"]);
|
|
38
|
+
const NOTE_EXT = new Set([".html", ".htm", ".txt", ".md"]);
|
|
39
|
+
const SUPPLEMENTAL = "supplemental-metadata";
|
|
40
|
+
// Takeout's sidecar names run to about 51 characters; a photo's own name is cut from about 46.
|
|
41
|
+
const CUT_AT = 30;
|
|
42
|
+
const SIDECAR_MAX = 1024 * 1024;
|
|
43
|
+
|
|
44
|
+
// The name a sidecar describes, from the sidecar's own name: { target, n, marked }, or null for a name
|
|
45
|
+
// that is not `.json`. `n` is Takeout's "(1)" copy number; `marked` says the name carries the
|
|
46
|
+
// supplemental-metadata marker, whole or cut short.
|
|
47
|
+
export function sidecarTarget(name) {
|
|
48
|
+
if (!/\.json$/i.test(name)) return null;
|
|
49
|
+
let stem = name.slice(0, -5);
|
|
50
|
+
let n = null;
|
|
51
|
+
const copy = /\((\d+)\)$/.exec(stem);
|
|
52
|
+
if (copy) { n = copy[1]; stem = stem.slice(0, copy.index); }
|
|
53
|
+
let marked = false;
|
|
54
|
+
const tail = /\.([a-z-]*)$/i.exec(stem);
|
|
55
|
+
if (tail && SUPPLEMENTAL.startsWith(tail[1].toLowerCase())) {
|
|
56
|
+
const before = stem.slice(0, tail.index);
|
|
57
|
+
if (/\.[a-z0-9]{2,5}$/i.test(before) || tail[1].length >= 4) { stem = before; marked = true; }
|
|
58
|
+
}
|
|
59
|
+
return { target: stem, n, marked };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// What kind of Takeout file a parsed JSON value is: "photo", "keep", "album", or null.
|
|
63
|
+
export function takeoutShape(v) {
|
|
64
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return null;
|
|
65
|
+
if (v.photoTakenTime || (v.creationTime && (v.title !== undefined || v.imageViews !== undefined))) return "photo";
|
|
66
|
+
if (v.createdTimestampUsec !== undefined || v.userEditedTimestampUsec !== undefined) return "keep";
|
|
67
|
+
if (v.albumData || (v.title !== undefined && v.date?.timestamp !== undefined && v.access !== undefined)) return "album";
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Seconds or microseconds since the epoch, as Takeout writes them, to an ISO time in UTC.
|
|
72
|
+
const iso = (ms) => new Date(ms).toISOString().replace(/\.000Z$/, "Z");
|
|
73
|
+
const seconds = (x) => (/^\d{1,12}$/.test(String(x ?? "").trim()) ? Number(String(x).trim()) : null);
|
|
74
|
+
const usec = (x) => (/^\d{1,18}$/.test(String(x ?? "").trim()) ? Math.floor(Number(String(x).trim()) / 1000) : null);
|
|
75
|
+
|
|
76
|
+
// The facts a sidecar folds into its file's entry. Nothing from geoData or people is read.
|
|
77
|
+
function factsOf(v, shape) {
|
|
78
|
+
const out = {};
|
|
79
|
+
if (shape === "photo") {
|
|
80
|
+
const e = seconds(v.photoTakenTime?.timestamp);
|
|
81
|
+
if (e !== null) { out.takenEpoch = e; out.taken = iso(e * 1000); }
|
|
82
|
+
if (captionText(v)) out.caption = true;
|
|
83
|
+
} else if (shape === "keep") {
|
|
84
|
+
const c = usec(v.createdTimestampUsec);
|
|
85
|
+
const e = usec(v.userEditedTimestampUsec);
|
|
86
|
+
if (c !== null && c > 0) out.created = iso(c);
|
|
87
|
+
if (e !== null && e > 0) out.edited = iso(e);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A caption, unless it is empty or shaped like a secret (then it is folded nowhere).
|
|
93
|
+
function captionText(v) {
|
|
94
|
+
const t = typeof v?.description === "string" ? v.description.trim() : "";
|
|
95
|
+
if (!t || t.split("\n").some(secretShape)) return null;
|
|
96
|
+
return t;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function captionOf(buf) {
|
|
100
|
+
try { return captionText(JSON.parse(buf.toString("utf8"))); } catch { return null; }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const withN = (name, n) => {
|
|
104
|
+
const ext = extname(name);
|
|
105
|
+
return ext ? `${name.slice(0, -ext.length)}(${n})${ext}` : `${name}(${n})`;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Pair every sidecar in a walked source with the file it describes. `files` are the walk's
|
|
109
|
+
// { rel, abs, size }, `read(abs)` their bytes. Returns { folded, sidecars }: `folded` maps a file's rel
|
|
110
|
+
// to the facts its sidecar gave it, `sidecars` lists every sidecar with why it was not placed and, when
|
|
111
|
+
// it has one, the file it describes (`of`).
|
|
112
|
+
export function pairSidecars(files, read) {
|
|
113
|
+
const folded = new Map();
|
|
114
|
+
const sidecars = [];
|
|
115
|
+
if (falsified("sidecars-as-entries")) return { folded, sidecars };
|
|
116
|
+
const byDir = new Map();
|
|
117
|
+
for (const f of files) {
|
|
118
|
+
const d = dirname(f.rel);
|
|
119
|
+
byDir.set(d, [...(byDir.get(d) ?? []), f]);
|
|
120
|
+
}
|
|
121
|
+
for (const [dir, here] of byDir) {
|
|
122
|
+
const others = here.filter((f) => !/\.json$/i.test(f.rel));
|
|
123
|
+
const names = new Map(others.map((f) => [basename(f.rel), f]));
|
|
124
|
+
for (const f of here) {
|
|
125
|
+
const name = basename(f.rel);
|
|
126
|
+
const t = sidecarTarget(name);
|
|
127
|
+
if (!t) continue;
|
|
128
|
+
let v = null;
|
|
129
|
+
let bad = null;
|
|
130
|
+
if (f.size > SIDECAR_MAX) bad = "too large to be a sidecar";
|
|
131
|
+
else {
|
|
132
|
+
const text = read(f.abs).toString("utf8");
|
|
133
|
+
try { v = JSON.parse(text); } catch { bad = "not valid JSON"; }
|
|
134
|
+
}
|
|
135
|
+
const shape = v ? takeoutShape(v) : null;
|
|
136
|
+
const named = t.marked || MEDIA_EXT.has(extname(t.target).toLowerCase()) || NOTE_EXT.has(extname(t.target).toLowerCase());
|
|
137
|
+
// A .json that is neither shaped nor named like a sidecar is an ordinary file of the pile.
|
|
138
|
+
if (!shape && !named) continue;
|
|
139
|
+
if (shape === "album") { sidecars.push({ rel: f.rel, why: "Google Photos' details of the album this folder holds; not an entry of its own", sidecar: true }); continue; }
|
|
140
|
+
// The file it describes, in this folder.
|
|
141
|
+
let match = null;
|
|
142
|
+
if (t.n) match = names.get(withN(t.target, t.n)) ?? null;
|
|
143
|
+
else match = names.get(t.target) ?? null;
|
|
144
|
+
if (!match && !t.n) {
|
|
145
|
+
const same = others.filter((o) => basename(o.rel, extname(o.rel)) === t.target);
|
|
146
|
+
const notes = same.filter((o) => NOTE_EXT.has(extname(o.rel).toLowerCase()));
|
|
147
|
+
match = (notes.length === 1 ? notes[0] : same.length === 1 ? same[0] : null);
|
|
148
|
+
}
|
|
149
|
+
if (!match && !t.n && t.target.length >= CUT_AT) {
|
|
150
|
+
const cut = others.filter((o) => basename(o.rel).startsWith(t.target));
|
|
151
|
+
if (cut.length === 1) match = cut[0];
|
|
152
|
+
}
|
|
153
|
+
if (!match) {
|
|
154
|
+
sidecars.push({ rel: f.rel, why: bad ? `a Takeout sidecar that is ${bad}, and no file it describes is beside it; nothing read from it` : `a Takeout sidecar for ${t.n ? withN(t.target, t.n) : t.target}, which is not in the pile; placed nowhere`, sidecar: true, unmatched: true });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const facts = bad ? {} : factsOf(v, shape ?? "photo");
|
|
158
|
+
sidecars.push({ rel: f.rel, of: match.rel, why: bad ? `a Takeout sidecar for ${basename(match.rel)} that is ${bad}; nothing read from it, and ${basename(match.rel)} is placed without it` : `Google Takeout's details of ${basename(match.rel)}, folded into its entry`, sidecar: true, ...(bad ? { malformed: true } : {}) });
|
|
159
|
+
if (bad) continue;
|
|
160
|
+
const fold = { sidecar: f.rel, sha256: sha256(read(f.abs)), ...facts };
|
|
161
|
+
folded.set(match.rel, fold);
|
|
162
|
+
// A photo's edited copy has no sidecar of its own: it is the same moment.
|
|
163
|
+
const ext = extname(match.rel);
|
|
164
|
+
const edited = names.get(`${basename(match.rel, ext)}-edited${ext}`);
|
|
165
|
+
if (edited && !folded.has(edited.rel)) folded.set(edited.rel, { ...fold, sharedWith: match.rel });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { folded, sidecars };
|
|
169
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
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
|
+
// 10. when the VM names a key-wrapping key (lib/attested-wrap.mjs), REPORTDATA also binds it, so the
|
|
27
|
+
// key a client wraps circle keys to is one the attested VM made, not one the host operator chose.
|
|
28
|
+
//
|
|
29
|
+
// The binding, exactly: REPORTDATA (64 bytes) = SHA-512( "EVDA-TDX-REPORTDATA-v1" || 0x00 ||
|
|
30
|
+
// nonce (32 bytes) || SPKI DER of the TLS public key ). The nonce has a fixed length and SPKI DER
|
|
31
|
+
// carries its own length, so the concatenation is unambiguous. The VM computes the same value with
|
|
32
|
+
// the same function when it asks the TDX module for a quote.
|
|
33
|
+
//
|
|
34
|
+
// With a key-wrapping key, REPORTDATA = SHA-512( "EVDA-TDX-REPORTDATA-v2" || 0x00 || nonce (32 bytes) ||
|
|
35
|
+
// SPKI DER of the TLS public key || the X25519 wrap public key (32 raw bytes) ). The wrap key has a fixed
|
|
36
|
+
// length and comes last, after a self-delimiting SPKI, and the tag differs, so neither form can be read
|
|
37
|
+
// as the other.
|
|
38
|
+
//
|
|
39
|
+
// Not checked here yet: certificate revocation (Intel's PCK and root CRLs), quote v5 (refused as
|
|
40
|
+
// unknown rather than half-checked), the TD relaunch advice v5 quotes carry, and whether the expected
|
|
41
|
+
// measurements are themselves in Sigstore's log (lib/measurement-log.mjs checks a digest; tying the
|
|
42
|
+
// expected MRTD and RTMRs to a logged statement is a later slice).
|
|
43
|
+
//
|
|
44
|
+
// Every failure returns { ok: false, reason } in words; nothing a quote contains can make it throw.
|
|
45
|
+
|
|
46
|
+
import { createHash, createPublicKey, KeyObject, randomBytes, timingSafeEqual, verify as verifySig, X509Certificate } from "node:crypto";
|
|
47
|
+
import { readFileSync } from "node:fs";
|
|
48
|
+
import { derTlv as tlv, derChildren as children, derOid as oid } from "./measurement-log.mjs";
|
|
49
|
+
|
|
50
|
+
export const TDX_TRUST = JSON.parse(readFileSync(new URL("./tdx-trust.json", import.meta.url), "utf8"));
|
|
51
|
+
|
|
52
|
+
/** The domain tag in front of every REPORTDATA preimage. */
|
|
53
|
+
export const REPORTDATA_TAG = "EVDA-TDX-REPORTDATA-v1";
|
|
54
|
+
|
|
55
|
+
/** The measurement registers a policy must name, each 48 bytes as 96 lowercase hex characters. */
|
|
56
|
+
export const MEASUREMENT_NAMES = Object.freeze(["mrtd", "rtmr0", "rtmr1", "rtmr2", "rtmr3"]);
|
|
57
|
+
|
|
58
|
+
const HEADER_LEN = 48;
|
|
59
|
+
const BODY_LEN = 584;
|
|
60
|
+
const QE_REPORT_LEN = 384;
|
|
61
|
+
const TEE_TDX = 0x81;
|
|
62
|
+
const ATT_KEY_ECDSA_P256 = 2;
|
|
63
|
+
const CERT_QE_REPORT = 6;
|
|
64
|
+
const CERT_PCK_CHAIN = 5;
|
|
65
|
+
const OID_SGX_EXT = "1.2.840.113741.1.13.1";
|
|
66
|
+
const OID_SGX_TCB = `${OID_SGX_EXT}.2`;
|
|
67
|
+
const OID_SGX_FMSPC = `${OID_SGX_EXT}.4`;
|
|
68
|
+
|
|
69
|
+
const fail = (reason) => ({ ok: false, reason });
|
|
70
|
+
const hex = (b) => Buffer.from(b).toString("hex");
|
|
71
|
+
const sha256 = (b) => createHash("sha256").update(b).digest();
|
|
72
|
+
|
|
73
|
+
// ─── the binding ─────────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const spkiDer = (key) => {
|
|
76
|
+
if (Buffer.isBuffer(key) || key instanceof Uint8Array) return createPublicKey({ key: Buffer.from(key), format: "der", type: "spki" }).export({ type: "spki", format: "der" });
|
|
77
|
+
if (key instanceof KeyObject && key.type === "public") return key.export({ type: "spki", format: "der" });
|
|
78
|
+
return createPublicKey(key).export({ type: "spki", format: "der" });
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** The domain tag when REPORTDATA also binds the VM's key-wrapping key. */
|
|
82
|
+
export const REPORTDATA_WRAP_TAG = "EVDA-TDX-REPORTDATA-v2";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The 64 bytes a TD must put in REPORTDATA to bind `nonce` (32 bytes) and `tlsPublicKey` to its quote,
|
|
86
|
+
* and, when given, `wrapPublicKey` (the raw 32-byte X25519 key the VM made inside itself).
|
|
87
|
+
*/
|
|
88
|
+
export function reportDataFor(nonce, tlsPublicKey, wrapPublicKey) {
|
|
89
|
+
const n = Buffer.from(nonce);
|
|
90
|
+
if (n.length !== 32) throw new Error("the nonce must be 32 bytes");
|
|
91
|
+
if (wrapPublicKey == null) return createHash("sha512").update(Buffer.concat([Buffer.from(REPORTDATA_TAG, "ascii"), Buffer.from([0]), n, spkiDer(tlsPublicKey)])).digest();
|
|
92
|
+
const w = Buffer.from(wrapPublicKey);
|
|
93
|
+
if (w.length !== 32) throw new Error("the wrap key must be 32 bytes");
|
|
94
|
+
return createHash("sha512").update(Buffer.concat([Buffer.from(REPORTDATA_WRAP_TAG, "ascii"), Buffer.from([0]), n, spkiDer(tlsPublicKey), w])).digest();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The phone's record of the nonces it has issued. A nonce is good once, within `ttlMs` of issue.
|
|
99
|
+
* `take` returns null when the nonce is fresh (and spends it), or a reason in words.
|
|
100
|
+
*/
|
|
101
|
+
export function createNonceBook({ ttlMs = 60_000 } = {}) {
|
|
102
|
+
const issued = new Map();
|
|
103
|
+
const spent = new Set();
|
|
104
|
+
return {
|
|
105
|
+
issue(at = Date.now()) {
|
|
106
|
+
const n = randomBytes(32);
|
|
107
|
+
issued.set(hex(n), at);
|
|
108
|
+
return n;
|
|
109
|
+
},
|
|
110
|
+
take(nonce, at = Date.now()) {
|
|
111
|
+
const key = hex(nonce ?? []);
|
|
112
|
+
if (spent.has(key)) return "the nonce was already used";
|
|
113
|
+
if (!issued.has(key)) return "the nonce was not issued by this phone";
|
|
114
|
+
const issuedAt = issued.get(key);
|
|
115
|
+
issued.delete(key);
|
|
116
|
+
spent.add(key);
|
|
117
|
+
if (at - issuedAt > ttlMs || at < issuedAt) return `the nonce is stale (issued ${Math.round((at - issuedAt) / 1000)}s ago, limit ${ttlMs / 1000}s)`;
|
|
118
|
+
return null;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── parsing ─────────────────────────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
/** Splits a TDX quote v4 into its parts. Throws on any length that does not add up. */
|
|
126
|
+
export function parseTdxQuote(raw) {
|
|
127
|
+
const q = Buffer.from(raw);
|
|
128
|
+
if (q.length < HEADER_LEN + 4) throw new Error("the quote is shorter than its header");
|
|
129
|
+
const version = q.readUInt16LE(0);
|
|
130
|
+
if (version !== 4) throw new Error(`quote version ${version} is not understood (only 4)`);
|
|
131
|
+
const header = {
|
|
132
|
+
version,
|
|
133
|
+
attestationKeyType: q.readUInt16LE(2),
|
|
134
|
+
teeType: q.readUInt32LE(4),
|
|
135
|
+
qeVendorId: hex(q.subarray(12, 28)),
|
|
136
|
+
};
|
|
137
|
+
if (q.length < HEADER_LEN + BODY_LEN + 4) throw new Error("the quote is truncated in its TD report body");
|
|
138
|
+
const b = q.subarray(HEADER_LEN, HEADER_LEN + BODY_LEN);
|
|
139
|
+
const at = (o, n) => b.subarray(o, o + n);
|
|
140
|
+
const body = {
|
|
141
|
+
teeTcbSvn: at(0, 16),
|
|
142
|
+
mrSeam: hex(at(16, 48)),
|
|
143
|
+
seamAttributes: hex(at(112, 8)),
|
|
144
|
+
tdAttributes: at(120, 8),
|
|
145
|
+
xfam: hex(at(128, 8)),
|
|
146
|
+
mrtd: hex(at(136, 48)),
|
|
147
|
+
mrConfigId: hex(at(184, 48)),
|
|
148
|
+
mrOwner: hex(at(232, 48)),
|
|
149
|
+
mrOwnerConfig: hex(at(280, 48)),
|
|
150
|
+
rtmr0: hex(at(328, 48)),
|
|
151
|
+
rtmr1: hex(at(376, 48)),
|
|
152
|
+
rtmr2: hex(at(424, 48)),
|
|
153
|
+
rtmr3: hex(at(472, 48)),
|
|
154
|
+
reportData: at(520, 64),
|
|
155
|
+
};
|
|
156
|
+
const signedLen = HEADER_LEN + BODY_LEN;
|
|
157
|
+
const sigLen = q.readUInt32LE(signedLen);
|
|
158
|
+
const s = q.subarray(signedLen + 4);
|
|
159
|
+
if (s.length < sigLen) throw new Error("the quote is truncated in its signature data");
|
|
160
|
+
if (sigLen < 64 + 64 + 6) throw new Error("the quote's signature data is too short");
|
|
161
|
+
const quoteSignature = s.subarray(0, 64);
|
|
162
|
+
const attestationKey = s.subarray(64, 128);
|
|
163
|
+
const certType = s.readUInt16LE(128);
|
|
164
|
+
const certLen = s.readUInt32LE(130);
|
|
165
|
+
if (certType !== CERT_QE_REPORT) throw new Error(`certification data type ${certType} is not a QE report (6)`);
|
|
166
|
+
const c = s.subarray(134, 134 + certLen);
|
|
167
|
+
if (c.length !== certLen || 134 + certLen > sigLen) throw new Error("the quote is truncated in its certification data");
|
|
168
|
+
if (c.length < QE_REPORT_LEN + 64 + 2) throw new Error("the QE report certification data is too short");
|
|
169
|
+
const qeReport = c.subarray(0, QE_REPORT_LEN);
|
|
170
|
+
const qeReportSignature = c.subarray(QE_REPORT_LEN, QE_REPORT_LEN + 64);
|
|
171
|
+
const authLen = c.readUInt16LE(QE_REPORT_LEN + 64);
|
|
172
|
+
let o = QE_REPORT_LEN + 66;
|
|
173
|
+
if (o + authLen + 6 > c.length) throw new Error("the quote is truncated in its QE auth data");
|
|
174
|
+
const qeAuthData = c.subarray(o, o + authLen);
|
|
175
|
+
o += authLen;
|
|
176
|
+
const innerType = c.readUInt16LE(o);
|
|
177
|
+
const innerLen = c.readUInt32LE(o + 2);
|
|
178
|
+
if (innerType !== CERT_PCK_CHAIN) throw new Error(`the QE's certification data type ${innerType} is not a PCK chain (5)`);
|
|
179
|
+
const chainBytes = c.subarray(o + 6, o + 6 + innerLen);
|
|
180
|
+
if (chainBytes.length !== innerLen) throw new Error("the quote is truncated in its PCK chain");
|
|
181
|
+
const r = qeReport;
|
|
182
|
+
return {
|
|
183
|
+
header,
|
|
184
|
+
body,
|
|
185
|
+
signed: q.subarray(0, signedLen),
|
|
186
|
+
quoteSignature,
|
|
187
|
+
attestationKey,
|
|
188
|
+
qeReport: {
|
|
189
|
+
raw: qeReport,
|
|
190
|
+
miscSelect: r.readUInt32LE(16),
|
|
191
|
+
attributes: r.subarray(48, 64),
|
|
192
|
+
mrSigner: hex(r.subarray(128, 160)),
|
|
193
|
+
isvProdId: r.readUInt16LE(256),
|
|
194
|
+
isvSvn: r.readUInt16LE(258),
|
|
195
|
+
reportData: r.subarray(320, 384),
|
|
196
|
+
},
|
|
197
|
+
qeReportSignature,
|
|
198
|
+
qeAuthData,
|
|
199
|
+
pckChainPem: chainBytes.toString("utf8").replace(/\0+$/, ""),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const pemCerts = (pem) => (String(pem).match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).map((p) => new X509Certificate(p));
|
|
204
|
+
|
|
205
|
+
const uint = (buf) => { let v = 0; for (const x of buf) v = v * 256 + x; return v; };
|
|
206
|
+
|
|
207
|
+
/** The SGX TCB component SVNs, PCESVN and FMSPC from a PCK certificate's Intel extension. */
|
|
208
|
+
export function pckExtensions(der) {
|
|
209
|
+
const buf = Buffer.from(der);
|
|
210
|
+
const tbs = children(buf, tlv(buf, 0))[0];
|
|
211
|
+
const extWrap = children(buf, tbs).find((c) => c.tag === 0xa3);
|
|
212
|
+
if (!extWrap) throw new Error("the PCK certificate has no extensions");
|
|
213
|
+
for (const ext of children(buf, children(buf, extWrap)[0])) {
|
|
214
|
+
const parts = children(buf, ext);
|
|
215
|
+
if (oid(buf.subarray(parts[0].start, parts[0].end)) !== OID_SGX_EXT) continue;
|
|
216
|
+
const value = parts.at(-1);
|
|
217
|
+
const raw = buf.subarray(value.start, value.end);
|
|
218
|
+
const out = { cpuSvn: [], pceSvn: null, fmspc: null };
|
|
219
|
+
for (const item of children(raw, tlv(raw, 0))) {
|
|
220
|
+
const [idNode, valNode] = children(raw, item);
|
|
221
|
+
const id = oid(raw.subarray(idNode.start, idNode.end));
|
|
222
|
+
if (id === OID_SGX_FMSPC) out.fmspc = hex(raw.subarray(valNode.start, valNode.end));
|
|
223
|
+
if (id === OID_SGX_TCB) {
|
|
224
|
+
for (const comp of children(raw, valNode)) {
|
|
225
|
+
const [cid, cval] = children(raw, comp);
|
|
226
|
+
const n = Number(oid(raw.subarray(cid.start, cid.end)).split(".").at(-1));
|
|
227
|
+
if (n >= 1 && n <= 16) out.cpuSvn[n - 1] = uint(raw.subarray(cval.start, cval.end));
|
|
228
|
+
if (n === 17) out.pceSvn = uint(raw.subarray(cval.start, cval.end));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
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");
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
throw new Error("the PCK certificate has no SGX extension");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─── signatures and chains ───────────────────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
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" });
|
|
241
|
+
const p1363 = (data, key, sig) => verifySig("sha256", data, { key, dsaEncoding: "ieee-p1363" }, sig);
|
|
242
|
+
|
|
243
|
+
function checkChainToRoot(certs, root, at, what) {
|
|
244
|
+
const all = [...certs, root];
|
|
245
|
+
for (let i = 0; i < all.length; i++) {
|
|
246
|
+
const issuer = all[Math.min(i + 1, all.length - 1)];
|
|
247
|
+
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"})`;
|
|
248
|
+
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()}`;
|
|
249
|
+
if (i > 0 && !all[i].ca) return `the ${what} chain has a non-CA certificate as an issuer`;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Splits an Intel PCS response into the exact signed bytes, the parsed document and the signature. */
|
|
255
|
+
function signedDocument(text, field) {
|
|
256
|
+
const t = String(text ?? "").replace(/\s+$/, "");
|
|
257
|
+
const m = new RegExp(`^\\{"${field}":([\\s\\S]+),"signature":"([0-9a-fA-F]{128})"\\}$`).exec(t);
|
|
258
|
+
if (!m) return null;
|
|
259
|
+
return { body: Buffer.from(m[1], "utf8"), doc: JSON.parse(m[1]), signature: Buffer.from(m[2], "hex") };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function checkCollateralDoc({ text, chainPem, field, root, at, what }) {
|
|
263
|
+
const d = signedDocument(text, field);
|
|
264
|
+
if (!d) return { error: `the ${what} is not an Intel PCS response` };
|
|
265
|
+
const certs = pemCerts(chainPem);
|
|
266
|
+
if (certs.length === 0) return { error: `the ${what} has no issuer chain` };
|
|
267
|
+
const signer = certs[0];
|
|
268
|
+
const chainProblem = checkChainToRoot(certs.filter((c) => !c.raw.equals(root.raw)), root, at, what);
|
|
269
|
+
if (chainProblem) return { error: chainProblem };
|
|
270
|
+
if (!p1363(d.body, signer.publicKey, d.signature)) return { error: `the ${what} signature does not verify under Intel's TCB signing key` };
|
|
271
|
+
const issue = Date.parse(d.doc.issueDate);
|
|
272
|
+
const next = Date.parse(d.doc.nextUpdate);
|
|
273
|
+
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})` };
|
|
274
|
+
return { doc: d.doc };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const masked = (value, mask) => Buffer.from(value.map((b, i) => b & mask[i]));
|
|
278
|
+
|
|
279
|
+
// Intel's TCB level algorithm (the PCS TDX TCB info spec, as go-tdx-guest implements it): the first
|
|
280
|
+
// level, in Intel's descending order, that the PCK's SGX SVNs, its PCESVN and the TD's TEE_TCB_SVN all
|
|
281
|
+
// meet. When TEE_TCB_SVN[1] names a TDX module version, indexes 0 and 1 are judged instead by the
|
|
282
|
+
// matching TDX module identity.
|
|
283
|
+
function matchTcbLevel(tcbInfo, pck, teeTcbSvn) {
|
|
284
|
+
const from = teeTcbSvn[1] > 0 ? 2 : 0;
|
|
285
|
+
for (const level of tcbInfo.tcbLevels ?? []) {
|
|
286
|
+
const sgx = level.tcb?.sgxtcbcomponents ?? [];
|
|
287
|
+
const tdx = level.tcb?.tdxtcbcomponents ?? [];
|
|
288
|
+
if (sgx.length !== 16 || tdx.length !== 16) continue;
|
|
289
|
+
if (!pck.cpuSvn.every((v, i) => v >= sgx[i].svn)) continue;
|
|
290
|
+
if (pck.pceSvn < level.tcb.pcesvn) continue;
|
|
291
|
+
if (!teeTcbSvn.every((v, i) => i < from || v >= tdx[i].svn)) continue;
|
|
292
|
+
return level;
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function matchModuleLevel(tcbInfo, teeTcbSvn) {
|
|
298
|
+
const id = `TDX_${teeTcbSvn[1].toString(16).padStart(2, "0").toUpperCase()}`;
|
|
299
|
+
const identity = (tcbInfo.tdxModuleIdentities ?? []).find((m) => String(m.id).toUpperCase() === id);
|
|
300
|
+
if (!identity) return { error: `Intel's TCB info has no identity for TDX module ${id}` };
|
|
301
|
+
const level = (identity.tcbLevels ?? []).find((l) => teeTcbSvn[0] >= l.tcb?.isvsvn);
|
|
302
|
+
if (!level) return { error: `TDX module ${id} at SVN ${teeTcbSvn[0]} matches no level Intel lists` };
|
|
303
|
+
return { id, level };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Verifies a TDX quote's signatures, its chain to the pinned Intel root, the QE's identity and the
|
|
308
|
+
* platform's TCB level against Intel's signed collateral. Policy on the TD itself (debug, measurements,
|
|
309
|
+
* nonce, TLS binding) is verifyTdxAttestation's.
|
|
310
|
+
*
|
|
311
|
+
* collateral: { tcbInfo, tcbInfoIssuerChain, qeIdentity, qeIdentityIssuerChain } as Intel PCS serves them.
|
|
312
|
+
* minTcb: { statuses: ["UpToDate", ...], tcbEvaluationDataNumber } (statuses defaults to UpToDate only).
|
|
313
|
+
*/
|
|
314
|
+
export function verifyTdxQuote({ quote, collateral, minTcb, now, trust = TDX_TRUST } = {}) {
|
|
315
|
+
try {
|
|
316
|
+
return checkQuote({ quote, collateral, minTcb, now, trust });
|
|
317
|
+
} catch (err) {
|
|
318
|
+
return fail(`the quote could not be read: ${err.message}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function checkQuote({ quote, collateral, minTcb, now, trust }) {
|
|
323
|
+
if (!quote) return fail("there is no quote");
|
|
324
|
+
if (now === undefined) return fail("no time was given to check certificates and collateral against");
|
|
325
|
+
if (!collateral?.tcbInfo || !collateral?.qeIdentity) return fail("no Intel collateral was given, so the TCB cannot be judged");
|
|
326
|
+
if (!minTcb || !Number.isSafeInteger(minTcb.tcbEvaluationDataNumber)) return fail("no minimum TCB was named");
|
|
327
|
+
const at = new Date(now);
|
|
328
|
+
const q = parseTdxQuote(quote);
|
|
329
|
+
|
|
330
|
+
// 1. What kind of quote this is.
|
|
331
|
+
if (q.header.teeType !== TEE_TDX) return fail(`the quote is for TEE type 0x${q.header.teeType.toString(16)}, not TDX`);
|
|
332
|
+
if (q.header.attestationKeyType !== ATT_KEY_ECDSA_P256) return fail(`attestation key type ${q.header.attestationKeyType} is not ECDSA P-256`);
|
|
333
|
+
if (q.header.qeVendorId !== trust.intel.qeVendorId) return fail("the quote is not from Intel's quoting enclave");
|
|
334
|
+
|
|
335
|
+
// 2. The attestation key signed the header and the TD report.
|
|
336
|
+
if (!p1363(q.signed, rawP256(q.attestationKey), q.quoteSignature)) return fail("the quote signature does not verify under its attestation key");
|
|
337
|
+
|
|
338
|
+
// 3. The PCK leaf signed the QE report, and chains to the pinned Intel root.
|
|
339
|
+
const root = new X509Certificate(Buffer.from(trust.intel.rootDer, "base64"));
|
|
340
|
+
const certs = pemCerts(q.pckChainPem);
|
|
341
|
+
if (certs.length < 2) return fail("the quote's PCK chain is incomplete");
|
|
342
|
+
const chain = certs.filter((c) => !c.raw.equals(root.raw));
|
|
343
|
+
if (chain.length !== 2) return fail("the quote's PCK chain does not end at the pinned Intel root");
|
|
344
|
+
const chainProblem = checkChainToRoot(chain, root, at, "PCK");
|
|
345
|
+
if (chainProblem) return fail(chainProblem);
|
|
346
|
+
const pck = chain[0];
|
|
347
|
+
if (pck.ca) return fail("the PCK certificate is a CA");
|
|
348
|
+
if (!p1363(q.qeReport.raw, pck.publicKey, q.qeReportSignature)) return fail("the QE report signature does not verify under the PCK certificate");
|
|
349
|
+
|
|
350
|
+
// 4. The QE vouched for this attestation key.
|
|
351
|
+
const want = Buffer.concat([sha256(Buffer.concat([q.attestationKey, q.qeAuthData])), Buffer.alloc(32)]);
|
|
352
|
+
if (!want.equals(q.qeReport.reportData)) return fail("the QE report does not bind the attestation key that signed the quote");
|
|
353
|
+
|
|
354
|
+
// 5. Intel's collateral: the QE is Intel's, and the platform's TCB level is one the policy allows.
|
|
355
|
+
const qe = checkCollateralDoc({ text: collateral.qeIdentity, chainPem: collateral.qeIdentityIssuerChain, field: "enclaveIdentity", root, at, what: "QE identity" });
|
|
356
|
+
if (qe.error) return fail(qe.error);
|
|
357
|
+
const id = qe.doc;
|
|
358
|
+
if (id.id !== "TD_QE") return fail(`the QE identity is for ${id.id}, not TD_QE`);
|
|
359
|
+
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");
|
|
360
|
+
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");
|
|
361
|
+
if (q.qeReport.mrSigner !== id.mrsigner.toLowerCase()) return fail("the QE was not signed by Intel (MRSIGNER differs)");
|
|
362
|
+
if (q.qeReport.isvProdId !== id.isvprodid) return fail("the QE's product id does not match Intel's QE identity");
|
|
363
|
+
const qeLevel = (id.tcbLevels ?? []).find((l) => l.tcb?.isvsvn <= q.qeReport.isvSvn);
|
|
364
|
+
if (qeLevel?.tcbStatus !== "UpToDate") return fail(`the QE's TCB is ${qeLevel?.tcbStatus ?? "not in Intel's list"}`);
|
|
365
|
+
|
|
366
|
+
const ti = checkCollateralDoc({ text: collateral.tcbInfo, chainPem: collateral.tcbInfoIssuerChain, field: "tcbInfo", root, at, what: "TCB info" });
|
|
367
|
+
if (ti.error) return fail(ti.error);
|
|
368
|
+
const tcbInfo = ti.doc;
|
|
369
|
+
if (tcbInfo.id !== "TDX" || tcbInfo.version !== 3) return fail("the TCB info is not TDX TCB info version 3");
|
|
370
|
+
const ext = pckExtensions(pck.raw);
|
|
371
|
+
if (tcbInfo.fmspc.toLowerCase() !== ext.fmspc) return fail(`the TCB info is for FMSPC ${tcbInfo.fmspc}, and this platform is ${ext.fmspc}`);
|
|
372
|
+
const teeTcbSvn = [...q.body.teeTcbSvn];
|
|
373
|
+
if (tcbInfo.tcbEvaluationDataNumber < minTcb.tcbEvaluationDataNumber) return fail(`Intel's TCB evaluation ${tcbInfo.tcbEvaluationDataNumber} is older than the minimum ${minTcb.tcbEvaluationDataNumber}`);
|
|
374
|
+
const level = matchTcbLevel(tcbInfo, ext, teeTcbSvn);
|
|
375
|
+
if (!level) return fail("the platform matches no TCB level in Intel's TCB info, so it is below every level Intel supports");
|
|
376
|
+
const statuses = minTcb.statuses ?? ["UpToDate"];
|
|
377
|
+
if (!statuses.includes(level.tcbStatus)) return fail(`the platform's TCB is ${level.tcbStatus} (${level.tcbDate}), below the minimum (${statuses.join(" or ")})`);
|
|
378
|
+
let module = null;
|
|
379
|
+
if (teeTcbSvn[1] > 0) {
|
|
380
|
+
const m = matchModuleLevel(tcbInfo, teeTcbSvn);
|
|
381
|
+
if (m.error) return fail(m.error);
|
|
382
|
+
if (!statuses.includes(m.level.tcbStatus)) return fail(`TDX module ${m.id} is ${m.level.tcbStatus}, below the minimum (${statuses.join(" or ")})`);
|
|
383
|
+
module = { id: m.id, status: m.level.tcbStatus };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
ok: true,
|
|
388
|
+
body: q.body,
|
|
389
|
+
tcb: { status: level.tcbStatus, date: level.tcbDate, evaluationDataNumber: tcbInfo.tcbEvaluationDataNumber, fmspc: ext.fmspc, module, advisoryIds: level.advisoryIDs ?? [] },
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The phone's whole check before it sends a hosted EVÐA anything. Returns { ok: true, measurements,
|
|
395
|
+
* tcb, tlsPublicKeySha256 } (the hash is the key the phone pins), or { ok: false, reason }.
|
|
396
|
+
*
|
|
397
|
+
* nonce: the 32 bytes the phone sent; nonces: the createNonceBook that issued it (spent here, pass or fail).
|
|
398
|
+
* tlsPublicKey: the TLS server key of this connection (KeyObject, PEM, or SPKI DER).
|
|
399
|
+
* expected: { mrtd, rtmr0, rtmr1, rtmr2, rtmr3 } as hex. Every one must be named.
|
|
400
|
+
* wrapPublicKey: the VM's key-wrapping key as the host named it (32 raw bytes), or absent. When given,
|
|
401
|
+
* the quote must bind it too, and the result names it.
|
|
402
|
+
*/
|
|
403
|
+
export function verifyTdxAttestation({ quote, nonce, nonces, tlsPublicKey, wrapPublicKey, expected, minTcb, collateral, now, trust = TDX_TRUST } = {}) {
|
|
404
|
+
try {
|
|
405
|
+
if (!nonces?.take) return fail("no nonce book was given, so freshness cannot be checked");
|
|
406
|
+
const at = now === undefined ? undefined : new Date(now).getTime();
|
|
407
|
+
const stale = nonces.take(nonce, at);
|
|
408
|
+
if (stale) return fail(stale);
|
|
409
|
+
if (!tlsPublicKey) return fail("no TLS key was given, so the quote cannot be bound to this connection");
|
|
410
|
+
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`);
|
|
411
|
+
|
|
412
|
+
const r = verifyTdxQuote({ quote, collateral, minTcb, now, trust });
|
|
413
|
+
if (!r.ok) return r;
|
|
414
|
+
|
|
415
|
+
// 6. Debug off. TDATTRIBUTES bit 0 is TUD.DEBUG: a debug TD lets the host read its memory.
|
|
416
|
+
if (r.body.tdAttributes[0] & 1) return fail("the TD runs with debug on, so its host can read its memory");
|
|
417
|
+
|
|
418
|
+
// 7. The code is the code expected.
|
|
419
|
+
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)}...`);
|
|
420
|
+
|
|
421
|
+
// 9 and 10. The quote is for this nonce, this TLS key and, when named, this wrap key.
|
|
422
|
+
const want = reportDataFor(nonce, tlsPublicKey, wrapPublicKey);
|
|
423
|
+
if (!timingSafeEqual(want, r.body.reportData)) return fail(wrapPublicKey == null ? "REPORTDATA does not bind this nonce and this TLS key" : "REPORTDATA does not bind this nonce, this TLS key and this wrap key");
|
|
424
|
+
|
|
425
|
+
const measurements = Object.fromEntries(MEASUREMENT_NAMES.map((n) => [n, r.body[n]]));
|
|
426
|
+
const out = { ok: true, measurements, tcb: r.tcb, tlsPublicKeySha256: sha256(spkiDer(tlsPublicKey)).toString("hex") };
|
|
427
|
+
if (wrapPublicKey != null) out.wrapPublicKey = Buffer.from(wrapPublicKey);
|
|
428
|
+
return out;
|
|
429
|
+
} catch (err) {
|
|
430
|
+
return fail(`the attestation could not be checked: ${err.message}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
@@ -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
|
+
}
|