@prismnetwork/agent-sdk 0.6.1 → 0.7.0
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/LICENSE +201 -0
- package/NOTICE +10 -0
- package/attest.d.mts +125 -0
- package/attest.mjs +826 -0
- package/e2ee.d.mts +89 -0
- package/e2ee.mjs +215 -0
- package/package.json +27 -6
- package/prism.d.mts +63 -0
- package/prism.mjs +457 -41
- package/vendor/aci-verifier/crypto.mjs +100 -0
- package/vendor/aci-verifier/digest.mjs +39 -0
- package/vendor/aci-verifier/errors.mjs +21 -0
- package/vendor/aci-verifier/index.mjs +32 -0
- package/vendor/aci-verifier/receipt.mjs +88 -0
- package/vendor/aci-verifier/report.mjs +206 -0
- package/vendor/aci-verifier/session.mjs +33 -0
package/attest.mjs
ADDED
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
// Agent-side verification of a confidential generation: the checks an agent
|
|
2
|
+
// runs itself, after the fact, over the answer it just paid for.
|
|
3
|
+
//
|
|
4
|
+
// The chain it establishes, in order: the TDX quote verifies to Intel's root;
|
|
5
|
+
// that quote commits to the workload's key set and to a nonce this client
|
|
6
|
+
// chose; the boot event log replays to the RTMR3 the quote states, and the
|
|
7
|
+
// compose it measures is the code this SDK pins; the per-request receipt is
|
|
8
|
+
// signed by a key in that same key set and commits to the exact request and
|
|
9
|
+
// response bytes; the upstream that ran the model was itself verified and the
|
|
10
|
+
// session it cites is the document it claims to be; the GPU is attested by
|
|
11
|
+
// NVIDIA under a nonce bound into a quote from the same TD.
|
|
12
|
+
//
|
|
13
|
+
// Two things this cannot prove, and reports as skips rather than dressing up:
|
|
14
|
+
// nobody outside the enclave holds the signing keys (the report publishes
|
|
15
|
+
// custody evidence, but no verifier in the protocol appraises the KMS chain
|
|
16
|
+
// today), and where TLS terminates. End-to-end encryption is what removes the
|
|
17
|
+
// second one from the trust path: with it on, the relay carries ciphertext.
|
|
18
|
+
//
|
|
19
|
+
// Two checks reach outside Web Crypto and load their library when they run:
|
|
20
|
+
// the TDX quote needs @phala/dcap-qvl (^0.6.1, the pure-JS package) and the
|
|
21
|
+
// NVIDIA tokens need jose (^6). Without them those checks report what is
|
|
22
|
+
// missing instead of passing.
|
|
23
|
+
import {
|
|
24
|
+
checkSessionEvidence,
|
|
25
|
+
computeReportData,
|
|
26
|
+
computeSessionId,
|
|
27
|
+
findEvent,
|
|
28
|
+
fromHex,
|
|
29
|
+
hashBody,
|
|
30
|
+
quoteReportData,
|
|
31
|
+
sha384,
|
|
32
|
+
toHex,
|
|
33
|
+
verifyComposeMeasurement,
|
|
34
|
+
verifyQuote,
|
|
35
|
+
verifyRawQuote,
|
|
36
|
+
verifyReceipt,
|
|
37
|
+
verifyReportBinding,
|
|
38
|
+
} from "./vendor/aci-verifier/index.mjs";
|
|
39
|
+
|
|
40
|
+
export const DEFAULT_CONFIDENTIAL_BASE = "https://api.prismnetwork.tech/inference";
|
|
41
|
+
|
|
42
|
+
/// The deployment the confidential tier is pinned to, read off the live
|
|
43
|
+
/// known-good report. The launcher image digest is the root of it: the launcher
|
|
44
|
+
/// is measured into the quote, and it is the thing that clones and runs the
|
|
45
|
+
/// gateway source, so its digest pins the code that ends up holding the E2EE
|
|
46
|
+
/// private key. `repoUrl` is the human-readable half of the same fact, and
|
|
47
|
+
/// `osImageHash` is the dstack guest image the whole stack booted under.
|
|
48
|
+
/// `repoCommit` pins the source revision the launcher builds, so a compose that
|
|
49
|
+
/// names another revision of the same repo does not pass.
|
|
50
|
+
///
|
|
51
|
+
/// This is a snapshot of one known-good deployment. It has to be updated when
|
|
52
|
+
/// Phala rebuilds the launcher or advances the gateway source, and it does not
|
|
53
|
+
/// establish that the launcher image was built from the source it names.
|
|
54
|
+
export const EXPECTED_WORKLOAD = {
|
|
55
|
+
launcherImage:
|
|
56
|
+
"ghcr.io/redpill-ai/private-ai-launcher@sha256:c083ff9e6a5ddf10f6c9e9bb1f74cc618deebecfea5208b563c574399db4637c",
|
|
57
|
+
repoUrl: "https://github.com/Dstack-TEE/private-ai-gateway.git",
|
|
58
|
+
osImageHash: "bd369a8c2f9edb2b52dad48ac8e0b32dde5f1337c423a506b48d07403a7d8033",
|
|
59
|
+
repoCommit: "b6b5c1b82f6fc59490db5a5255bf4493805e66c6",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const NRAS_ATTEST_URL = "https://nras.attestation.nvidia.com/v3/attest/gpu";
|
|
63
|
+
const NRAS_JWKS_URL = "https://nras.attestation.nvidia.com/.well-known/jwks.json";
|
|
64
|
+
const NRAS_ISSUER = "https://nras.attestation.nvidia.com";
|
|
65
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
66
|
+
|
|
67
|
+
// A verified verdict tolerates only these two skips, and only for the reason
|
|
68
|
+
// each names. Anything else that could not run means evidence the verdict would
|
|
69
|
+
// have rested on was missing, which is `incomplete` rather than `verified`.
|
|
70
|
+
const CUSTODY = "key-custody";
|
|
71
|
+
const CHANNEL = "tls-spki";
|
|
72
|
+
const WORKLOAD = "workload-identity";
|
|
73
|
+
|
|
74
|
+
const CHECKS = {
|
|
75
|
+
"keyset-digest": "workload key set recomputed from the served report",
|
|
76
|
+
"report-data-binding": "the quote commits to that key set and to our nonce",
|
|
77
|
+
"tdx-quote": "TDX quote verifies to Intel's root",
|
|
78
|
+
"rtmr3-replay": "boot event log replays to the quote's RTMR3",
|
|
79
|
+
"compose-hash": "the running compose is the one measured into the quote",
|
|
80
|
+
"workload-identity": "the measured compose runs the pinned launcher and source",
|
|
81
|
+
"receipt-signature": "receipt signed by an attested receipt key",
|
|
82
|
+
"receipt-keyset-binding": "receipt binds to the verified key set",
|
|
83
|
+
"request-hash": "the request bytes match the signed receipt",
|
|
84
|
+
"response-hash": "the response bytes match the signed receipt",
|
|
85
|
+
"upstream-verified": "the serving upstream was verified, and verification was required",
|
|
86
|
+
"session-id": "the cited attestation session is the document it claims to be",
|
|
87
|
+
"session-evidence": "the session's evidence hashes to its digest",
|
|
88
|
+
"gpu-nras": "GPU attested by NVIDIA",
|
|
89
|
+
"gpu-binding": "the GPU attestation nonce is bound to the workload's quote",
|
|
90
|
+
"tls-spki": "the TLS key this client spoke to is in the attested key set",
|
|
91
|
+
"key-custody": "private-key custody appraised",
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
class Transcript {
|
|
95
|
+
constructor() {
|
|
96
|
+
this.checks = [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
add(id, status, detail) {
|
|
100
|
+
this.checks.push({ id, title: CHECKS[id] ?? id, status, detail });
|
|
101
|
+
return status === "pass";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
pass(id, detail) {
|
|
105
|
+
return this.add(id, "pass", detail);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fail(id, detail) {
|
|
109
|
+
return this.add(id, "fail", detail);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
skip(id, detail) {
|
|
113
|
+
return this.add(id, "skip", detail);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const unixTime = (seconds) => new Date(seconds * 1000).toISOString();
|
|
118
|
+
|
|
119
|
+
function randomNonce() {
|
|
120
|
+
return toHex(globalThis.crypto.getRandomValues(new Uint8Array(32)));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// A JSON document, or a reason it is not one. Only a host this client cannot
|
|
124
|
+
/// reach at all throws: an endpoint that answers with an error status or with
|
|
125
|
+
/// something that is not JSON has said something about itself, and the caller
|
|
126
|
+
/// turns that into a failed check rather than an exception.
|
|
127
|
+
async function getJson(fetchImpl, url, what) {
|
|
128
|
+
let res;
|
|
129
|
+
try {
|
|
130
|
+
res = await fetchImpl(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
131
|
+
} catch (err) {
|
|
132
|
+
throw new Error(`${what} unreachable: ${err?.message ?? err}`);
|
|
133
|
+
}
|
|
134
|
+
if (!res.ok) return { ok: false, detail: `${what} answered HTTP ${res.status}` };
|
|
135
|
+
try {
|
|
136
|
+
return { ok: true, body: await res.json() };
|
|
137
|
+
} catch {
|
|
138
|
+
return { ok: false, detail: `${what} answered with something that is not JSON` };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// dstack writes its runtime events with this type, and the RTMR3 replay chains
|
|
143
|
+
// each event's `digest`, never its payload. So a payload only counts once it
|
|
144
|
+
// reproduces the digest that was measured.
|
|
145
|
+
const DSTACK_RUNTIME_EVENT = 0x08000001;
|
|
146
|
+
const IMAGE_PIN = /image:\s*([^\s"\\]+)@sha256:([0-9a-f]{64})/g;
|
|
147
|
+
const REPO_URL = /REPO_URL=([^\s"\\]+)/g;
|
|
148
|
+
const REPO_COMMIT = /REPO_COMMIT=([0-9a-fA-F]{7,40})/g;
|
|
149
|
+
|
|
150
|
+
const short = (hex) => String(hex ?? "").slice(0, 12);
|
|
151
|
+
|
|
152
|
+
/// The payload of the one pre-system-ready dstack event called `name`, or null
|
|
153
|
+
/// when there is not exactly one or its digest does not reproduce.
|
|
154
|
+
export async function measuredEvent(events, name) {
|
|
155
|
+
const found = [];
|
|
156
|
+
for (const e of events ?? []) {
|
|
157
|
+
if (e?.imr !== 3 || e?.event_type !== DSTACK_RUNTIME_EVENT) continue;
|
|
158
|
+
if (e.event === "system-ready") break;
|
|
159
|
+
if (e.event === name) found.push(e);
|
|
160
|
+
}
|
|
161
|
+
if (found.length !== 1) return null;
|
|
162
|
+
const [event] = found;
|
|
163
|
+
const label = new TextEncoder().encode(`:${event.event}:`);
|
|
164
|
+
let payload;
|
|
165
|
+
try {
|
|
166
|
+
payload = fromHex(String(event.event_payload ?? ""));
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const buf = new Uint8Array(4 + label.length + payload.length);
|
|
171
|
+
new DataView(buf.buffer).setUint32(0, DSTACK_RUNTIME_EVENT, true);
|
|
172
|
+
buf.set(label, 4);
|
|
173
|
+
buf.set(payload, 4 + label.length);
|
|
174
|
+
const digest = toHex(await sha384(buf));
|
|
175
|
+
return digest === String(event.digest ?? "").toLowerCase() ? String(event.event_payload).toLowerCase() : null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/// What the measured compose says it runs: the image digests it pins and the
|
|
179
|
+
/// source the launcher clones. Every value here is inside the bytes that hash to
|
|
180
|
+
/// the measured compose-hash, so none of it is the report's word for it.
|
|
181
|
+
function measuredWorkload(appCompose) {
|
|
182
|
+
const compose = String(appCompose ?? "");
|
|
183
|
+
const only = (pattern) => {
|
|
184
|
+
const hits = [...compose.matchAll(pattern)].map((m) => m[1]);
|
|
185
|
+
return hits.length === 1 ? hits[0] : null;
|
|
186
|
+
};
|
|
187
|
+
return {
|
|
188
|
+
images: [...compose.matchAll(IMAGE_PIN)].map((m) => ({ repository: m[1], digest: m[2].toLowerCase() })),
|
|
189
|
+
repoUrl: only(REPO_URL),
|
|
190
|
+
repoCommit: only(REPO_COMMIT)?.toLowerCase() ?? null,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const sourceOf = (measured) =>
|
|
195
|
+
measured.repoUrl && measured.repoCommit ? `${measured.repoUrl} @ ${measured.repoCommit}` : null;
|
|
196
|
+
|
|
197
|
+
/// §9.1 check 4 as a policy rather than a printout. `appCompose` is the measured
|
|
198
|
+
/// compose text, `osImageHash` the measured dstack image, `provenance` the
|
|
199
|
+
/// report's own `source_provenance` (which §4.1 says is not bound into the
|
|
200
|
+
/// quote, so it counts only where the measured compose agrees with it).
|
|
201
|
+
export function gateWorkloadIdentity({ appCompose, osImageHash, provenance, expected }) {
|
|
202
|
+
const measured = measuredWorkload(appCompose);
|
|
203
|
+
const problems = [];
|
|
204
|
+
const [wantRepository, wantDigest] = String(expected?.launcherImage ?? "").split("@sha256:");
|
|
205
|
+
const running = measured.images.filter((i) => i.repository === wantRepository);
|
|
206
|
+
if (running.length !== 1) {
|
|
207
|
+
problems.push(
|
|
208
|
+
running.length === 0
|
|
209
|
+
? `the measured compose runs no ${wantRepository} image`
|
|
210
|
+
: `the measured compose names ${running.length} ${wantRepository} images`,
|
|
211
|
+
);
|
|
212
|
+
} else if (running[0].digest !== String(wantDigest).toLowerCase()) {
|
|
213
|
+
problems.push(`the measured launcher is sha256:${running[0].digest}, this SDK pins sha256:${wantDigest}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (measured.repoUrl === null) {
|
|
217
|
+
problems.push("the measured compose names no single source repository");
|
|
218
|
+
} else if (measured.repoUrl !== expected?.repoUrl) {
|
|
219
|
+
problems.push(`the measured source is ${measured.repoUrl}, this SDK pins ${expected?.repoUrl}`);
|
|
220
|
+
}
|
|
221
|
+
if (measured.repoCommit === null) {
|
|
222
|
+
problems.push("the measured compose pins no single source commit");
|
|
223
|
+
} else if (expected?.repoCommit && measured.repoCommit !== String(expected.repoCommit).toLowerCase()) {
|
|
224
|
+
problems.push(`the measured commit is ${measured.repoCommit}, this SDK pins ${expected.repoCommit}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const stated = provenance ?? {};
|
|
228
|
+
if (measured.repoUrl !== null && stated.repo_url != null && stated.repo_url !== measured.repoUrl) {
|
|
229
|
+
problems.push(`the report declares source ${stated.repo_url}, the measured compose clones ${measured.repoUrl}`);
|
|
230
|
+
}
|
|
231
|
+
if (
|
|
232
|
+
measured.repoCommit !== null &&
|
|
233
|
+
stated.repo_commit != null &&
|
|
234
|
+
String(stated.repo_commit).toLowerCase() !== measured.repoCommit
|
|
235
|
+
) {
|
|
236
|
+
problems.push(`the report declares commit ${stated.repo_commit}, the measured compose pins ${measured.repoCommit}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (expected?.osImageHash) {
|
|
240
|
+
if (typeof osImageHash !== "string") {
|
|
241
|
+
problems.push("the boot log carries no os-image-hash that reproduces its measured digest");
|
|
242
|
+
} else if (osImageHash !== String(expected.osImageHash).toLowerCase()) {
|
|
243
|
+
problems.push(`the measured OS image is ${osImageHash}, this SDK pins ${expected.osImageHash}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
ok: problems.length === 0,
|
|
249
|
+
detail: problems.length
|
|
250
|
+
? problems.join("; ")
|
|
251
|
+
: `launcher ${wantRepository}@sha256:${short(wantDigest)}, dstack OS ${short(expected?.osImageHash)}, ` +
|
|
252
|
+
`source ${sourceOf(measured)}. The pin is a snapshot of a known-good deployment, so it needs updating ` +
|
|
253
|
+
"when the launcher is rebuilt, and it does not establish that the image was built from that source.",
|
|
254
|
+
provenance: sourceOf(measured),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/// The identity appraisal over a report whose compose measurement already holds.
|
|
259
|
+
/// `verifyConfidential` runs it after the fact and the SDK's pre-send gate runs
|
|
260
|
+
/// it before it encrypts anything, so both establish which code holds the key.
|
|
261
|
+
/// `expected` of null is an explicit caller downgrade and reports itself as one.
|
|
262
|
+
export async function appraiseWorkload(report, compose, expected = EXPECTED_WORKLOAD) {
|
|
263
|
+
const evidence = report?.attestation?.evidence ?? {};
|
|
264
|
+
let events;
|
|
265
|
+
try {
|
|
266
|
+
events = JSON.parse(evidence.event_log);
|
|
267
|
+
} catch {
|
|
268
|
+
return { ok: false, detail: "the report's boot event log is not readable" };
|
|
269
|
+
}
|
|
270
|
+
const measuredCompose = await measuredEvent(events, "compose-hash");
|
|
271
|
+
if (measuredCompose === null || measuredCompose !== compose?.composeHash) {
|
|
272
|
+
return { ok: false, detail: "the compose-hash event does not reproduce the digest the RTMR3 replay chains" };
|
|
273
|
+
}
|
|
274
|
+
const measured = measuredWorkload(evidence.app_compose);
|
|
275
|
+
if (expected === null) {
|
|
276
|
+
return {
|
|
277
|
+
ok: true,
|
|
278
|
+
skipped: true,
|
|
279
|
+
detail: "workload identity not pinned by caller, so the transcript establishes a TDX enclave and not which code runs in it",
|
|
280
|
+
provenance: sourceOf(measured),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
return gateWorkloadIdentity({
|
|
284
|
+
appCompose: evidence.app_compose,
|
|
285
|
+
osImageHash: await measuredEvent(events, "os-image-hash"),
|
|
286
|
+
provenance: report?.attestation?.source_provenance,
|
|
287
|
+
expected,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/// The claim gate over an NVIDIA attestation (NRAS) result. Kept apart from the
|
|
292
|
+
/// JWT signature check so the policy is readable and testable on its own: a
|
|
293
|
+
/// signed token saying the GPU failed its measurements is still a failure.
|
|
294
|
+
export function gateNrasClaims({ overall, gpus, nonce, now = Math.floor(Date.now() / 1000) }) {
|
|
295
|
+
const problems = [];
|
|
296
|
+
if (overall?.["x-nvidia-overall-att-result"] !== true) problems.push("overall attestation result is not true");
|
|
297
|
+
if (typeof overall?.eat_nonce !== "string" || overall.eat_nonce.toLowerCase() !== nonce.toLowerCase()) {
|
|
298
|
+
problems.push("the overall token answers a different nonce");
|
|
299
|
+
}
|
|
300
|
+
if (typeof overall?.exp === "number" && now >= overall.exp) problems.push("the overall token has expired");
|
|
301
|
+
|
|
302
|
+
const entries = Object.entries(gpus ?? {});
|
|
303
|
+
if (entries.length === 0) problems.push("no per-GPU token");
|
|
304
|
+
const models = [];
|
|
305
|
+
for (const [name, claims] of entries) {
|
|
306
|
+
const say = (text) => problems.push(`${name}: ${text}`);
|
|
307
|
+
if (claims?.measres !== "success") say("measurements do not match the reference values");
|
|
308
|
+
if (claims?.secboot !== true) say("secure boot is off");
|
|
309
|
+
if (claims?.dbgstat !== "disabled") say("debug mode is not disabled");
|
|
310
|
+
if (claims?.["x-nvidia-gpu-attestation-report-nonce-match"] !== true) say("attestation report answers a different nonce");
|
|
311
|
+
if (claims?.["x-nvidia-attestation-warning"] !== null) {
|
|
312
|
+
say(`attestation warning: ${JSON.stringify(claims?.["x-nvidia-attestation-warning"] ?? null)}`);
|
|
313
|
+
}
|
|
314
|
+
if (typeof claims?.eat_nonce === "string" && claims.eat_nonce.toLowerCase() !== nonce.toLowerCase()) {
|
|
315
|
+
say("token answers a different nonce");
|
|
316
|
+
}
|
|
317
|
+
if (typeof claims?.hwmodel === "string") models.push(`${name} ${claims.hwmodel}`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
ok: problems.length === 0,
|
|
322
|
+
detail: problems.length
|
|
323
|
+
? problems.join("; ")
|
|
324
|
+
: `${models.join(", ")}: measurements match, secure boot on, debug disabled`,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/// The legacy attestation report binds the GPU nonce into the CPU quote's
|
|
329
|
+
/// report-data slot as signing_address(20) || zeros(12) || nvidia_nonce(32).
|
|
330
|
+
/// Both halves have to hold: the address half proves the quote belongs to the
|
|
331
|
+
/// workload that signs its answers, the nonce half proves the GPU evidence was
|
|
332
|
+
/// produced for this quote and not replayed from another machine.
|
|
333
|
+
export function gateGpuBinding({ reportData, signingAddress, nonce }) {
|
|
334
|
+
const slot = String(reportData ?? "").toLowerCase();
|
|
335
|
+
const address = String(signingAddress ?? "").replace(/^0x/, "").toLowerCase();
|
|
336
|
+
const want = `${address}${"0".repeat(24)}${String(nonce ?? "").toLowerCase()}`;
|
|
337
|
+
if (slot.length !== 128 || address.length !== 40) {
|
|
338
|
+
return { ok: false, detail: "the report does not carry a 64-byte report-data slot and a signing address" };
|
|
339
|
+
}
|
|
340
|
+
if (slot !== want) {
|
|
341
|
+
return {
|
|
342
|
+
ok: false,
|
|
343
|
+
detail: slot.slice(0, 40) === address
|
|
344
|
+
? "the quote binds a different GPU nonce than the evidence carries"
|
|
345
|
+
: "the quote binds a different signing address",
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
return { ok: true, detail: `report data binds ${signingAddress} and GPU nonce ${nonce}` };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function verifyNrasTokens(payload, { fetchImpl, nonce, now }) {
|
|
352
|
+
let res;
|
|
353
|
+
try {
|
|
354
|
+
res = await fetchImpl(NRAS_ATTEST_URL, {
|
|
355
|
+
method: "POST",
|
|
356
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
357
|
+
body: payload,
|
|
358
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
359
|
+
});
|
|
360
|
+
} catch (err) {
|
|
361
|
+
return { ok: false, detail: `NVIDIA attestation unreachable: ${err?.message ?? err}` };
|
|
362
|
+
}
|
|
363
|
+
if (!res.ok) return { ok: false, detail: `NVIDIA attestation answered HTTP ${res.status}` };
|
|
364
|
+
const body = await res.json().catch(() => null);
|
|
365
|
+
const overallToken = Array.isArray(body?.[0]) ? body[0][1] : null;
|
|
366
|
+
const gpuTokens = body?.[1];
|
|
367
|
+
if (typeof overallToken !== "string" || gpuTokens === null || typeof gpuTokens !== "object") {
|
|
368
|
+
return { ok: false, detail: "NVIDIA attestation returned an unexpected shape" };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
let jose;
|
|
372
|
+
try {
|
|
373
|
+
jose = await import("jose");
|
|
374
|
+
} catch {
|
|
375
|
+
return { ok: false, detail: "NVIDIA's tokens cannot be checked here: install jose (^6)" };
|
|
376
|
+
}
|
|
377
|
+
// NVIDIA rotates these keys every couple of days, which is why the tokens are
|
|
378
|
+
// verified now and the decoded claims are what gets kept.
|
|
379
|
+
const jwks = jose.createRemoteJWKSet(new URL(NRAS_JWKS_URL));
|
|
380
|
+
const open = async (token) => {
|
|
381
|
+
const { payload: claims } = await jose.jwtVerify(token, jwks, {
|
|
382
|
+
issuer: NRAS_ISSUER,
|
|
383
|
+
algorithms: ["ES384"],
|
|
384
|
+
});
|
|
385
|
+
return claims;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
let overall;
|
|
389
|
+
const gpus = {};
|
|
390
|
+
try {
|
|
391
|
+
overall = await open(overallToken);
|
|
392
|
+
for (const [name, token] of Object.entries(gpuTokens)) gpus[name] = await open(token);
|
|
393
|
+
} catch (err) {
|
|
394
|
+
return { ok: false, detail: `an NVIDIA token did not verify: ${err?.message ?? err}` };
|
|
395
|
+
}
|
|
396
|
+
return { ...gateNrasClaims({ overall, gpus, nonce, now }), claims: { overall, gpus } };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/// Verify one confidential generation end to end. `receiptId` comes from the
|
|
400
|
+
/// `x-receipt-id` header of the response; `requestBytes` and `responseBytes`
|
|
401
|
+
/// are the exact bytes this client sent and received (pass `requestHash` /
|
|
402
|
+
/// `responseHash` instead when only the digests were kept). Under E2EE the
|
|
403
|
+
/// receipt covers the restored plaintext request, so pass
|
|
404
|
+
/// `restoredRequestBytes` (or `restoredRequestHash`) as well.
|
|
405
|
+
///
|
|
406
|
+
/// `expectedWorkload` is the code the enclave must be running; `null` downgrades
|
|
407
|
+
/// that check to a skip and says so. `expectedKeysetDigest` is the key set a
|
|
408
|
+
/// prompt was actually sealed to, which is what ties this transcript to the call
|
|
409
|
+
/// it describes rather than to whatever the endpoint serves now.
|
|
410
|
+
///
|
|
411
|
+
/// The verdict is `verified` when every check that ran passed and every skip is
|
|
412
|
+
/// one of the documented ones, `failed` when a check failed, and `incomplete`
|
|
413
|
+
/// when nothing failed but evidence the verdict would have rested on was
|
|
414
|
+
/// missing. No verification outcome is ever thrown; the one thing that throws is
|
|
415
|
+
/// a gateway this client cannot reach at all, which is not a statement about the
|
|
416
|
+
/// workload.
|
|
417
|
+
export async function verifyConfidential({
|
|
418
|
+
base = DEFAULT_CONFIDENTIAL_BASE,
|
|
419
|
+
model,
|
|
420
|
+
receiptId,
|
|
421
|
+
receipt = null,
|
|
422
|
+
requestBytes = null,
|
|
423
|
+
responseBytes = null,
|
|
424
|
+
requestHash = null,
|
|
425
|
+
responseHash = null,
|
|
426
|
+
restoredRequestBytes = null,
|
|
427
|
+
restoredRequestHash = null,
|
|
428
|
+
e2ee = false,
|
|
429
|
+
expectedWorkload = EXPECTED_WORKLOAD,
|
|
430
|
+
expectedKeysetDigest = null,
|
|
431
|
+
nonce = randomNonce(),
|
|
432
|
+
now = Math.floor(Date.now() / 1000),
|
|
433
|
+
observedSpki = null,
|
|
434
|
+
collateralUrl = undefined,
|
|
435
|
+
fetchImpl = fetch,
|
|
436
|
+
} = {}) {
|
|
437
|
+
if (!receiptId && !receipt) {
|
|
438
|
+
throw new Error("no receipt to verify: the response carried no x-receipt-id header");
|
|
439
|
+
}
|
|
440
|
+
const t = new Transcript();
|
|
441
|
+
const root = String(base).replace(/\/+$/, "");
|
|
442
|
+
const expectedSkips = new Set([CUSTODY]);
|
|
443
|
+
if (!observedSpki) expectedSkips.add(CHANNEL);
|
|
444
|
+
const settle = (extra) => verdict(t, expectedSkips, { nonce, receiptId, model: model ?? null, ...extra });
|
|
445
|
+
|
|
446
|
+
const fetched = await getJson(fetchImpl, `${root}/v1/attestation?nonce=${nonce}`, "the attestation endpoint");
|
|
447
|
+
if (!fetched.ok) {
|
|
448
|
+
// Without a report there is nothing to establish anything against, and the
|
|
449
|
+
// two checks that are never a pass keep saying why they are not.
|
|
450
|
+
for (const id of Object.keys(CHECKS)) {
|
|
451
|
+
if (id !== CUSTODY && id !== CHANNEL) t.fail(id, fetched.detail);
|
|
452
|
+
}
|
|
453
|
+
channelCheck(t, null, observedSpki, root);
|
|
454
|
+
t.skip(CUSTODY, CUSTODY_DETAIL);
|
|
455
|
+
return settle({ keysetDigest: null, provenance: null });
|
|
456
|
+
}
|
|
457
|
+
const report = fetched.body;
|
|
458
|
+
|
|
459
|
+
const binding = await verifyReportBinding(report, nonce, { now });
|
|
460
|
+
const digest = binding.workloadKeysetDigest;
|
|
461
|
+
const keyset = binding.keyset;
|
|
462
|
+
const badBinding = binding.checks.find((c) => !c.ok);
|
|
463
|
+
if (badBinding) {
|
|
464
|
+
t.fail("keyset-digest", badBinding.detail ?? `${badBinding.name} failed`);
|
|
465
|
+
} else if (expectedKeysetDigest && digest !== expectedKeysetDigest) {
|
|
466
|
+
// The report is sound, but it describes a different key set than the one
|
|
467
|
+
// the prompt was sealed to, so it is not this call's report.
|
|
468
|
+
t.fail("keyset-digest", `the endpoint now serves ${digest}, this call was sealed to ${expectedKeysetDigest}`);
|
|
469
|
+
} else {
|
|
470
|
+
t.pass("keyset-digest", `${digest}, aci/1, valid until ${unixTime(keyset.not_after)}`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const quote = await verifyQuote(report, { collateralUrl, now });
|
|
474
|
+
if (!quote.ok) {
|
|
475
|
+
t.fail("tdx-quote", quote.detail ?? "quote verification failed");
|
|
476
|
+
} else if (quote.status !== "UpToDate") {
|
|
477
|
+
const advisories = quote.advisoryIds?.length ? ` (${quote.advisoryIds.join(", ")})` : "";
|
|
478
|
+
t.fail("tdx-quote", `verified to Intel's root, but the platform TCB is ${quote.status}${advisories}`);
|
|
479
|
+
} else {
|
|
480
|
+
t.pass("tdx-quote", "verified to Intel's root, platform TCB up to date");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// The 32 bytes the enclave asked the CPU to sign, read out of the verified
|
|
484
|
+
// quote rather than off the report's own copy of them. A quote too malformed
|
|
485
|
+
// to read at all leaves nothing to compare, which the check below says.
|
|
486
|
+
const slot = quote.ok ? quote.report.reportData : readReportData(report.attestation?.evidence?.quote);
|
|
487
|
+
const unverified = quote.ok ? "" : " (read off an unverified quote)";
|
|
488
|
+
if (!digest) {
|
|
489
|
+
t.fail("report-data-binding", "no key set was established, so nothing can be recomputed against the quote");
|
|
490
|
+
} else {
|
|
491
|
+
const expected = await computeReportData(digest, nonce);
|
|
492
|
+
const bound = slot.length === 64 ? toHex(slot.slice(0, 32)) : "nothing: the quote carries no report-data slot";
|
|
493
|
+
const padded = slot.length === 64 && slot.slice(32).every((b) => b === 0);
|
|
494
|
+
if (bound === expected && padded) {
|
|
495
|
+
t.pass("report-data-binding", `the quote's report data is sha256 of our nonce over ${digest}${unverified}`);
|
|
496
|
+
} else {
|
|
497
|
+
t.fail("report-data-binding", `the quote binds ${bound}, this nonce and key set produce ${expected}`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
let measurement = null;
|
|
502
|
+
try {
|
|
503
|
+
measurement = await verifyComposeMeasurement(report, { statedRtmr3: quote.ok ? quote.report.rtMr3 : null });
|
|
504
|
+
const rtmr3 = measurement.checks.find((c) => c.name === "rtmr3");
|
|
505
|
+
t.add("rtmr3-replay", rtmr3.ok ? "pass" : "fail", rtmr3.ok ? `${toHex(measurement.rtmr3)}${unverified}` : rtmr3.detail);
|
|
506
|
+
const composeHash = measurement.checks.find((c) => c.name === "compose_hash");
|
|
507
|
+
t.add(
|
|
508
|
+
"compose-hash",
|
|
509
|
+
composeHash.ok ? "pass" : "fail",
|
|
510
|
+
composeHash.ok
|
|
511
|
+
? `sha256(app_compose)=${measurement.composeHash} measured before system-ready`
|
|
512
|
+
: composeHash.detail,
|
|
513
|
+
);
|
|
514
|
+
if (!measurement.ok) measurement = null;
|
|
515
|
+
} catch (err) {
|
|
516
|
+
t.fail("rtmr3-replay", `the report's boot evidence could not be replayed: ${err?.message ?? err}`);
|
|
517
|
+
t.fail("compose-hash", "no replayable boot evidence to measure the compose against");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// §9.1 check 4. Everything above establishes a genuine TDX enclave; this is
|
|
521
|
+
// the check that says which code is running inside it.
|
|
522
|
+
let provenance = null;
|
|
523
|
+
if (!measurement) {
|
|
524
|
+
t.fail(WORKLOAD, "no measured compose to read the workload identity out of");
|
|
525
|
+
} else {
|
|
526
|
+
const identity = await appraiseWorkload(report, measurement, expectedWorkload);
|
|
527
|
+
provenance = identity.provenance ?? null;
|
|
528
|
+
t.add(WORKLOAD, identity.skipped ? "skip" : identity.ok ? "pass" : "fail", identity.detail);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// A receipt lives in the workload's memory only, so a caller that fetched it
|
|
532
|
+
// when the answer arrived passes it in rather than hoping it is still there.
|
|
533
|
+
const document = receipt ?? (await receiptDocument(fetchImpl, root, receiptId));
|
|
534
|
+
if (!document) {
|
|
535
|
+
for (const id of ["receipt-signature", "receipt-keyset-binding", "request-hash", "response-hash"]) {
|
|
536
|
+
t.fail(id, "the receipt for this call could not be read");
|
|
537
|
+
}
|
|
538
|
+
t.fail("upstream-verified", "no receipt to read an upstream verification out of");
|
|
539
|
+
t.skip("session-id", "no session is cited");
|
|
540
|
+
t.skip("session-evidence", "no session is cited");
|
|
541
|
+
await gpuChecks(t, { root, fetchImpl, model, digest, quote, collateralUrl, now });
|
|
542
|
+
channelCheck(t, keyset, observedSpki, root);
|
|
543
|
+
t.skip(CUSTODY, CUSTODY_DETAIL);
|
|
544
|
+
return settle({ keysetDigest: digest ?? null, provenance });
|
|
545
|
+
}
|
|
546
|
+
const verified = keyset ? await verifyReceipt(document, keyset, digest) : null;
|
|
547
|
+
const signature = verified?.checks.find((c) => c.name === "signature");
|
|
548
|
+
t.add(
|
|
549
|
+
"receipt-signature",
|
|
550
|
+
signature?.ok ? "pass" : "fail",
|
|
551
|
+
signature?.ok ? `key "${document.key_id}"` : (signature?.detail ?? "no key set to verify the receipt against"),
|
|
552
|
+
);
|
|
553
|
+
const version = verified?.checks.find((c) => c.name === "api_version");
|
|
554
|
+
const bound = verified?.checks.find((c) => c.name === "workload_keyset_digest");
|
|
555
|
+
if (version?.ok === false) {
|
|
556
|
+
t.fail("receipt-keyset-binding", version.detail);
|
|
557
|
+
} else {
|
|
558
|
+
t.add(
|
|
559
|
+
"receipt-keyset-binding",
|
|
560
|
+
bound?.ok ? "pass" : "fail",
|
|
561
|
+
bound?.ok ? `${digest}, served at ${unixTime(document.served_at)}` : (bound?.detail ?? "the receipt binds no key set"),
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
await bodyCheck(t, "request-hash", document, "request.received", {
|
|
566
|
+
bytes: e2ee ? restoredRequestBytes : requestBytes,
|
|
567
|
+
hash: e2ee ? restoredRequestHash : requestHash,
|
|
568
|
+
});
|
|
569
|
+
await bodyCheck(t, "response-hash", document, "response.returned", { bytes: responseBytes, hash: responseHash });
|
|
570
|
+
|
|
571
|
+
const sessionId = await upstreamCheck(t, document);
|
|
572
|
+
if (sessionId) await sessionChecks(t, { root, fetchImpl, sessionId, servedAt: document.served_at });
|
|
573
|
+
|
|
574
|
+
await gpuChecks(t, { root, fetchImpl, model, digest, quote, collateralUrl, now });
|
|
575
|
+
|
|
576
|
+
channelCheck(t, keyset, observedSpki, root);
|
|
577
|
+
|
|
578
|
+
// §9.1 check 5. The report does publish dstack-kms custody evidence, but
|
|
579
|
+
// appraising it needs the KMS root key and chain rules no verifier in this
|
|
580
|
+
// protocol implements yet, so this is reported as unproven rather than
|
|
581
|
+
// waved through.
|
|
582
|
+
t.skip(CUSTODY, CUSTODY_DETAIL);
|
|
583
|
+
|
|
584
|
+
return settle({ model: document.model ?? model ?? null, keysetDigest: digest ?? null, provenance });
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const CUSTODY_DETAIL =
|
|
588
|
+
"no verifier appraises the KMS custody chain yet; encrypt end to end rather than rely on it";
|
|
589
|
+
|
|
590
|
+
/// The verdict rule over a finished check list. `failed` means a check actually
|
|
591
|
+
/// failed. `incomplete` means nothing failed but evidence the verdict would have
|
|
592
|
+
/// rested on was missing, which is a different thing to say and is said with a
|
|
593
|
+
/// different word.
|
|
594
|
+
export function verdictOf(checks, expectedSkips = [CUSTODY, CHANNEL]) {
|
|
595
|
+
const tolerated = new Set(expectedSkips);
|
|
596
|
+
if (checks.some((c) => c.status === "fail")) return "failed";
|
|
597
|
+
return checks.some((c) => c.status === "skip" && !tolerated.has(c.id)) ? "incomplete" : "verified";
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function verdict(t, expectedSkips, rest) {
|
|
601
|
+
return { verdict: verdictOf(t.checks, expectedSkips), checks: t.checks, ...rest };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function readReportData(quoteHex) {
|
|
605
|
+
try {
|
|
606
|
+
return quoteReportData(String(quoteHex ?? ""));
|
|
607
|
+
} catch {
|
|
608
|
+
return new Uint8Array(0);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function receiptDocument(fetchImpl, root, receiptId) {
|
|
613
|
+
const fetched = await getJson(fetchImpl, `${root}/v1/receipts/${encodeURIComponent(receiptId)}`, "the receipt endpoint");
|
|
614
|
+
return fetched.ok ? fetched.body : null;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function bodyCheck(t, id, receipt, event, { bytes, hash }) {
|
|
618
|
+
const recorded = findEvent(receipt, event)?.body_hash;
|
|
619
|
+
if (typeof recorded !== "string") return t.fail(id, `the receipt carries no ${event} body hash`);
|
|
620
|
+
const computed = bytes ? await hashBody(bytes) : hash;
|
|
621
|
+
if (!computed) {
|
|
622
|
+
return t.skip(id, `no ${event === "request.received" ? "request" : "response"} bytes were kept to compare`);
|
|
623
|
+
}
|
|
624
|
+
if (computed === recorded) return t.pass(id, recorded);
|
|
625
|
+
return t.fail(id, `the bytes hash to ${computed}, the receipt records ${recorded}`);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function upstreamCheck(t, receipt) {
|
|
629
|
+
const events = Array.isArray(receipt.event_log) ? receipt.event_log.filter((e) => e.type === "upstream.verified") : [];
|
|
630
|
+
if (events.length === 0) {
|
|
631
|
+
t.fail("upstream-verified", "the receipt records no upstream verification");
|
|
632
|
+
t.skip("session-id", "no session is cited");
|
|
633
|
+
t.skip("session-evidence", "no session is cited");
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
const verified = events.find((e) => e.result === "verified");
|
|
637
|
+
if (!verified) {
|
|
638
|
+
const why = events[0].reason ?? "no reason given";
|
|
639
|
+
t.fail("upstream-verified", `the receipt records serving through an unverified upstream: ${why}`);
|
|
640
|
+
t.skip("session-id", "no session is cited");
|
|
641
|
+
t.skip("session-evidence", "no session is cited");
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
if (verified.required !== true) {
|
|
645
|
+
t.fail("upstream-verified", "the upstream verified, but verification was not required for this request");
|
|
646
|
+
} else if (typeof verified.session_id !== "string") {
|
|
647
|
+
t.fail("upstream-verified", "the upstream verified but the receipt cites no session");
|
|
648
|
+
} else {
|
|
649
|
+
t.pass("upstream-verified", `${verified.model_id ?? "the model"} served through session ${verified.session_id}`);
|
|
650
|
+
}
|
|
651
|
+
return typeof verified.session_id === "string" ? verified.session_id : null;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function sessionChecks(t, { root, fetchImpl, sessionId, servedAt }) {
|
|
655
|
+
let fetched;
|
|
656
|
+
try {
|
|
657
|
+
fetched = await getJson(fetchImpl, `${root}/v1/sessions/${encodeURIComponent(sessionId)}`, "the sessions endpoint");
|
|
658
|
+
} catch (err) {
|
|
659
|
+
fetched = { ok: false, detail: err?.message ?? String(err) };
|
|
660
|
+
}
|
|
661
|
+
if (!fetched.ok) {
|
|
662
|
+
t.skip("session-id", `the cited session could not be fetched: ${fetched.detail}`);
|
|
663
|
+
t.skip("session-evidence", "no session record to hash");
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const session = fetched.body;
|
|
667
|
+
const problems = [];
|
|
668
|
+
if ((await computeSessionId(session)) !== sessionId) problems.push("the record does not hash to the cited id");
|
|
669
|
+
if (session.api_version !== "aci/1") problems.push(`api_version "${session.api_version}" is not "aci/1"`);
|
|
670
|
+
if (!(servedAt >= session.established_at && servedAt <= session.expires_at)) {
|
|
671
|
+
problems.push("the request was served outside the session's validity window");
|
|
672
|
+
}
|
|
673
|
+
t.add("session-id", problems.length === 0 ? "pass" : "fail", problems.length === 0
|
|
674
|
+
? `${session.upstream_name}, valid ${unixTime(session.established_at)} to ${unixTime(session.expires_at)}`
|
|
675
|
+
: problems.join("; "));
|
|
676
|
+
|
|
677
|
+
const evidenceOk = await checkSessionEvidence(session.evidence);
|
|
678
|
+
t.add("session-evidence", evidenceOk ? "pass" : "fail", evidenceOk
|
|
679
|
+
? session.evidence.digest
|
|
680
|
+
: "the session's evidence does not hash to the digest it records");
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function gpuChecks(t, { root, fetchImpl, model, digest, quote, collateralUrl, now }) {
|
|
684
|
+
let fetched;
|
|
685
|
+
try {
|
|
686
|
+
const query = model ? `?model=${encodeURIComponent(model)}` : "";
|
|
687
|
+
fetched = await getJson(fetchImpl, `${root}/v1/gpu-evidence${query}`, "the GPU evidence endpoint");
|
|
688
|
+
} catch (err) {
|
|
689
|
+
fetched = { ok: false, detail: err?.message ?? String(err) };
|
|
690
|
+
}
|
|
691
|
+
if (!fetched.ok) {
|
|
692
|
+
t.skip("gpu-nras", `the GPU evidence could not be fetched: ${fetched.detail}`);
|
|
693
|
+
t.skip("gpu-binding", "no GPU evidence to bind");
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const report = fetched.body;
|
|
697
|
+
const payload = report?.nvidia_payload;
|
|
698
|
+
if (typeof payload !== "string") {
|
|
699
|
+
t.fail("gpu-nras", "the GPU evidence carries no NVIDIA attestation payload");
|
|
700
|
+
t.fail("gpu-binding", "no GPU evidence to bind");
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
let nonce;
|
|
704
|
+
try {
|
|
705
|
+
nonce = JSON.parse(payload).nonce;
|
|
706
|
+
} catch {
|
|
707
|
+
nonce = null;
|
|
708
|
+
}
|
|
709
|
+
if (typeof nonce !== "string") {
|
|
710
|
+
t.fail("gpu-nras", "the NVIDIA attestation payload carries no nonce");
|
|
711
|
+
t.fail("gpu-binding", "no GPU nonce to bind");
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const nras = await verifyNrasTokens(payload, { fetchImpl, nonce, now });
|
|
716
|
+
t.add("gpu-nras", nras.ok ? "pass" : "fail", nras.detail);
|
|
717
|
+
|
|
718
|
+
// The GPU evidence is only worth anything once it is tied to the workload we
|
|
719
|
+
// verified. Its own quote is verified here, the report-data slot is read out
|
|
720
|
+
// of that verified structure, and the TD it came from is held against the TD
|
|
721
|
+
// whose quote carried our nonce. The plaintext key-set field the same
|
|
722
|
+
// response supplies is a label, not a binding.
|
|
723
|
+
if (report.workload_keyset_digest !== digest) {
|
|
724
|
+
const named = report.workload_keyset_digest;
|
|
725
|
+
return t.fail("gpu-binding", `the GPU evidence names key set ${named}, not the one that served us`);
|
|
726
|
+
}
|
|
727
|
+
if (typeof report.intel_quote !== "string") {
|
|
728
|
+
return t.fail("gpu-binding", "the GPU evidence carries no CPU quote to bind against");
|
|
729
|
+
}
|
|
730
|
+
if (!quote?.ok) {
|
|
731
|
+
return t.fail("gpu-binding", "the workload's own quote did not verify, so the GPU evidence cannot be tied to it");
|
|
732
|
+
}
|
|
733
|
+
const gpuQuote = await verifyRawQuote(report.intel_quote, { collateralUrl, now });
|
|
734
|
+
if (!gpuQuote.ok) return t.fail("gpu-binding", `the GPU evidence's own quote did not verify: ${gpuQuote.detail}`);
|
|
735
|
+
const tie = sameTd(quote.report, gpuQuote.report);
|
|
736
|
+
if (!tie.ok) {
|
|
737
|
+
return t.fail(
|
|
738
|
+
"gpu-binding",
|
|
739
|
+
`the GPU evidence's quote comes from a different TD: ${tie.differing.join(", ")} do not match the workload's quote`,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
const gate = gateGpuBinding({
|
|
743
|
+
reportData: toHex(gpuQuote.report.reportData),
|
|
744
|
+
signingAddress: report.signing_address,
|
|
745
|
+
nonce,
|
|
746
|
+
});
|
|
747
|
+
t.add(
|
|
748
|
+
"gpu-binding",
|
|
749
|
+
gate.ok ? "pass" : "fail",
|
|
750
|
+
gate.ok ? `${gate.detail}, quoted by the TD that carried our nonce` : gate.detail,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const TD_MEASUREMENTS = ["mrTd", "rtMr0", "rtMr1", "rtMr2", "rtMr3"];
|
|
755
|
+
|
|
756
|
+
/// Whether two verified TD reports describe the same TD. RTMR3 covers the
|
|
757
|
+
/// instance id, so this is a per-instance tie and not merely "another box
|
|
758
|
+
/// running the same image".
|
|
759
|
+
export function sameTd(a, b) {
|
|
760
|
+
const differing = TD_MEASUREMENTS.filter((f) => toHex(a?.[f] ?? []) !== toHex(b?.[f] ?? []));
|
|
761
|
+
return { ok: differing.length === 0, differing };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/// §9.1 check 6. The pin says the connection terminated at a key the enclave
|
|
765
|
+
/// published. It does not say TLS terminates inside the enclave: no evidence in
|
|
766
|
+
/// this protocol establishes that, which is why the honest answer without an
|
|
767
|
+
/// observed certificate is a skip and why E2EE is the mechanism that does not
|
|
768
|
+
/// depend on the answer.
|
|
769
|
+
function channelCheck(t, keyset, observedSpki, root) {
|
|
770
|
+
if (!observedSpki) {
|
|
771
|
+
return t.skip(
|
|
772
|
+
CHANNEL,
|
|
773
|
+
"no TLS certificate was observed from here; a prompt's protection rests on end-to-end encryption, not on this pin",
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
const entries = Array.isArray(keyset?.tls_public_keys) ? keyset.tls_public_keys : [];
|
|
777
|
+
if (entries.length === 0) return t.fail(CHANNEL, "the attested key set publishes no TLS key to pin against");
|
|
778
|
+
const host = (() => {
|
|
779
|
+
try {
|
|
780
|
+
return new URL(root).hostname.toLowerCase();
|
|
781
|
+
} catch {
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
})();
|
|
785
|
+
const observed = observedSpki.toLowerCase();
|
|
786
|
+
// §3.1 makes `domain` optional, and the same report shape uses explicit nulls
|
|
787
|
+
// for unknown values, so anything that is not a string is an unscoped entry.
|
|
788
|
+
const candidates = entries.filter((k) => typeof k.domain !== "string" || host === null || k.domain.toLowerCase() === host);
|
|
789
|
+
return candidates.some((k) => String(k.spki_sha256).toLowerCase() === observed)
|
|
790
|
+
? t.pass(CHANNEL, `the observed TLS key ${observed} is in the attested key set`)
|
|
791
|
+
: t.fail(CHANNEL, `the observed TLS key ${observed} is not in the attested key set`);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/// The SHA-256 of the SPKI the TLS server at `url` actually presented, for the
|
|
795
|
+
/// channel check. `fetch` does not expose the peer certificate, so this opens
|
|
796
|
+
/// its own connection; the pin is only meaningful against a host that serves
|
|
797
|
+
/// the attested workload directly, not through a relay that terminates TLS of
|
|
798
|
+
/// its own.
|
|
799
|
+
export async function observeTlsSpki(url) {
|
|
800
|
+
const { connect } = await import("node:tls");
|
|
801
|
+
const { X509Certificate, createHash } = await import("node:crypto");
|
|
802
|
+
const target = new URL(url);
|
|
803
|
+
return new Promise((resolve, reject) => {
|
|
804
|
+
const socket = connect(
|
|
805
|
+
{ host: target.hostname, port: Number(target.port || 443), servername: target.hostname },
|
|
806
|
+
() => {
|
|
807
|
+
const peer = socket.getPeerCertificate();
|
|
808
|
+
socket.end();
|
|
809
|
+
if (!peer?.raw) return reject(new Error("the TLS peer presented no certificate"));
|
|
810
|
+
// getPeerCertificate().pubkey is the raw EC point, not the SPKI the
|
|
811
|
+
// keyset pins, so the key is re-exported from the certificate itself.
|
|
812
|
+
const spki = new X509Certificate(peer.raw).publicKey.export({ type: "spki", format: "der" });
|
|
813
|
+
resolve(createHash("sha256").update(spki).digest("hex"));
|
|
814
|
+
},
|
|
815
|
+
);
|
|
816
|
+
socket.on("error", reject);
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/// The checks table as a few lines of text, for a terminal or a tool result.
|
|
821
|
+
export function renderChecks(result) {
|
|
822
|
+
const mark = { pass: "ok ", fail: "FAIL", skip: "skip" };
|
|
823
|
+
const lines = result.checks.map((c) => `${mark[c.status]} ${c.title}${c.detail ? `\n ${c.detail}` : ""}`);
|
|
824
|
+
const counts = ["pass", "fail", "skip"].map((s) => `${result.checks.filter((c) => c.status === s).length} ${s}`);
|
|
825
|
+
return `${lines.join("\n")}\n\n${result.verdict} (${counts.join(", ")})`;
|
|
826
|
+
}
|