@alejoamiras/tee-rex 4.0.0-devnet.2-patch.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.
@@ -0,0 +1,317 @@
1
+ import * as x509 from "@peculiar/x509";
2
+ import { decode as decodeCbor, encode as encodeCbor } from "cbor-x";
3
+ import type { ValueOf } from "ts-essentials";
4
+ import { logger } from "./logger.js";
5
+
6
+ /**
7
+ * AWS Nitro Enclaves Root CA certificate (PEM).
8
+ * Source: https://aws-nitro-enclaves.amazonaws.com/AWS_NitroEnclaves_Root-G1.zip
9
+ */
10
+ const AWS_NITRO_ROOT_CA_PEM = `-----BEGIN CERTIFICATE-----
11
+ MIICETCCAZagAwIBAgIRAPkxdWgbkK/hHUbMtOTn+FYwCgYIKoZIzj0EAwMwSTEL
12
+ MAkGA1UEBhMCVVMxDzANBgNVBAoMBkFtYXpvbjEMMAoGA1UECwwDQVdTMRswGQYD
13
+ VQQDDBJhd3Mubml0cm8tZW5jbGF2ZXMwHhcNMTkxMDI4MTMyODA1WhcNNDkxMDI4
14
+ MTQyODA1WjBJMQswCQYDVQQGEwJVUzEPMA0GA1UECgwGQW1hem9uMQwwCgYDVQQL
15
+ DANBV1MxGzAZBgNVBAMMEmF3cy5uaXRyby1lbmNsYXZlczB2MBAGByqGSM49AgEG
16
+ BSuBBAAiA2IABPwCVOumCMHzaHDimtqQvkY4MpJzbolL//Zy2YlES1BR5TSksfbb
17
+ 48C8WBoyt7F2Bw7eEtaaP+ohG2bnUs990d0JX28TcPQXCEPZ3BABIeTPYwEoCWZE
18
+ h8l5YoQwTcU/9KNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUkCW1DdkF
19
+ R+eWw5b6cp3PmanfS5YwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2kAMGYC
20
+ MQCjfy+Rocm9Xue4YnwWmNJVA44fA0P5W2OpYow9OYCVRaEevL8uO1XYru5xtMPW
21
+ rfMCMQCi85sWBbJwKKXdS6BptQFuZbT73o/gBh1qUxl/nNr12UO8Yfwr6wPLb+6N
22
+ IwLz3/Y=
23
+ -----END CERTIFICATE-----`;
24
+
25
+ /** Parsed fields from a Nitro attestation document. */
26
+ export interface NitroAttestationDocument {
27
+ moduleId: string;
28
+ timestamp: number;
29
+ digest: string;
30
+ pcrs: Map<number, Uint8Array>;
31
+ certificate: Uint8Array;
32
+ cabundle: Uint8Array[];
33
+ publicKey?: Uint8Array;
34
+ userData?: Uint8Array;
35
+ nonce?: Uint8Array;
36
+ }
37
+
38
+ /** Options for attestation verification. */
39
+ export interface AttestationVerifyOptions {
40
+ /** Expected PCR values. Only the specified PCR indices are checked. */
41
+ expectedPCRs?: Record<number, string>;
42
+ /** Maximum age of the attestation document in milliseconds. Default: 5 minutes. */
43
+ maxAgeMs?: number;
44
+ /**
45
+ * Expected nonce value (hex string). When provided, the attestation document's
46
+ * nonce field must match exactly. Use this to prevent replay attacks by including
47
+ * a challenge in the attestation request.
48
+ */
49
+ expectedNonce?: string;
50
+ /** @internal Override the root CA for testing. Defaults to the AWS Nitro Enclaves Root CA. */
51
+ rootCaPem?: string;
52
+ }
53
+
54
+ /**
55
+ * Verify a Nitro attestation document and extract the embedded public key.
56
+ *
57
+ * Verification steps:
58
+ * 1. Decode the COSE_Sign1 envelope
59
+ * 2. Extract and parse the CBOR attestation document payload
60
+ * 3. Build and verify the certificate chain (cabundle → leaf → AWS Nitro root CA)
61
+ * 4. Verify the COSE_Sign1 signature using the leaf certificate
62
+ * 5. Optionally check PCR values and document freshness
63
+ * 6. Return the embedded public key
64
+ *
65
+ * Uses @peculiar/x509 + Web Crypto API for cross-platform compatibility
66
+ * (works in Node.js, Bun, and browsers).
67
+ */
68
+ export async function verifyNitroAttestation(
69
+ attestationDocumentBase64: string,
70
+ options: AttestationVerifyOptions = {},
71
+ ): Promise<{ publicKey: string; document: NitroAttestationDocument }> {
72
+ const { maxAgeMs = 5 * 60 * 1000 } = options;
73
+
74
+ // 1. Decode the COSE_Sign1 envelope
75
+ const raw = Buffer.from(attestationDocumentBase64, "base64");
76
+ const coseSign1: unknown = decodeCbor(raw);
77
+
78
+ if (!Array.isArray(coseSign1) || coseSign1.length !== 4) {
79
+ throw new AttestationError("Invalid COSE_Sign1 structure", AttestationErrorCode.INVALID_COSE);
80
+ }
81
+
82
+ const [protectedHeaders, , payload, signature] = coseSign1 as [
83
+ Uint8Array,
84
+ unknown,
85
+ Uint8Array,
86
+ Uint8Array,
87
+ ];
88
+
89
+ // 2. Parse the attestation document from the payload
90
+ const doc = decodeCbor(payload) as Record<string, unknown>;
91
+ const attestationDoc = parseAttestationDocument(doc);
92
+
93
+ // 3. Build and verify certificate chain
94
+ // Buffer.from() ensures ArrayBuffer backing (not SharedArrayBuffer) for @peculiar/x509 compat
95
+ const leafCert = new x509.X509Certificate(Buffer.from(attestationDoc.certificate));
96
+ const rootCa = new x509.X509Certificate(options.rootCaPem ?? AWS_NITRO_ROOT_CA_PEM);
97
+
98
+ // Build chain: cabundle contains certs from root to intermediate(s)
99
+ const caBundleCerts = attestationDoc.cabundle.map(
100
+ (der) => new x509.X509Certificate(Buffer.from(der)),
101
+ );
102
+ await verifyCertificateChain(leafCert, caBundleCerts, rootCa);
103
+
104
+ // 4. Verify COSE_Sign1 signature
105
+ // Sig_structure = ["Signature1", protected_headers, external_aad, payload]
106
+ // cbor-x encodes Uint8Array with CBOR tag 64 but Buffer as plain bstr.
107
+ // COSE requires plain bstr, so use Buffer.alloc(0) for the empty external_aad.
108
+ const sigStructure = encodeCbor(["Signature1", protectedHeaders, Buffer.alloc(0), payload]);
109
+
110
+ const leafPublicKey = await leafCert.publicKey.export(
111
+ { name: "ECDSA", namedCurve: "P-384" } as EcKeyImportParams,
112
+ ["verify"],
113
+ );
114
+ const signatureValid = await crypto.subtle.verify(
115
+ { name: "ECDSA", hash: "SHA-384" },
116
+ leafPublicKey,
117
+ Buffer.from(signature),
118
+ Buffer.from(sigStructure),
119
+ );
120
+
121
+ if (!signatureValid) {
122
+ throw new AttestationError(
123
+ "COSE_Sign1 signature verification failed",
124
+ AttestationErrorCode.SIGNATURE_FAILED,
125
+ );
126
+ }
127
+
128
+ // 5. Check freshness (with 30s tolerance for clock skew between client and enclave)
129
+ const CLOCK_SKEW_TOLERANCE_MS = 30_000;
130
+ const docAge = Date.now() - attestationDoc.timestamp;
131
+ if (docAge > maxAgeMs + CLOCK_SKEW_TOLERANCE_MS) {
132
+ throw new AttestationError(
133
+ `Attestation document is too old (${Math.round(docAge / 1000)}s > ${Math.round(maxAgeMs / 1000)}s)`,
134
+ AttestationErrorCode.EXPIRED,
135
+ );
136
+ }
137
+
138
+ // 6. Check PCR values if specified
139
+ if (options.expectedPCRs) {
140
+ for (const [index, expectedHex] of Object.entries(options.expectedPCRs)) {
141
+ const pcrIndex = Number(index);
142
+ const actual = attestationDoc.pcrs.get(pcrIndex);
143
+ if (!actual) {
144
+ throw new AttestationError(
145
+ `PCR${pcrIndex} not found in attestation document`,
146
+ AttestationErrorCode.PCR_MISMATCH,
147
+ );
148
+ }
149
+ const actualHex = Buffer.from(actual).toString("hex");
150
+ if (actualHex !== expectedHex.toLowerCase()) {
151
+ throw new AttestationError(
152
+ `PCR${pcrIndex} mismatch: expected ${expectedHex}, got ${actualHex}`,
153
+ AttestationErrorCode.PCR_MISMATCH,
154
+ );
155
+ }
156
+ }
157
+ }
158
+
159
+ // 7. Check nonce if specified
160
+ if (options.expectedNonce) {
161
+ if (!attestationDoc.nonce) {
162
+ throw new AttestationError(
163
+ "Attestation document does not contain a nonce",
164
+ AttestationErrorCode.NONCE_MISMATCH,
165
+ );
166
+ }
167
+ const actualNonce = Buffer.from(attestationDoc.nonce).toString("hex");
168
+ if (actualNonce !== options.expectedNonce.toLowerCase()) {
169
+ throw new AttestationError(
170
+ `Nonce mismatch: expected ${options.expectedNonce}, got ${actualNonce}`,
171
+ AttestationErrorCode.NONCE_MISMATCH,
172
+ );
173
+ }
174
+ }
175
+
176
+ // 8. Extract public key
177
+ if (!attestationDoc.publicKey) {
178
+ throw new AttestationError(
179
+ "Attestation document does not contain a public key",
180
+ AttestationErrorCode.MISSING_KEY,
181
+ );
182
+ }
183
+
184
+ const publicKey = new TextDecoder().decode(attestationDoc.publicKey);
185
+
186
+ logger.info("Nitro attestation verified successfully", {
187
+ moduleId: attestationDoc.moduleId,
188
+ pcr0: Buffer.from(attestationDoc.pcrs.get(0) ?? new Uint8Array())
189
+ .toString("hex")
190
+ .slice(0, 16),
191
+ });
192
+
193
+ return { publicKey, document: attestationDoc };
194
+ }
195
+
196
+ function parseAttestationDocument(doc: Record<string, unknown>): NitroAttestationDocument {
197
+ if (typeof doc.module_id !== "string") {
198
+ throw new AttestationError("Missing or invalid module_id");
199
+ }
200
+ // cbor-x decodes 8-byte CBOR uint64 as BigInt — Nitro's Rust NSM library always
201
+ // encodes the timestamp as uint64 regardless of value, so we must accept both.
202
+ if (typeof doc.timestamp !== "number" && typeof doc.timestamp !== "bigint") {
203
+ throw new AttestationError("Missing or invalid timestamp");
204
+ }
205
+ if (typeof doc.digest !== "string") {
206
+ throw new AttestationError("Missing or invalid digest");
207
+ }
208
+ if (!(doc.certificate instanceof Uint8Array)) {
209
+ throw new AttestationError("Missing or invalid certificate");
210
+ }
211
+ if (!Array.isArray(doc.cabundle)) {
212
+ throw new AttestationError("Missing or invalid cabundle");
213
+ }
214
+
215
+ // Normalize pcrs: cbor-x decodes CBOR maps as plain objects (with string keys)
216
+ // by default, but as Map when using certain configurations. Accept both.
217
+ let pcrs: Map<number, Uint8Array>;
218
+ if (doc.pcrs instanceof Map) {
219
+ pcrs = doc.pcrs as Map<number, Uint8Array>;
220
+ for (const [key, value] of pcrs) {
221
+ if (typeof key !== "number" || !(value instanceof Uint8Array)) {
222
+ throw new AttestationError("Invalid pcrs entry: expected Map<number, Uint8Array>");
223
+ }
224
+ }
225
+ } else if (doc.pcrs && typeof doc.pcrs === "object") {
226
+ pcrs = new Map<number, Uint8Array>();
227
+ for (const [key, value] of Object.entries(doc.pcrs as Record<string, unknown>)) {
228
+ if (!(value instanceof Uint8Array)) {
229
+ throw new AttestationError("Invalid pcrs entry: expected Map<number, Uint8Array>");
230
+ }
231
+ pcrs.set(Number(key), value);
232
+ }
233
+ } else {
234
+ throw new AttestationError("Missing or invalid pcrs");
235
+ }
236
+
237
+ // Validate cabundle entries
238
+ const cabundle = doc.cabundle as unknown[];
239
+ for (const entry of cabundle) {
240
+ if (!(entry instanceof Uint8Array)) {
241
+ throw new AttestationError("Invalid cabundle entry: expected Uint8Array[]");
242
+ }
243
+ }
244
+
245
+ return {
246
+ moduleId: doc.module_id as string,
247
+ timestamp: Number(doc.timestamp),
248
+ digest: doc.digest as string,
249
+ pcrs,
250
+ certificate: doc.certificate as Uint8Array,
251
+ cabundle: cabundle as Uint8Array[],
252
+ publicKey: doc.public_key instanceof Uint8Array ? doc.public_key : undefined,
253
+ userData: doc.user_data instanceof Uint8Array ? doc.user_data : undefined,
254
+ nonce: doc.nonce instanceof Uint8Array ? doc.nonce : undefined,
255
+ };
256
+ }
257
+
258
+ async function verifyCertificateChain(
259
+ leaf: x509.X509Certificate,
260
+ intermediates: x509.X509Certificate[],
261
+ root: x509.X509Certificate,
262
+ ): Promise<void> {
263
+ // Verify root is self-signed
264
+ const rootSelfSigned = await root.verify({ signatureOnly: true });
265
+ if (!rootSelfSigned) {
266
+ throw new AttestationError("Root CA is not self-signed", AttestationErrorCode.CHAIN_FAILED);
267
+ }
268
+
269
+ // Build ordered chain: root → intermediates → leaf
270
+ const chain = [root, ...intermediates, leaf];
271
+
272
+ for (let i = 1; i < chain.length; i++) {
273
+ const cert = chain[i]!;
274
+ const issuer = chain[i - 1]!;
275
+
276
+ const valid = await cert.verify({ publicKey: issuer, signatureOnly: true });
277
+ if (!valid) {
278
+ throw new AttestationError(
279
+ `Certificate chain verification failed at index ${i}`,
280
+ AttestationErrorCode.CHAIN_FAILED,
281
+ );
282
+ }
283
+
284
+ // Check validity period
285
+ const now = new Date();
286
+ if (now < cert.notBefore || now > cert.notAfter) {
287
+ throw new AttestationError(
288
+ `Certificate at index ${i} is not within its validity period`,
289
+ AttestationErrorCode.CHAIN_FAILED,
290
+ );
291
+ }
292
+ }
293
+ }
294
+
295
+ /** Machine-readable error codes for attestation verification failures. */
296
+ export type AttestationErrorCode = ValueOf<typeof AttestationErrorCode>;
297
+ export const AttestationErrorCode = {
298
+ INVALID_COSE: "INVALID_COSE",
299
+ INVALID_DOCUMENT: "INVALID_DOCUMENT",
300
+ CHAIN_FAILED: "CHAIN_FAILED",
301
+ SIGNATURE_FAILED: "SIGNATURE_FAILED",
302
+ EXPIRED: "EXPIRED",
303
+ PCR_MISMATCH: "PCR_MISMATCH",
304
+ NONCE_MISMATCH: "NONCE_MISMATCH",
305
+ MISSING_KEY: "MISSING_KEY",
306
+ } as const;
307
+
308
+ /** Error thrown when Nitro attestation verification fails. Includes a machine-readable {@link AttestationErrorCode}. */
309
+ export class AttestationError extends Error {
310
+ readonly code: AttestationErrorCode;
311
+
312
+ constructor(message: string, code: AttestationErrorCode = AttestationErrorCode.INVALID_DOCUMENT) {
313
+ super(message);
314
+ this.name = "AttestationError";
315
+ this.code = code;
316
+ }
317
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import * as openpgp from "openpgp";
3
+ import { encrypt } from "./encrypt.js";
4
+
5
+ /** Generate a test keypair. */
6
+ async function generateTestKeys() {
7
+ const keys = await openpgp.generateKey({
8
+ type: "curve25519",
9
+ userIDs: [{ name: "Test" }],
10
+ });
11
+ return { publicKey: keys.publicKey, privateKey: keys.privateKey };
12
+ }
13
+
14
+ /** Decrypt data with a private key. */
15
+ async function decryptWithKey(data: Uint8Array, privateKeyArmored: string): Promise<Uint8Array> {
16
+ const message = await openpgp.readMessage({ binaryMessage: data });
17
+ const decrypted = await openpgp.decrypt({
18
+ message,
19
+ format: "binary",
20
+ decryptionKeys: await openpgp.readPrivateKey({ armoredKey: privateKeyArmored }),
21
+ });
22
+ return decrypted.data as Uint8Array;
23
+ }
24
+
25
+ describe("encrypt", () => {
26
+ test("produces non-empty output", async () => {
27
+ const { publicKey } = await generateTestKeys();
28
+ const data = new TextEncoder().encode("hello");
29
+
30
+ const encrypted = await encrypt({ data, encryptionPublicKey: publicKey });
31
+
32
+ expect(encrypted.length).toBeGreaterThan(0);
33
+ });
34
+
35
+ test("output differs from input", async () => {
36
+ const { publicKey } = await generateTestKeys();
37
+ const data = new TextEncoder().encode("sensitive data");
38
+
39
+ const encrypted = await encrypt({ data, encryptionPublicKey: publicKey });
40
+
41
+ expect(encrypted).not.toEqual(data);
42
+ });
43
+
44
+ test("roundtrip: encrypt then decrypt preserves data", async () => {
45
+ const { publicKey, privateKey } = await generateTestKeys();
46
+ const original = new TextEncoder().encode("roundtrip test data");
47
+
48
+ const encrypted = await encrypt({ data: original, encryptionPublicKey: publicKey });
49
+ const decrypted = await decryptWithKey(encrypted, privateKey);
50
+
51
+ expect(decrypted).toEqual(original);
52
+ });
53
+
54
+ test("roundtrip works with binary data", async () => {
55
+ const { publicKey, privateKey } = await generateTestKeys();
56
+ const original = new Uint8Array(256);
57
+ for (let i = 0; i < 256; i++) {
58
+ original[i] = i;
59
+ }
60
+
61
+ const encrypted = await encrypt({ data: original, encryptionPublicKey: publicKey });
62
+ const decrypted = await decryptWithKey(encrypted, privateKey);
63
+
64
+ expect(decrypted).toEqual(original);
65
+ });
66
+
67
+ test("throws on invalid public key", async () => {
68
+ const data = new TextEncoder().encode("hello");
69
+
70
+ expect(encrypt({ data, encryptionPublicKey: "not-a-key" })).rejects.toThrow();
71
+ });
72
+ });
@@ -0,0 +1,29 @@
1
+ import * as openpgp from "openpgp";
2
+
3
+ openpgp.config.aeadProtect = true;
4
+
5
+ export async function encrypt({
6
+ data,
7
+ encryptionPublicKey,
8
+ }: {
9
+ data: Uint8Array;
10
+ encryptionPublicKey: string;
11
+ }): Promise<Uint8Array> {
12
+ const message = await openpgp.createMessage({ binary: data });
13
+ const encryptedArmored = await openpgp.encrypt({
14
+ message,
15
+ encryptionKeys: await openpgp.readKey({ armoredKey: encryptionPublicKey }),
16
+ });
17
+
18
+ const encrypted = await unarmorToUint8Array(encryptedArmored);
19
+ return encrypted;
20
+ }
21
+
22
+ async function unarmorToUint8Array(armored: string) {
23
+ const unarmored = await openpgp.unarmor(armored);
24
+ const unarmoredData: unknown = unarmored.data;
25
+ if (!(unarmoredData instanceof Uint8Array)) {
26
+ throw new Error("Unarmored data is not a Uint8Array");
27
+ }
28
+ return unarmoredData;
29
+ }
@@ -0,0 +1,3 @@
1
+ import { getLogger } from "@logtape/logtape";
2
+
3
+ export const logger = getLogger(["tee-rex", "prover"]);
@@ -0,0 +1,184 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
2
+ import { BBLazyPrivateKernelProver } from "@aztec/bb-prover/client/lazy";
3
+ import { WASMSimulator } from "@aztec/simulator/client";
4
+ import * as attestationModule from "./attestation.js";
5
+ import { ProvingMode, TeeRexProver } from "./tee-rex-prover.js";
6
+
7
+ describe("TeeRexProver", () => {
8
+ test("can instantiate with correct proving modes", () => {
9
+ const prover = new TeeRexProver("http://localhost:4000", new WASMSimulator());
10
+
11
+ // Default mode should be remote
12
+ expect(prover).toBeDefined();
13
+
14
+ // Can set to local mode
15
+ prover.setProvingMode(ProvingMode.local);
16
+
17
+ // Can set back to remote mode
18
+ prover.setProvingMode(ProvingMode.remote);
19
+ });
20
+
21
+ describe("createChonkProof routing", () => {
22
+ const API_URL = "http://tee-rex-test.invalid:9999";
23
+
24
+ // Minimal fake execution step matching PrivateExecutionStep shape
25
+ const fakeStep = {
26
+ functionName: "test_fn",
27
+ witness: new Map([[0, "val"]]),
28
+ bytecode: new Uint8Array([0, 1]),
29
+ vk: new Uint8Array([2, 3]),
30
+ timings: { witgen: 10 },
31
+ } as any;
32
+
33
+ let originalFetch: typeof globalThis.fetch;
34
+
35
+ beforeEach(() => {
36
+ originalFetch = globalThis.fetch;
37
+ });
38
+
39
+ afterEach(() => {
40
+ globalThis.fetch = originalFetch;
41
+ });
42
+
43
+ test("remote mode calls the API's attestation endpoint", async () => {
44
+ const fetchedUrls: string[] = [];
45
+
46
+ globalThis.fetch = mock(async (input: any) => {
47
+ const url = typeof input === "string" ? input : input.url;
48
+ fetchedUrls.push(url);
49
+ // Return a failure to stop the flow early — we just want to verify the URL
50
+ return new Response("not found", { status: 404 });
51
+ }) as any;
52
+
53
+ const prover = new TeeRexProver(API_URL, new WASMSimulator());
54
+ prover.setProvingMode(ProvingMode.remote);
55
+
56
+ // createChonkProof will fail because our mock server returns 404,
57
+ // but we can verify it tried to reach the right endpoint
58
+ try {
59
+ await prover.createChonkProof([fakeStep]);
60
+ } catch {
61
+ // Expected — the mock doesn't return a valid response
62
+ }
63
+
64
+ const attestationEndpointCalled = fetchedUrls.some((url) =>
65
+ url.includes(`${API_URL}/attestation`),
66
+ );
67
+ expect(attestationEndpointCalled).toBe(true);
68
+ });
69
+
70
+ test("requireAttestation rejects standard mode servers", async () => {
71
+ globalThis.fetch = mock(async (input: any) => {
72
+ const url = typeof input === "string" ? input : input.url;
73
+ if (url.includes("/attestation")) {
74
+ return new Response(JSON.stringify({ mode: "standard", publicKey: "fake-key" }), {
75
+ status: 200,
76
+ headers: { "Content-Type": "application/json" },
77
+ });
78
+ }
79
+ return new Response("not found", { status: 404 });
80
+ }) as any;
81
+
82
+ const prover = new TeeRexProver(API_URL, new WASMSimulator());
83
+ prover.setProvingMode(ProvingMode.remote);
84
+ prover.setAttestationConfig({ requireAttestation: true });
85
+
86
+ await expect(prover.createChonkProof([fakeStep])).rejects.toThrow(
87
+ "requireAttestation is enabled",
88
+ );
89
+ });
90
+
91
+ test("nitro mode falls back to server-provided key when node:crypto unavailable", async () => {
92
+ const SERVER_PUBLIC_KEY = "server-provided-key-abc123";
93
+
94
+ globalThis.fetch = mock(async (input: any) => {
95
+ const url = typeof input === "string" ? input : input.url;
96
+ if (url.includes("/attestation")) {
97
+ return new Response(
98
+ JSON.stringify({
99
+ mode: "nitro",
100
+ attestationDocument: "fake-doc",
101
+ publicKey: SERVER_PUBLIC_KEY,
102
+ }),
103
+ { status: 200, headers: { "Content-Type": "application/json" } },
104
+ );
105
+ }
106
+ // /prove endpoint — return a valid response so the flow completes up to encryption
107
+ return new Response("not found", { status: 404 });
108
+ }) as any;
109
+
110
+ // Mock verifyNitroAttestation to throw a browser-like error
111
+ const verifySpy = spyOn(attestationModule, "verifyNitroAttestation").mockRejectedValue(
112
+ new TypeError("s is not a constructor"),
113
+ );
114
+
115
+ const prover = new TeeRexProver(API_URL, new WASMSimulator());
116
+ prover.setProvingMode(ProvingMode.remote);
117
+
118
+ // createChonkProof will fail at the /prove call (404), but the attestation
119
+ // fallback should have succeeded — verify by checking the spy was called
120
+ // and the flow continued past attestation to the /prove request
121
+ try {
122
+ await prover.createChonkProof([fakeStep]);
123
+ } catch {
124
+ // Expected — the mock /prove returns 404
125
+ }
126
+
127
+ expect(verifySpy).toHaveBeenCalledTimes(1);
128
+ verifySpy.mockRestore();
129
+ });
130
+
131
+ test("nitro mode re-throws real verification errors", async () => {
132
+ globalThis.fetch = mock(async (input: any) => {
133
+ const url = typeof input === "string" ? input : input.url;
134
+ if (url.includes("/attestation")) {
135
+ return new Response(
136
+ JSON.stringify({
137
+ mode: "nitro",
138
+ attestationDocument: "fake-doc",
139
+ publicKey: "server-key",
140
+ }),
141
+ { status: 200, headers: { "Content-Type": "application/json" } },
142
+ );
143
+ }
144
+ return new Response("not found", { status: 404 });
145
+ }) as any;
146
+
147
+ // Mock verifyNitroAttestation to throw a real verification error
148
+ const verifySpy = spyOn(attestationModule, "verifyNitroAttestation").mockRejectedValue(
149
+ new Error("PCR0 mismatch: expected abc got def"),
150
+ );
151
+
152
+ const prover = new TeeRexProver(API_URL, new WASMSimulator());
153
+ prover.setProvingMode(ProvingMode.remote);
154
+
155
+ await expect(prover.createChonkProof([fakeStep])).rejects.toThrow("PCR0 mismatch");
156
+
157
+ verifySpy.mockRestore();
158
+ });
159
+
160
+ test("local mode calls super.createChonkProof, not the API", async () => {
161
+ let fetchCalled = false;
162
+ globalThis.fetch = mock(async () => {
163
+ fetchCalled = true;
164
+ return new Response("", { status: 500 });
165
+ }) as any;
166
+
167
+ const superSpy = spyOn(BBLazyPrivateKernelProver.prototype, "createChonkProof");
168
+ superSpy.mockRejectedValue(new Error("local prover not available in test"));
169
+
170
+ const prover = new TeeRexProver(API_URL, new WASMSimulator());
171
+ prover.setProvingMode(ProvingMode.local);
172
+
173
+ try {
174
+ await prover.createChonkProof([fakeStep]);
175
+ } catch {
176
+ // Expected — we mocked super to throw
177
+ }
178
+
179
+ expect(fetchCalled).toBe(false);
180
+ expect(superSpy).toHaveBeenCalledTimes(1);
181
+ superSpy.mockRestore();
182
+ });
183
+ });
184
+ });