privateer-agent 0.7.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Level 1 receipt verification (§10.2 checks 1–2) and helpers for the body-hash
3
+ * checks (§10.2 checks 3–4). "Established identity and keyset" means a keyset the
4
+ * caller already trusts — from a Level 2 report verification, or published by a
5
+ * party the client trusts. The recomputed `workload_id` and keyset digest of
6
+ * that keyset are the values the receipt must match.
7
+ */
8
+
9
+ import { receiptSigningBytes, computeWorkloadId, computeKeysetDigest } from './digest';
10
+ import { verifySignature, sha256Prefixed } from './crypto';
11
+ import { fromHex } from './crypto';
12
+ import type { Receipt, ReceiptEvent, WorkloadKeyset, Check, ReceiptVerification } from './types';
13
+
14
+ /**
15
+ * Verify a receipt against an established keyset — §10.2 checks 1 and 2:
16
+ *
17
+ * 1. `signature.key_id` names a key in the keyset's `receipt_signing_keys`,
18
+ * `signature.algo` matches that key, and the signature verifies over the
19
+ * §8.5 canonical bytes under that key.
20
+ * 2. The receipt's `workload_id` and `workload_keyset_digest` equal the values
21
+ * recomputed from the established keyset (§4.1, §4.2).
22
+ *
23
+ * Returns a per-check result — a failed check is `ok: false`, never thrown.
24
+ * Throws {@link UnsupportedAlgorithmError} only when the signing algorithm is
25
+ * outside Web Crypto scope (e.g. `ecdsa-secp256k1`).
26
+ */
27
+ export async function verifyReceipt(
28
+ receipt: Receipt,
29
+ keyset: WorkloadKeyset,
30
+ ): Promise<ReceiptVerification> {
31
+ const checks: Check[] = [];
32
+
33
+ const establishedWorkloadId = await computeWorkloadId(keyset.workload_identity.public_key);
34
+ const establishedDigest = await computeKeysetDigest(keyset);
35
+
36
+ // Check 2: self-described identity matches the established keyset.
37
+ checks.push({
38
+ name: 'workload_id',
39
+ ok: receipt.workload_id === establishedWorkloadId,
40
+ ...(receipt.workload_id === establishedWorkloadId
41
+ ? {}
42
+ : { detail: `receipt ${receipt.workload_id} != established ${establishedWorkloadId}` }),
43
+ });
44
+ checks.push({
45
+ name: 'workload_keyset_digest',
46
+ ok: receipt.workload_keyset_digest === establishedDigest,
47
+ ...(receipt.workload_keyset_digest === establishedDigest
48
+ ? {}
49
+ : { detail: `receipt ${receipt.workload_keyset_digest} != established ${establishedDigest}` }),
50
+ });
51
+
52
+ // Check 1: signature under a named receipt signing key.
53
+ const keyEntry = keyset.receipt_signing_keys.find((k) => k.key_id === receipt.signature.key_id);
54
+ if (!keyEntry) {
55
+ checks.push({
56
+ name: 'signature',
57
+ ok: false,
58
+ detail: `signature.key_id "${receipt.signature.key_id}" not in receipt_signing_keys`,
59
+ });
60
+ } else if (receipt.signature.algo !== keyEntry.algo) {
61
+ // §3.1: the attested key decides the algorithm; the receipt may not override it.
62
+ checks.push({
63
+ name: 'signature',
64
+ ok: false,
65
+ detail: `signature.algo "${receipt.signature.algo}" != keyset entry algo "${keyEntry.algo}"`,
66
+ });
67
+ } else {
68
+ const message = receiptSigningBytes(receipt);
69
+ const ok = await verifySignature(
70
+ keyEntry.algo,
71
+ fromHex(keyEntry.public_key),
72
+ fromHex(receipt.signature.value),
73
+ message,
74
+ 'receipt signature (§8.5)',
75
+ );
76
+ checks.push({ name: 'signature', ok, ...(ok ? {} : { detail: 'Ed25519 verification failed' }) });
77
+ }
78
+
79
+ return { ok: checks.every((c) => c.ok), checks };
80
+ }
81
+
82
+ /** Find the first event of a given type in a receipt's event log. */
83
+ export function findEvent(receipt: Receipt, type: string): ReceiptEvent | undefined {
84
+ return receipt.event_log.find((e) => e.type === type);
85
+ }
86
+
87
+ /**
88
+ * `sha256:<hex>` of raw body bytes — the form ACI body hashes use (§3). Accepts a
89
+ * string (UTF-8 encoded) or raw bytes.
90
+ */
91
+ export async function hashBody(body: Uint8Array | string): Promise<string> {
92
+ const bytes = typeof body === 'string' ? new TextEncoder().encode(body) : body;
93
+ return sha256Prefixed(bytes);
94
+ }
95
+
96
+ /**
97
+ * §10.2 check 3: the request bytes the client sent match `request.received.body_hash`.
98
+ * For E2EE requests, pass the decrypted body as the service observed it (§8.3, §12).
99
+ * Returns false when the event or its hash is absent.
100
+ */
101
+ export async function checkRequestBodyHash(
102
+ receipt: Receipt,
103
+ requestBody: Uint8Array | string,
104
+ ): Promise<boolean> {
105
+ const event = findEvent(receipt, 'request.received');
106
+ const expected = event?.body_hash;
107
+ if (typeof expected !== 'string') return false;
108
+ return (await hashBody(requestBody)) === expected;
109
+ }
110
+
111
+ /**
112
+ * §10.2 check 4: the response bytes the client received match
113
+ * `response.returned.wire_hash` — for a stream, the in-order raw SSE bytes.
114
+ * Returns false when the event or its hash is absent.
115
+ */
116
+ export async function checkResponseWireHash(
117
+ receipt: Receipt,
118
+ responseBody: Uint8Array | string,
119
+ ): Promise<boolean> {
120
+ const event = findEvent(receipt, 'response.returned');
121
+ const expected = event?.wire_hash;
122
+ if (typeof expected !== 'string') return false;
123
+ return (await hashBody(responseBody)) === expected;
124
+ }
125
+
126
+ /**
127
+ * For E2EE responses, check the decrypted response bytes match
128
+ * `response.returned.cleartext_hash` (§10.2 check 4, §12). Only meaningful when
129
+ * the client can reproduce the service's pre-encryption serialization.
130
+ */
131
+ export async function checkResponseCleartextHash(
132
+ receipt: Receipt,
133
+ cleartextBody: Uint8Array | string,
134
+ ): Promise<boolean> {
135
+ const event = findEvent(receipt, 'response.returned');
136
+ const expected = event?.cleartext_hash;
137
+ if (typeof expected !== 'string') return false;
138
+ return (await hashBody(cleartextBody)) === expected;
139
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Level 2 report-binding checks (§10.1 checks 2–6), minus the hardware root of
3
+ * trust. This verifies the *cryptographic binding* of the report — that its
4
+ * `workload_id`, keyset digest, `report_data`, and endorsement are internally
5
+ * consistent and endorsed by the identity key — for the nonce the client
6
+ * supplied. It does NOT do §10.1 check 1 (the TEE quote verifies to the vendor
7
+ * root) or the "hardware evidence binds `report_data`" half of check 4: parsing
8
+ * and checking a TDX/SEV-SNP quote is verifier-profile territory and needs
9
+ * primitives outside the Web Crypto API. Compose this with a quote verifier and
10
+ * the custody/provenance/channel checks (§10.1 checks 1, 7–10) for full Level 2.
11
+ */
12
+
13
+ import {
14
+ computeWorkloadId,
15
+ computeKeysetDigest,
16
+ computeReportData,
17
+ keysetEndorsementPayload,
18
+ } from './digest';
19
+ import { verifySignature, fromHex } from './crypto';
20
+ import type { AttestationReport, Check, ReportVerification } from './types';
21
+
22
+ /** Options for {@link verifyReportBinding}. */
23
+ export interface ReportBindingOptions {
24
+ /**
25
+ * Current time in Unix seconds for the freshness check (§10.1 check 6).
26
+ * Defaults to the local clock. Pass an explicit value for deterministic tests.
27
+ */
28
+ now?: number;
29
+ /**
30
+ * Whether the profile trusts the platform's declared validity window
31
+ * (`freshness.fetched_at`/`stale_after`, §5.1). Off by default — recency comes
32
+ * from the nonce binding, and `fetched_at`/`stale_after` need a securely
33
+ * synced TEE clock to mean anything.
34
+ */
35
+ trustPlatformClock?: boolean;
36
+ }
37
+
38
+ /**
39
+ * Verify the report's cryptographic bindings for `nonce` (§10.1 checks 2–6).
40
+ * `nonce` is the value the verifier supplied to `GET /v1/aci/attestation`, or
41
+ * `null`/`undefined` when it requested no nonce (§4.4).
42
+ *
43
+ * Returns a per-check result and the identity recomputed from the report's
44
+ * keyset; a failed check is `ok: false`, never thrown. Throws
45
+ * {@link UnsupportedAlgorithmError} when the identity key algorithm is outside
46
+ * Web Crypto scope (e.g. `ecdsa-secp256k1`).
47
+ */
48
+ export async function verifyReportBinding(
49
+ report: AttestationReport,
50
+ nonce: string | null | undefined,
51
+ options: ReportBindingOptions = {},
52
+ ): Promise<ReportVerification> {
53
+ const now = options.now ?? Math.floor(Date.now() / 1000);
54
+ const checks: Check[] = [];
55
+
56
+ const keyset = report.attestation.workload_keyset;
57
+ const identityKey = keyset.workload_identity.public_key;
58
+
59
+ // Check 2: workload_id == digest of the identity public key in the report's keyset.
60
+ const workloadId = await computeWorkloadId(identityKey);
61
+ pushEqual(checks, 'workload_id', report.workload_id, workloadId);
62
+
63
+ // Check 3: workload_keyset_digest == digest of the report's keyset.
64
+ const workloadKeysetDigest = await computeKeysetDigest(keyset);
65
+ pushEqual(checks, 'workload_keyset_digest', report.workload_keyset_digest, workloadKeysetDigest);
66
+
67
+ // Check 4 (binding half): report_data == the §4.4 statement digest for this nonce.
68
+ // The hardware-evidence-binds-report_data half is out of scope (see file header).
69
+ const expectedReportData = await computeReportData(workloadId, workloadKeysetDigest, nonce);
70
+ pushEqual(checks, 'report_data', report.attestation.report_data, expectedReportData);
71
+
72
+ // Check 5: keyset endorsement verifies under the identity key, algo matching.
73
+ const endorsement = report.attestation.keyset_endorsement;
74
+ if (endorsement.algo !== identityKey.algo) {
75
+ checks.push({
76
+ name: 'keyset_endorsement',
77
+ ok: false,
78
+ detail: `endorsement.algo "${endorsement.algo}" != identity key algo "${identityKey.algo}"`,
79
+ });
80
+ } else {
81
+ const ok = await verifySignature(
82
+ identityKey.algo,
83
+ fromHex(identityKey.public_key),
84
+ fromHex(endorsement.value),
85
+ keysetEndorsementPayload(workloadKeysetDigest),
86
+ 'keyset endorsement (§4.3)',
87
+ );
88
+ checks.push({
89
+ name: 'keyset_endorsement',
90
+ ok,
91
+ ...(ok ? {} : { detail: 'endorsement signature failed under identity key' }),
92
+ });
93
+ }
94
+
95
+ // Check 6: freshness. Nonce binding is check 4; here bound the epoch and,
96
+ // when trusted, the declared validity window.
97
+ const notAfter = keyset.keyset_epoch.not_after;
98
+ const epochOk = now < notAfter;
99
+ checks.push({
100
+ name: 'keyset_epoch.not_after',
101
+ ok: epochOk,
102
+ ...(epochOk ? {} : { detail: `now ${now} >= not_after ${notAfter}` }),
103
+ });
104
+ if (options.trustPlatformClock) {
105
+ const freshness = report.attestation.freshness;
106
+ const fetchedAt = freshness?.fetched_at;
107
+ const staleAfter = freshness?.stale_after;
108
+ const windowOk =
109
+ typeof fetchedAt === 'number' &&
110
+ typeof staleAfter === 'number' &&
111
+ fetchedAt <= now &&
112
+ now < staleAfter;
113
+ checks.push({
114
+ name: 'freshness_window',
115
+ ok: windowOk,
116
+ ...(windowOk ? {} : { detail: `now ${now} outside [${fetchedAt}, ${staleAfter})` }),
117
+ });
118
+ }
119
+
120
+ return { ok: checks.every((c) => c.ok), checks, workloadId, workloadKeysetDigest };
121
+ }
122
+
123
+ function pushEqual(checks: Check[], name: string, actual: string, expected: string): void {
124
+ const ok = actual === expected;
125
+ checks.push({ name, ok, ...(ok ? {} : { detail: `report ${actual} != recomputed ${expected}` }) });
126
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Wire shapes for the ACI artifacts this verifier reads, plus the result types
3
+ * it returns. These mirror spec/aci.md §4, §5, §8, §9; only the fields the
4
+ * verifier touches are typed precisely, with an index signature left open so
5
+ * unknown extension fields (§3.2) survive canonicalization untouched.
6
+ */
7
+
8
+ import type { JcsValue } from './jcs';
9
+
10
+ /** A public key object: `{ algo, public_key }` (§4.1). */
11
+ export interface PublicKey {
12
+ algo: string;
13
+ public_key: string;
14
+ [key: string]: JcsValue | undefined;
15
+ }
16
+
17
+ /** The workload identity (§4.2): the identity public key plus an optional subject. */
18
+ export interface WorkloadIdentity {
19
+ public_key: PublicKey;
20
+ subject?: string | null;
21
+ [key: string]: JcsValue | undefined;
22
+ }
23
+
24
+ /** A receipt signing key entry (§4.2). */
25
+ export interface ReceiptSigningKey {
26
+ key_id: string;
27
+ algo: string;
28
+ public_key: string;
29
+ [key: string]: JcsValue | undefined;
30
+ }
31
+
32
+ /** The workload keyset (§4.2). Digested with JCS to yield `workload_keyset_digest`. */
33
+ export interface WorkloadKeyset {
34
+ workload_identity: WorkloadIdentity;
35
+ keyset_epoch: { version: number; not_after: number; [key: string]: JcsValue | undefined };
36
+ receipt_signing_keys: ReceiptSigningKey[];
37
+ e2ee_public_keys?: JcsValue[];
38
+ tls_public_keys?: JcsValue[];
39
+ [key: string]: JcsValue | undefined;
40
+ }
41
+
42
+ /** A receipt signature block (§8.2). `value` is dropped for canonical signing bytes (§8.5). */
43
+ export interface ReceiptSignature {
44
+ algo: string;
45
+ key_id: string;
46
+ value: string;
47
+ [key: string]: JcsValue | undefined;
48
+ }
49
+
50
+ /** A single receipt event (§8.3). Only `seq`/`type` are fixed; other fields are type-specific. */
51
+ export interface ReceiptEvent {
52
+ seq: number;
53
+ type: string;
54
+ [key: string]: JcsValue | undefined;
55
+ }
56
+
57
+ /** An inference receipt (§8.2). */
58
+ export interface Receipt {
59
+ api_version: string;
60
+ receipt_id: string;
61
+ workload_id: string;
62
+ workload_keyset_digest: string;
63
+ event_log: ReceiptEvent[];
64
+ signature: ReceiptSignature;
65
+ [key: string]: JcsValue | undefined;
66
+ }
67
+
68
+ /** The keyset endorsement / revocation signature block (§4.3, §5.1). */
69
+ export interface Endorsement {
70
+ algo: string;
71
+ value: string;
72
+ [key: string]: JcsValue | undefined;
73
+ }
74
+
75
+ /** The `attestation` object of a report (§5.1); only the fields Level 1 reads are typed. */
76
+ export interface Attestation {
77
+ workload_keyset: WorkloadKeyset;
78
+ report_data: string;
79
+ keyset_endorsement: Endorsement;
80
+ freshness?: { fetched_at?: number; stale_after?: number; [key: string]: JcsValue | undefined };
81
+ [key: string]: JcsValue | undefined;
82
+ }
83
+
84
+ /** An attestation report (§5.1). */
85
+ export interface AttestationReport {
86
+ api_version: string;
87
+ workload_id: string;
88
+ workload_keyset_digest: string;
89
+ attestation: Attestation;
90
+ [key: string]: JcsValue | undefined;
91
+ }
92
+
93
+ /** A verifier-provided evidence block on a session record (§9.2). */
94
+ export interface SessionEvidence {
95
+ digest?: string | null;
96
+ data?: string;
97
+ [key: string]: JcsValue | undefined;
98
+ }
99
+
100
+ /**
101
+ * An attested session record (§9.2). The `session_id` is recomputed from the
102
+ * named fields; absent optional fields (`endpoint`, `identity`, `evidence.digest`)
103
+ * are restored as JSON `null` in the content-addressing material.
104
+ */
105
+ export interface SessionRecord {
106
+ upstream_name: string;
107
+ endpoint?: string | null;
108
+ verifier_id: string;
109
+ identity?: JcsValue;
110
+ channel_binding: JcsValue[];
111
+ claims: JcsValue;
112
+ evidence?: SessionEvidence | null;
113
+ [key: string]: JcsValue | undefined;
114
+ }
115
+
116
+ /** Outcome of one named verification check. */
117
+ export interface Check {
118
+ /** Stable machine-readable id, e.g. `signature`, `workload_id`. */
119
+ name: string;
120
+ ok: boolean;
121
+ /** Human-readable detail, present when the check fails. */
122
+ detail?: string;
123
+ }
124
+
125
+ /** Result of {@link verifyReceipt}: overall pass plus the individual §10.2 checks. */
126
+ export interface ReceiptVerification {
127
+ ok: boolean;
128
+ checks: Check[];
129
+ }
130
+
131
+ /** Result of {@link verifyReportBinding}: overall pass, the §10.1 checks, and the derived identity. */
132
+ export interface ReportVerification {
133
+ ok: boolean;
134
+ checks: Check[];
135
+ /** `workload_id` recomputed from the report's keyset (§4.1). */
136
+ workloadId: string;
137
+ /** `workload_keyset_digest` recomputed from the report's keyset (§4.2). */
138
+ workloadKeysetDigest: string;
139
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Minimal SSE (Server-Sent Events) parsing for streamed chat completions.
3
+ * Yields the parsed JSON object from each `data:` line; skips `[DONE]` and
4
+ * unparseable/partial lines. Used by the streaming provider transports.
5
+ */
6
+
7
+ /** Stream parse: iterate a ReadableStream of bytes, yielding parsed SSE data objects. */
8
+ export async function* iterateSSE(body: ReadableStream<Uint8Array>): AsyncGenerator<any> {
9
+ const reader = body.getReader();
10
+ const decoder = new TextDecoder();
11
+ let buf = '';
12
+ try {
13
+ // eslint-disable-next-line no-constant-condition
14
+ while (true) {
15
+ const { done, value } = await reader.read();
16
+ if (done) break;
17
+ buf += decoder.decode(value, { stream: true });
18
+ const lines = buf.split('\n');
19
+ buf = lines.pop() ?? '';
20
+ for (const obj of parseDataLines(lines)) yield obj;
21
+ }
22
+ // Flush any trailing buffered line.
23
+ if (buf.trim()) for (const obj of parseDataLines([buf])) yield obj;
24
+ } finally {
25
+ try { await reader.cancel(); } catch { /* ignore */ }
26
+ }
27
+ }
28
+
29
+ /** Sync parse: extract SSE data objects from a fully-buffered body (RN fallback). */
30
+ export function parseSSEText(text: string): any[] {
31
+ return parseDataLines(text.split('\n'));
32
+ }
33
+
34
+ function parseDataLines(lines: string[]): any[] {
35
+ const out: any[] = [];
36
+ for (const line of lines) {
37
+ if (!line.startsWith('data:')) continue;
38
+ const t = line.slice(5).trim();
39
+ if (!t || t === '[DONE]') continue;
40
+ try { out.push(JSON.parse(t)); } catch { /* partial / non-JSON — skip */ }
41
+ }
42
+ return out;
43
+ }
@@ -0,0 +1,17 @@
1
+ // The vendored @dstack/aci-verifier (and our phalaSeal) use the WebCrypto DOM
2
+ // global type names — CryptoKey, CryptoKeyPair, KeyUsage, BufferSource. This
3
+ // project's tsconfig `lib` is ES2023 (no DOM), so rather than pull the entire DOM
4
+ // lib into a Node CLI (which would add a pile of browser globals and ambiguate
5
+ // things like `fetch`), surface just those four names as globals, aliased to
6
+ // Node's own `webcrypto` types. Node ≥ 22 provides the runtime (X25519/HKDF/
7
+ // AES-GCM/Ed25519 on globalThis.crypto.subtle) — see the VENDORED.md note.
8
+ import type { webcrypto } from "node:crypto";
9
+
10
+ declare global {
11
+ type CryptoKey = webcrypto.CryptoKey;
12
+ type CryptoKeyPair = webcrypto.CryptoKeyPair;
13
+ type KeyUsage = webcrypto.KeyUsage;
14
+ type BufferSource = ArrayBufferView | ArrayBuffer;
15
+ }
16
+
17
+ export {};
@@ -0,0 +1,200 @@
1
+ // Phala (ACI E2EE) sealed transport for the account channel — the Node port of
2
+ // treeview's client PhalaProvider.
3
+ //
4
+ // Where Tinfoil seals the whole body at the transport layer (EHBP via SecureClient),
5
+ // Phala's Attested Confidential Inference encrypts the request's *content fields*
6
+ // (x25519-aes-256-gcm-hkdf-sha256) to the enclave's attested X25519 key, sends the
7
+ // `X-E2EE-*` headers alongside, and decrypts the response fields. The Privateer relay
8
+ // (`${server}/api/sealed/phala`, treeview/server/routes/sealed.js) injects PHALA_API_KEY
9
+ // and forwards ciphertext — it can't read prompts or responses.
10
+ //
11
+ // Crypto is the vendored @dstack/aci-verifier (./phala/aci-verifier), pure Web Crypto
12
+ // (X25519/HKDF/AES-GCM/Ed25519). Node ≥ 22 provides all of it on globalThis.crypto —
13
+ // no polyfills, unlike the RN app.
14
+ //
15
+ // Two-layer attestation, fail-secure:
16
+ // (1) verifyReportBinding — the report's crypto binding (keyset digest,
17
+ // report_data == statement(nonce), endorsement sig). Self-attesting alone.
18
+ // (2) verifyHardwareQuote — the hardware root: @phala/dcap-qvl verifies the TDX quote
19
+ // against Intel collateral and binds the quote's report_data to (1)'s statement
20
+ // digest. requireQuote defaults TRUE; PRIVATEER_PHALA_REQUIRE_QUOTE=0 drops it
21
+ // (local testing only — removes the hardware root of trust).
22
+
23
+ import type { Report } from "@phala/dcap-qvl";
24
+ import {
25
+ verifyReportBinding,
26
+ openE2eeChannel,
27
+ toHex,
28
+ fromHex,
29
+ type AttestationReport,
30
+ type ReportVerification,
31
+ type E2eeChannel,
32
+ } from "./phala/aci-verifier/index.ts";
33
+ import { serverBaseUrl } from "../auth/privateer.ts";
34
+
35
+ const DEFAULT_ACCEPTABLE_TCB = ["UpToDate"];
36
+
37
+ function relayBase(): string {
38
+ return `${serverBaseUrl().replace(/\/+$/, "")}/api/sealed/phala`;
39
+ }
40
+
41
+ // Hardware quote check on by default (fail-secure). Only "0"/"false" disables it.
42
+ function requireQuote(): boolean {
43
+ const v = process.env.PRIVATEER_PHALA_REQUIRE_QUOTE;
44
+ return !(v === "0" || v === "false");
45
+ }
46
+ function pccsUrl(): string | undefined {
47
+ return process.env.PRIVATEER_PHALA_PCCS_URL || undefined;
48
+ }
49
+ function acceptableTcb(): Set<string> {
50
+ const v = process.env.PRIVATEER_PHALA_TCB;
51
+ const list = v ? v.split(",").map((s) => s.trim()).filter(Boolean) : DEFAULT_ACCEPTABLE_TCB;
52
+ return new Set(list);
53
+ }
54
+
55
+ // The 64-byte report_data from a verified TDX quote report (TD1.0/1.5 layouts).
56
+ function extractQuoteReportData(report: Report): Uint8Array {
57
+ const td10 = report.asTd10?.();
58
+ if (td10?.reportData) return new Uint8Array(td10.reportData);
59
+ const td15 = report.asTd15?.();
60
+ if (td15?.base?.reportData) return new Uint8Array(td15.base.reportData);
61
+ const data = report.data as { reportData?: Uint8Array } | undefined;
62
+ if (data?.reportData) return new Uint8Array(data.reportData);
63
+ throw new Error("phala: verified quote report has no reportData");
64
+ }
65
+
66
+ interface VerifiedAttestation {
67
+ report: AttestationReport;
68
+ verification: ReportVerification;
69
+ }
70
+
71
+ // Attest once, cache the verified report; drop the memo on failure so a later call
72
+ // re-attests rather than caching the error.
73
+ let attestationPromise: Promise<VerifiedAttestation> | null = null;
74
+
75
+ function attest(): Promise<VerifiedAttestation> {
76
+ if (!attestationPromise) {
77
+ attestationPromise = establishAttestation().catch((err) => {
78
+ attestationPromise = null;
79
+ throw err as Error;
80
+ });
81
+ }
82
+ return attestationPromise;
83
+ }
84
+
85
+ export function resetPhala(): void {
86
+ attestationPromise = null;
87
+ }
88
+
89
+ async function establishAttestation(): Promise<VerifiedAttestation> {
90
+ const nonce = toHex(globalThis.crypto.getRandomValues(new Uint8Array(32)));
91
+ // The relay proxies GET /attestation?nonce=… → the gateway's
92
+ // GET /v1/aci/attestation?nonce=… (public; no user content).
93
+ const res = await fetch(`${relayBase()}/attestation?nonce=${nonce}`, { method: "GET" });
94
+ if (!res.ok) throw new Error(`phala attestation HTTP ${res.status}`);
95
+ const report = (await res.json()) as AttestationReport;
96
+
97
+ const verification = await verifyReportBinding(report, nonce);
98
+ if (!verification.ok) {
99
+ const failed = verification.checks.filter((c) => !c.ok).map((c) => c.name).join(", ");
100
+ throw new Error(`phala attestation binding failed: ${failed}`);
101
+ }
102
+ await verifyHardwareQuote(report);
103
+ return { report, verification };
104
+ }
105
+
106
+ async function verifyHardwareQuote(report: AttestationReport): Promise<void> {
107
+ if (!requireQuote()) return;
108
+
109
+ const attestation = report.attestation as unknown as {
110
+ tee_type?: string;
111
+ report_data?: string;
112
+ evidence?: { quote?: string; quote_report_data?: string };
113
+ };
114
+ const teeType = String(attestation?.tee_type || "");
115
+ if (teeType !== "tdx") throw new Error(`phala: unsupported/absent tee_type "${teeType}" (only tdx is wired)`);
116
+ const quoteHex = attestation.evidence?.quote;
117
+ if (typeof quoteHex !== "string" || !quoteHex) throw new Error("phala: attestation evidence has no TDX quote");
118
+ const reportDataHex = String(attestation.report_data || "").toLowerCase();
119
+ if (!reportDataHex) throw new Error("phala: report has no report_data");
120
+
121
+ // Verify the quote against fetched Intel/Phala collateral (pure-JS dcap-qvl).
122
+ const { getCollateralAndVerify } = await import("@phala/dcap-qvl");
123
+ const verified = await getCollateralAndVerify(fromHex(quoteHex), pccsUrl());
124
+
125
+ // 1) Genuine hardware + acceptable TCB status.
126
+ const status = String(verified.status);
127
+ if (!acceptableTcb().has(status)) throw new Error(`phala: TDX quote TCB status not accepted: "${status}"`);
128
+
129
+ // 2) The genuine quote committed to our attested statement digest.
130
+ const quoteReportData = extractQuoteReportData(verified.report);
131
+ if (toHex(quoteReportData.slice(0, 32)) !== reportDataHex) {
132
+ throw new Error("phala: TDX quote report_data does not bind the attested report_data");
133
+ }
134
+
135
+ // 3) Consistency: the report's declared quote_report_data matches the real quote.
136
+ const declared = attestation.evidence?.quote_report_data;
137
+ if (typeof declared === "string" && declared && toHex(quoteReportData) !== declared.toLowerCase()) {
138
+ throw new Error("phala: evidence.quote_report_data does not match the verified quote");
139
+ }
140
+ }
141
+
142
+ // Posture signal: does the attested keyset verify (crypto binding + hardware quote)?
143
+ // A green result is a quote WE checked, bound to the E2EE key we seal to.
144
+ export async function attestPhala(): Promise<{ ok: boolean; error?: string }> {
145
+ try {
146
+ await attest();
147
+ return { ok: true };
148
+ } catch (e) {
149
+ return { ok: false, error: (e as Error).message };
150
+ }
151
+ }
152
+
153
+ export interface PhalaExchange {
154
+ res: Response;
155
+ channel: E2eeChannel;
156
+ streaming: boolean;
157
+ }
158
+
159
+ // Run one sealed request for Pi: attest, open a fresh per-call E2EE channel (the
160
+ // channel's request state is single-shot → not safe to share across concurrent
161
+ // calls), seal the request fields, and POST to the relay with the X-E2EE-* headers +
162
+ // the cleartext X-Sealed-Model (relay billing) + Pi's account bearer. Returns the
163
+ // upstream response and the channel so the caller can decrypt it.
164
+ export async function phalaSealedFetch(
165
+ rawBody: string,
166
+ authHeader: string | undefined,
167
+ signal?: AbortSignal,
168
+ ): Promise<PhalaExchange> {
169
+ const { report, verification } = await attest();
170
+ const channel = await openE2eeChannel(report, verification);
171
+
172
+ let request: Record<string, unknown>;
173
+ try {
174
+ request = JSON.parse(rawBody) as Record<string, unknown>;
175
+ } catch {
176
+ throw new Error("phala: request body is not JSON");
177
+ }
178
+ const fullModel = typeof request.model === "string" ? request.model : "unknown";
179
+ const streaming = request.stream !== false;
180
+ // Bare model id for the enclave (the `phala/` prefix is app-side only); keep the
181
+ // full id on the cleartext X-Sealed-Model billing header.
182
+ request.model = fullModel.replace(/^phala\//, "");
183
+ request.stream = streaming;
184
+
185
+ const { body, headers: e2ee } = await channel.seal(request);
186
+ const headers: Record<string, string> = {
187
+ "Content-Type": "application/json",
188
+ "X-Sealed-Model": fullModel,
189
+ ...e2ee,
190
+ };
191
+ if (authHeader) headers.Authorization = authHeader;
192
+
193
+ const res = await fetch(`${relayBase()}/v1/chat/completions`, {
194
+ method: "POST",
195
+ headers,
196
+ body: JSON.stringify(body),
197
+ signal,
198
+ });
199
+ return { res, channel, streaming };
200
+ }