@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
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Ported from Dstack-TEE/private-ai-gateway clients/verifier-ts @ b6b5c1b, Apache-2.0.
|
|
2
|
+
//
|
|
3
|
+
// The ACI digest constructions (Appendix A, §3.1, §3.2). Artifacts the service
|
|
4
|
+
// builds are hashed as the exact served bytes; the attestation statement is the
|
|
5
|
+
// one report payload a verifier constructs itself, as a fixed byte template
|
|
6
|
+
// whose inputs are restricted so no JSON escaping is ever needed.
|
|
7
|
+
import { jcsBytes, sha256Hex, sha256Prefixed } from "./crypto.mjs";
|
|
8
|
+
import { AciFormatError } from "./errors.mjs";
|
|
9
|
+
|
|
10
|
+
const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
11
|
+
const NONCE_RE = /^[0-9a-f]{64}$/;
|
|
12
|
+
|
|
13
|
+
/// `workload_keyset_digest` (§3.1): sha256 over the keyset's JCS form.
|
|
14
|
+
export async function computeKeysetDigest(keyset) {
|
|
15
|
+
return sha256Prefixed(jcsBytes(keyset));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// The exact attestation-statement bytes (§3.2) for a keyset digest and the
|
|
19
|
+
/// nonce the client sent. A null or undefined nonce means the query parameter
|
|
20
|
+
/// was omitted, which puts the JSON literal `null` in the template.
|
|
21
|
+
export function attestationStatement(keysetDigest, nonce) {
|
|
22
|
+
if (!DIGEST_RE.test(keysetDigest)) {
|
|
23
|
+
throw new AciFormatError(`keyset digest is not sha256:<64-hex>: "${keysetDigest}"`);
|
|
24
|
+
}
|
|
25
|
+
if (nonce != null && !NONCE_RE.test(nonce)) {
|
|
26
|
+
throw new AciFormatError("nonce must be exactly 64 lowercase hex characters (§3.2)");
|
|
27
|
+
}
|
|
28
|
+
const noncePart = nonce == null ? "null" : `"${nonce}"`;
|
|
29
|
+
return new TextEncoder().encode(
|
|
30
|
+
`{"keyset_digest":"${keysetDigest}","nonce":${noncePart},"purpose":"aci.report_data.v1"}`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// `report_data` (§3.2): SHA-256 of the attestation statement, as bare lowercase
|
|
35
|
+
/// hex (it fills a report-data slot, not an ACI digest string). The TEE places
|
|
36
|
+
/// these 32 bytes zero-padded to 64 in the quote's report-data field.
|
|
37
|
+
export async function computeReportData(keysetDigest, nonce) {
|
|
38
|
+
return sha256Hex(attestationStatement(keysetDigest, nonce));
|
|
39
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Ported from Dstack-TEE/private-ai-gateway clients/verifier-ts @ b6b5c1b, Apache-2.0.
|
|
2
|
+
//
|
|
3
|
+
// Errors raised for conditions that are not ordinary verification failures. A
|
|
4
|
+
// failed check (bad signature, wrong hash) is reported as `ok: false` in the
|
|
5
|
+
// result objects and never thrown, so a caller cannot pass by forgetting a
|
|
6
|
+
// try/catch. These errors mean the input is malformed.
|
|
7
|
+
|
|
8
|
+
export class AciError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "AciError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// A value would not parse (hex, base64, JSON) or violates a spec-pinned format.
|
|
16
|
+
export class AciFormatError extends AciError {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "AciFormatError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Ported from Dstack-TEE/private-ai-gateway clients/verifier-ts @ b6b5c1b, Apache-2.0.
|
|
2
|
+
//
|
|
3
|
+
// The ACI check layer: the digest constructions (Appendix A, §3.1, §3.2),
|
|
4
|
+
// report binding and the hardware quote (§9.1), receipts and body hashes
|
|
5
|
+
// (§9.3), and attested sessions (§8, §9.2). Prism's own transcript (which
|
|
6
|
+
// checks run, which are honest skips, and what the verdict means) lives in
|
|
7
|
+
// ../../attest.mjs; the upstream `transcript.ts` renders a different one and is
|
|
8
|
+
// not ported.
|
|
9
|
+
export { AciError, AciFormatError } from "./errors.mjs";
|
|
10
|
+
export {
|
|
11
|
+
fromBase64,
|
|
12
|
+
fromHex,
|
|
13
|
+
jcsBytes,
|
|
14
|
+
sha256,
|
|
15
|
+
sha256Hex,
|
|
16
|
+
sha256Prefixed,
|
|
17
|
+
sha384,
|
|
18
|
+
toBase64,
|
|
19
|
+
toHex,
|
|
20
|
+
verifyEd25519,
|
|
21
|
+
} from "./crypto.mjs";
|
|
22
|
+
export { attestationStatement, computeKeysetDigest, computeReportData } from "./digest.mjs";
|
|
23
|
+
export {
|
|
24
|
+
quoteReportData,
|
|
25
|
+
replayRtmr3,
|
|
26
|
+
verifyComposeMeasurement,
|
|
27
|
+
verifyQuote,
|
|
28
|
+
verifyRawQuote,
|
|
29
|
+
verifyReportBinding,
|
|
30
|
+
} from "./report.mjs";
|
|
31
|
+
export { checkRequestBodyHash, checkResponseBodyHash, findEvent, hashBody, verifyReceipt } from "./receipt.mjs";
|
|
32
|
+
export { checkSessionApiVersion, checkSessionEvidence, computeSessionId } from "./session.mjs";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Ported from Dstack-TEE/private-ai-gateway clients/verifier-ts @ b6b5c1b, Apache-2.0.
|
|
2
|
+
//
|
|
3
|
+
// Receipt verification (§7, §9.3). A receipt is one JSON document; its
|
|
4
|
+
// `signature` is Ed25519 over JCS(document minus `signature`) under a key the
|
|
5
|
+
// established keyset lists. "Established" means a keyset whose digest the
|
|
6
|
+
// caller verified.
|
|
7
|
+
import { fromHex, jcsBytes, sha256Prefixed, verifyEd25519 } from "./crypto.mjs";
|
|
8
|
+
|
|
9
|
+
/// §9.3 checks 1-2: the `signature` member verifies over JCS(document minus
|
|
10
|
+
/// `signature`) under the keyset entry `key_id` names, and the document's
|
|
11
|
+
/// `workload_keyset_digest` equals the established digest. Documents whose
|
|
12
|
+
/// `api_version` is not `aci/1` are rejected (Appendix B).
|
|
13
|
+
export async function verifyReceipt(document, keyset, establishedDigest) {
|
|
14
|
+
const checks = [];
|
|
15
|
+
|
|
16
|
+
const signingKeys = Array.isArray(keyset.receipt_signing_keys) ? keyset.receipt_signing_keys : [];
|
|
17
|
+
const keyEntry = signingKeys.find((k) => k.key_id === document.key_id);
|
|
18
|
+
if (!keyEntry) {
|
|
19
|
+
checks.push({
|
|
20
|
+
name: "signature",
|
|
21
|
+
ok: false,
|
|
22
|
+
detail: `key_id "${document.key_id}" not in receipt_signing_keys`,
|
|
23
|
+
});
|
|
24
|
+
} else if (keyEntry.algo !== "ed25519") {
|
|
25
|
+
// Appendix B: ed25519 is the only defined signature algorithm.
|
|
26
|
+
checks.push({ name: "signature", ok: false, detail: `unsupported signature algo "${keyEntry.algo}"` });
|
|
27
|
+
} else {
|
|
28
|
+
const { signature, ...unsigned } = document;
|
|
29
|
+
let ok = false;
|
|
30
|
+
try {
|
|
31
|
+
ok = await verifyEd25519(fromHex(keyEntry.public_key), fromHex(signature), jcsBytes(unsigned));
|
|
32
|
+
} catch {
|
|
33
|
+
// Malformed hex is a failed verification, not a thrown one.
|
|
34
|
+
}
|
|
35
|
+
checks.push({
|
|
36
|
+
name: "signature",
|
|
37
|
+
ok,
|
|
38
|
+
...(ok ? {} : { detail: `ed25519 verification failed under "${document.key_id}"` }),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const versionOk = document.api_version === "aci/1";
|
|
43
|
+
checks.push({
|
|
44
|
+
name: "api_version",
|
|
45
|
+
ok: versionOk,
|
|
46
|
+
...(versionOk ? {} : { detail: `api_version "${document.api_version}" is not "aci/1"` }),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const boundOk = document.workload_keyset_digest === establishedDigest;
|
|
50
|
+
checks.push({
|
|
51
|
+
name: "workload_keyset_digest",
|
|
52
|
+
ok: boundOk,
|
|
53
|
+
...(boundOk
|
|
54
|
+
? {}
|
|
55
|
+
: { detail: `document ${document.workload_keyset_digest} != established ${establishedDigest}` }),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return { ok: checks.every((c) => c.ok), checks, payload: document };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function findEvent(payload, type) {
|
|
62
|
+
// Server-supplied JSON: a malformed document is a failed lookup, not a throw.
|
|
63
|
+
if (!Array.isArray(payload.event_log)) return undefined;
|
|
64
|
+
return payload.event_log.find((e) => e.type === type);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// `sha256:<hex>` of raw body bytes, the form ACI body hashes use (Appendix A).
|
|
68
|
+
export async function hashBody(body) {
|
|
69
|
+
return sha256Prefixed(typeof body === "string" ? new TextEncoder().encode(body) : body);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// §9.3 check 3: `request.received.body_hash` matches the plaintext wire body,
|
|
73
|
+
/// or the compact post-decryption JSON body for E2EE (§7.4).
|
|
74
|
+
export async function checkRequestBodyHash(payload, requestBody) {
|
|
75
|
+
return eventHashMatches(payload, "request.received", requestBody);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// §9.3 check 4: `response.returned.body_hash` matches the response bytes this
|
|
79
|
+
/// client read off the wire, SSE framing included (§7.4).
|
|
80
|
+
export async function checkResponseBodyHash(payload, responseBody) {
|
|
81
|
+
return eventHashMatches(payload, "response.returned", responseBody);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function eventHashMatches(payload, type, body) {
|
|
85
|
+
const expected = findEvent(payload, type)?.body_hash;
|
|
86
|
+
if (typeof expected !== "string") return false;
|
|
87
|
+
return (await hashBody(body)) === expected;
|
|
88
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Ported from Dstack-TEE/private-ai-gateway clients/verifier-ts @ b6b5c1b, Apache-2.0.
|
|
2
|
+
//
|
|
3
|
+
// Report binding: §9.1 check 2 (keyset bytes -> digest -> statement ->
|
|
4
|
+
// report_data), check 3 (expiry) and the aci/1 protocol gate; check 4 (the
|
|
5
|
+
// booted compose is the measured one); check 1 (the quote verifies to the Intel
|
|
6
|
+
// root) through @phala/dcap-qvl.
|
|
7
|
+
//
|
|
8
|
+
// The departures from the TypeScript original all exist so the caller can
|
|
9
|
+
// apply its own policy to material the original keeps private: the verified TD
|
|
10
|
+
// report and its advisory ids are returned rather than reduced to a boolean,
|
|
11
|
+
// `verifyRawQuote`, `replayRtmr3` and `quoteReportData` are exported, and the
|
|
12
|
+
// collateral URL is always passed explicitly (the library's no-URL default
|
|
13
|
+
// silently uses a third-party PCCS).
|
|
14
|
+
import { computeKeysetDigest, computeReportData } from "./digest.mjs";
|
|
15
|
+
import { fromHex, sha256Hex, sha384 } from "./crypto.mjs";
|
|
16
|
+
import { AciFormatError } from "./errors.mjs";
|
|
17
|
+
|
|
18
|
+
// Byte offsets into a v4 TDX quote: 48-byte header, then the TDReport10 fields
|
|
19
|
+
// up to rt_mr3 (472 bytes) and the 64-byte report-data slot behind it.
|
|
20
|
+
const TDX_RTMR3_OFFSET = 520;
|
|
21
|
+
const TDX_REPORT_DATA_OFFSET = 568;
|
|
22
|
+
|
|
23
|
+
/// Verify the report's cryptographic bindings for `nonce`, the value this
|
|
24
|
+
/// client sent to `GET /v1/aci/attestation`, or null when it sent none (§3.2).
|
|
25
|
+
/// One recomputation establishes that the keyset is exactly what the quote
|
|
26
|
+
/// bound and that the quote postdates the challenge (§9.1 check 2).
|
|
27
|
+
export async function verifyReportBinding(report, nonce, { now = Math.floor(Date.now() / 1000) } = {}) {
|
|
28
|
+
const checks = [];
|
|
29
|
+
|
|
30
|
+
const versionOk = report.api_version === "aci/1";
|
|
31
|
+
checks.push({
|
|
32
|
+
name: "api_version",
|
|
33
|
+
ok: versionOk,
|
|
34
|
+
...(versionOk ? {} : { detail: `api_version "${report.api_version}" is not "aci/1"` }),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const keysetValue = report.attestation?.workload_keyset;
|
|
38
|
+
if (keysetValue === null || typeof keysetValue !== "object" || Array.isArray(keysetValue)) {
|
|
39
|
+
const detail = "workload_keyset is not a JSON object";
|
|
40
|
+
for (const name of ["workload_keyset_digest", "report_data", "not_after"]) {
|
|
41
|
+
checks.push({ name, ok: false, detail });
|
|
42
|
+
}
|
|
43
|
+
return { ok: false, checks };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The recomputed digest is authoritative (Appendix A): the report's restated
|
|
47
|
+
// copy is checked for consistency but never feeds the statement.
|
|
48
|
+
const digest = await computeKeysetDigest(keysetValue);
|
|
49
|
+
pushEqual(checks, "workload_keyset_digest", report.workload_keyset_digest, digest);
|
|
50
|
+
pushEqual(checks, "report_data", report.attestation.report_data, await computeReportData(digest, nonce));
|
|
51
|
+
|
|
52
|
+
if (typeof keysetValue.not_after !== "number") {
|
|
53
|
+
checks.push({ name: "not_after", ok: false, detail: "keyset has no numeric not_after" });
|
|
54
|
+
} else {
|
|
55
|
+
const ok = now < keysetValue.not_after;
|
|
56
|
+
checks.push({
|
|
57
|
+
name: "not_after",
|
|
58
|
+
ok,
|
|
59
|
+
...(ok ? {} : { detail: `now ${now} >= not_after ${keysetValue.not_after}` }),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { ok: checks.every((c) => c.ok), checks, workloadKeysetDigest: digest, keyset: keysetValue };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pushEqual(checks, name, actual, expected) {
|
|
67
|
+
const ok = actual === expected;
|
|
68
|
+
checks.push({ name, ok, ...(ok ? {} : { detail: `report ${actual} != recomputed ${expected}` }) });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// §9.1 check 4 (dstack policy): the booted docker-compose is the one measured
|
|
72
|
+
/// into RTMR3. Replays `evidence.event_log` to RTMR3, compares it to the RTMR3
|
|
73
|
+
/// the quote states, then checks `sha256(app_compose)` equals the measured
|
|
74
|
+
/// `compose-hash`. Pass `statedRtmr3` to compare against a verified quote's
|
|
75
|
+
/// field instead of the raw bytes at the v4 offset.
|
|
76
|
+
export async function verifyComposeMeasurement(report, { statedRtmr3 = null } = {}) {
|
|
77
|
+
const ev = report.attestation?.evidence ?? {};
|
|
78
|
+
const { event_log: eventLog, app_compose: appCompose, quote } = ev;
|
|
79
|
+
if (typeof eventLog !== "string" || typeof appCompose !== "string" || typeof quote !== "string") {
|
|
80
|
+
throw new AciFormatError("evidence needs string event_log, app_compose, and quote");
|
|
81
|
+
}
|
|
82
|
+
const events = JSON.parse(eventLog);
|
|
83
|
+
|
|
84
|
+
const replayed = await replayRtmr3(events);
|
|
85
|
+
const stated = statedRtmr3 ?? fromHex(quote).slice(TDX_RTMR3_OFFSET, TDX_RTMR3_OFFSET + 48);
|
|
86
|
+
const rtmrOk = stated.length === 48 && replayed.every((b, i) => b === stated[i]);
|
|
87
|
+
|
|
88
|
+
// sha256(app_compose) must equal the compose-hash measured before
|
|
89
|
+
// system-ready. Two pre-system-ready compose-hash events are the tampering
|
|
90
|
+
// shape this lookup exists to catch.
|
|
91
|
+
const preSystemReady = [];
|
|
92
|
+
for (const e of events) {
|
|
93
|
+
if (e.imr !== 3) continue;
|
|
94
|
+
if (e.event === "system-ready") break;
|
|
95
|
+
if (e.event === "compose-hash") preSystemReady.push(e);
|
|
96
|
+
}
|
|
97
|
+
const duplicated = preSystemReady.length > 1;
|
|
98
|
+
const measured = duplicated ? undefined : preSystemReady[0]?.event_payload;
|
|
99
|
+
const recomputed = (await sha256Hex(new TextEncoder().encode(appCompose))).toLowerCase();
|
|
100
|
+
const composeOk = !duplicated && measured?.toLowerCase() === recomputed;
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
ok: rtmrOk && composeOk,
|
|
104
|
+
rtmr3: replayed,
|
|
105
|
+
composeHash: recomputed,
|
|
106
|
+
checks: [
|
|
107
|
+
{ name: "rtmr3", ok: rtmrOk, ...(rtmrOk ? {} : { detail: "event log RTMR3 != quote RTMR3" }) },
|
|
108
|
+
{
|
|
109
|
+
name: "compose_hash",
|
|
110
|
+
ok: composeOk,
|
|
111
|
+
...(composeOk
|
|
112
|
+
? {}
|
|
113
|
+
: {
|
|
114
|
+
detail: duplicated
|
|
115
|
+
? "multiple pre-system-ready compose-hash events"
|
|
116
|
+
: `sha256(app_compose)=${recomputed} != measured ${measured ?? "(none)"}`,
|
|
117
|
+
}),
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Replay the dstack event log's `imr==3` events to RTMR3: a SHA-384 chain over
|
|
124
|
+
/// each digest zero-padded to 48 bytes, from 48 zero bytes.
|
|
125
|
+
export async function replayRtmr3(events) {
|
|
126
|
+
let mr = new Uint8Array(48);
|
|
127
|
+
for (const e of events) {
|
|
128
|
+
if (e.imr !== 3) continue;
|
|
129
|
+
const digest = fromHex(e.digest);
|
|
130
|
+
const buf = new Uint8Array(48 + Math.max(digest.length, 48));
|
|
131
|
+
buf.set(mr);
|
|
132
|
+
buf.set(digest, 48);
|
|
133
|
+
mr = await sha384(buf);
|
|
134
|
+
}
|
|
135
|
+
return mr;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// The 64-byte report-data slot as it sits in a raw v4 TDX quote (§3.2).
|
|
139
|
+
export function quoteReportData(quoteHex) {
|
|
140
|
+
return fromHex(quoteHex).slice(TDX_REPORT_DATA_OFFSET, TDX_REPORT_DATA_OFFSET + 64);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// A TDX quote against the Intel vendor root, with the collateral fetched from
|
|
144
|
+
/// `collateralUrl`. Returns the verified TD report so a caller reads RTMR3 and
|
|
145
|
+
/// report_data out of the verified structure rather than off the raw bytes.
|
|
146
|
+
export async function verifyRawQuote(quoteHex, { collateralUrl, now = Math.floor(Date.now() / 1000) } = {}) {
|
|
147
|
+
// Loaded on demand: everything else here is Web Crypto, and a caller running
|
|
148
|
+
// the offline checks should not have to install a quote verifier to do it.
|
|
149
|
+
// The pure-JS package is the only one to use: the wasm bindings published as
|
|
150
|
+
// @phala/dcap-qvl-node and @topnod/dcap-qvl-node accept a forged QE identity
|
|
151
|
+
// (GHSA-796p-j2gh-9m2q).
|
|
152
|
+
let qvl;
|
|
153
|
+
try {
|
|
154
|
+
qvl = await import("@phala/dcap-qvl");
|
|
155
|
+
} catch {
|
|
156
|
+
return { ok: false, detail: "the quote cannot be checked here: install @phala/dcap-qvl (^0.6.1)" };
|
|
157
|
+
}
|
|
158
|
+
let verified;
|
|
159
|
+
try {
|
|
160
|
+
const raw = fromHex(quoteHex);
|
|
161
|
+
const collateral = await qvl.getCollateral(collateralUrl ?? qvl.INTEL_PCS_URL, raw);
|
|
162
|
+
verified = qvl.verify(raw, collateral, now);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
return { ok: false, detail: `quote did not verify: ${e?.message ?? e}` };
|
|
165
|
+
}
|
|
166
|
+
// A verified SGX quote must not satisfy a tdx report: bind the report type
|
|
167
|
+
// before anyone reads a TD field off it.
|
|
168
|
+
const td = verified.report.asTd10();
|
|
169
|
+
if (!td) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
status: verified.status,
|
|
173
|
+
detail: `verified quote is ${verified.report.type}, not a TDX TD report`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return { ok: true, status: verified.status, advisoryIds: verified.advisory_ids ?? [], report: td };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/// §9.1 check 1: the TDX quote verifies to the Intel vendor root, and the
|
|
180
|
+
/// verified quote's report_data equals the report's `report_data` zero-padded
|
|
181
|
+
/// to 64 bytes (§3.2). A pass here is what makes the RTMR3 that
|
|
182
|
+
/// {@link verifyComposeMeasurement} replays against authentic.
|
|
183
|
+
export async function verifyQuote(report, options = {}) {
|
|
184
|
+
// §4.2: tee_type selects the evidence format; this verifier implements tdx.
|
|
185
|
+
if (report.attestation?.tee_type !== "tdx") {
|
|
186
|
+
const type = JSON.stringify(report.attestation?.tee_type);
|
|
187
|
+
return { ok: false, detail: `tee_type ${type} needs a verifier this library does not implement (§4.2)` };
|
|
188
|
+
}
|
|
189
|
+
const quote = report.attestation.evidence?.quote;
|
|
190
|
+
if (typeof quote !== "string") return { ok: false, detail: "report evidence carries no quote" };
|
|
191
|
+
const reportDataHex = report.attestation.report_data;
|
|
192
|
+
if (!/^[0-9a-f]{64}$/.test(reportDataHex)) {
|
|
193
|
+
return { ok: false, detail: "report_data is not 32 bytes of lowercase hex" };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const verified = await verifyRawQuote(quote, options);
|
|
197
|
+
if (!verified.ok) return verified;
|
|
198
|
+
|
|
199
|
+
const slot = new Uint8Array(64);
|
|
200
|
+
slot.set(fromHex(reportDataHex));
|
|
201
|
+
const rd = verified.report.reportData;
|
|
202
|
+
if (!rd || rd.length !== 64 || !slot.every((b, i) => b === rd[i])) {
|
|
203
|
+
return { ok: false, status: verified.status, detail: "quote report_data does not bind the report" };
|
|
204
|
+
}
|
|
205
|
+
return verified;
|
|
206
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Ported from Dstack-TEE/private-ai-gateway clients/verifier-ts @ b6b5c1b, Apache-2.0.
|
|
2
|
+
//
|
|
3
|
+
// Attested-session helpers (§8, §9.2). A session is content-addressed: its id
|
|
4
|
+
// is the SHA-256 of the JCS form of the served document, and the signed receipt
|
|
5
|
+
// commits to that id. There is no session signature.
|
|
6
|
+
import { fromBase64, jcsBytes, sha256Hex, sha256Prefixed } from "./crypto.mjs";
|
|
7
|
+
|
|
8
|
+
/// `session_id` (§8): bare 64-hex sha256 of the JCS form of the parsed document.
|
|
9
|
+
export async function computeSessionId(record) {
|
|
10
|
+
return sha256Hex(jcsBytes(record));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// Appendix B: reject session documents whose `api_version` is not `aci/1`.
|
|
14
|
+
export function checkSessionApiVersion(record) {
|
|
15
|
+
return record?.api_version === "aci/1";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// §9.2(2): `evidence.data` decodes and hashes to `evidence.digest`. False when
|
|
19
|
+
/// the data URI is absent, malformed, or does not hash.
|
|
20
|
+
export async function checkSessionEvidence(evidence) {
|
|
21
|
+
if (evidence == null || typeof evidence !== "object") return false;
|
|
22
|
+
const { digest, data } = evidence;
|
|
23
|
+
if (typeof digest !== "string" || typeof data !== "string") return false;
|
|
24
|
+
const comma = data.indexOf(",");
|
|
25
|
+
if (!data.startsWith("data:") || comma < 0 || !data.slice(0, comma).endsWith(";base64")) return false;
|
|
26
|
+
let bytes;
|
|
27
|
+
try {
|
|
28
|
+
bytes = fromBase64(data.slice(comma + 1));
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return (await sha256Prefixed(bytes)) === digest;
|
|
33
|
+
}
|