@learncard/lca-api-service 1.5.2 → 1.6.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.
Files changed (56) hide show
  1. package/.env.example +10 -0
  2. package/CHANGELOG.md +44 -0
  3. package/bundle.mjs +1 -1
  4. package/dist/app.d.ts +23 -1
  5. package/dist/config/cacheEnvironment.d.ts +4 -4
  6. package/dist/config/environment.d.ts +145 -114
  7. package/dist/config/oidcEnvironment.d.ts +22 -22
  8. package/dist/didkit_wasm_bg.wasm +0 -0
  9. package/dist/helpers/bedrockGeneration.d.ts +17 -0
  10. package/dist/helpers/bedrockGeneration.test.d.ts +1 -0
  11. package/dist/helpers/bedrockSchema.d.ts +10 -0
  12. package/dist/helpers/imageGeneration.d.ts +42 -0
  13. package/dist/helpers/imageGeneration.test.d.ts +1 -0
  14. package/dist/index.js +2 -2
  15. package/dist/models/Notification.d.ts +3 -1
  16. package/dist/publish-metafile.json +1 -1
  17. package/dist/routes/ai.skill-icons.test.d.ts +1 -0
  18. package/dist/routes/ai.test.d.ts +1 -0
  19. package/dist/routes/escrow.d.ts +19 -0
  20. package/dist/routes/keys.d.ts +1 -0
  21. package/dist/routes/notifications.d.ts +3 -1
  22. package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
  23. package/dist/services/escrow-enclave/logFailure.test.d.ts +1 -0
  24. package/dist/services/escrow-enclave/types.d.ts +17 -1
  25. package/dist/types/notifications.d.ts +3 -1
  26. package/docs/bedrock-generation.md +119 -0
  27. package/docs/image-generation.md +120 -0
  28. package/esbuildPlugins.cjs +9 -6
  29. package/package.json +21 -16
  30. package/scripts/node24-runtime.cjs +9 -0
  31. package/scripts/verify-node24-layer.cjs +49 -0
  32. package/serverless-local.yml +3 -1
  33. package/serverless.function-env.cjs +2 -4
  34. package/serverless.yml +76 -3
  35. package/src/config/environment.ts +14 -0
  36. package/src/helpers/bedrockGeneration.test.ts +222 -0
  37. package/src/helpers/bedrockGeneration.ts +110 -0
  38. package/src/helpers/bedrockSchema.ts +352 -0
  39. package/src/helpers/imageGeneration.test.ts +456 -0
  40. package/src/helpers/imageGeneration.ts +432 -0
  41. package/src/routes/ai.skill-icons.test.ts +92 -0
  42. package/src/routes/ai.test.ts +80 -0
  43. package/src/routes/ai.ts +33 -144
  44. package/src/routes/escrow.ts +15 -2
  45. package/src/routes/keys.ts +18 -0
  46. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +19 -0
  47. package/src/services/escrow-enclave/logFailure.test.ts +72 -0
  48. package/src/services/escrow-enclave/logFailure.ts +46 -0
  49. package/src/services/escrow-enclave/remoteEnclave.test.ts +101 -3
  50. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +192 -2
  51. package/src/services/escrow-enclave/remoteEnclave.ts +101 -36
  52. package/src/services/escrow-enclave/types.ts +26 -2
  53. package/test/keys-auth-share-escrow.spec.ts +103 -0
  54. package/test/keys-escrow.spec.ts +65 -0
  55. package/test/oidc.spec.ts +41 -2
  56. package/tsconfig.bedrock.json +10 -0
@@ -1,9 +1,10 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { createServer, type Server } from 'node:https';
3
3
  import type { AddressInfo } from 'node:net';
4
- import { afterAll, beforeAll, describe, expect, it } from 'vitest';
5
- import { createHttpTransport } from './remoteEnclave';
4
+ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
5
+ import { createHttpTransport, createRemoteEnclave } from './remoteEnclave';
6
6
  import { pinnedEnclaveCa } from './enclaveTrustAnchors';
7
+ import { logEscrowFailure } from './logFailure';
7
8
 
8
9
  const fixture = (name: string): string =>
9
10
  readFileSync(new URL(`./__fixtures__/tls/${name}`, import.meta.url), 'utf8');
@@ -59,4 +60,193 @@ describe('remote enclave TLS trust', () => {
59
60
  );
60
61
  expect(pinnedEnclaveCa('https://enclave-host.internal')).toBeUndefined();
61
62
  });
63
+
64
+ it('pins a distinct production CA by hostname only', () => {
65
+ const production = pinnedEnclaveCa('https://escrow-enclave.production.internal:8443');
66
+
67
+ expect(production).toContain('BEGIN CERTIFICATE');
68
+ expect(production).not.toEqual(
69
+ pinnedEnclaveCa('https://escrow-enclave.staging.internal:8443')
70
+ );
71
+ expect(pinnedEnclaveCa('https://production.internal:8443')).toBeUndefined();
72
+ });
73
+ });
74
+
75
+ describe('remote enclave connection recovery', () => {
76
+ const servers: Server[] = [];
77
+ const config = { headers: { Authorization: 'Bearer test-secret' }, timeout: 1_000 };
78
+ const serve = async (handler: Parameters<typeof createServer>[1]): Promise<string> => {
79
+ const server = createServer(
80
+ { cert: fixture('pinned-leaf.pem'), key: fixture('pinned-leaf.key') },
81
+ handler
82
+ );
83
+ servers.push(server);
84
+ await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
85
+ return url(server).replace('/v1/attest', '');
86
+ };
87
+
88
+ afterEach(async () => {
89
+ vi.restoreAllMocks();
90
+ await Promise.all(
91
+ servers.splice(0).map(
92
+ server =>
93
+ new Promise<void>(resolve => {
94
+ server.closeAllConnections();
95
+ server.close(() => resolve());
96
+ })
97
+ )
98
+ );
99
+ });
100
+
101
+ it.each(['/v1/attest', '/v1/enrollment-state', '/v1/hold-state'])(
102
+ 'retries %s once on a pre-response reset, on a fresh TLS connection',
103
+ async path => {
104
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
105
+ const sockets = new Set();
106
+ let requests = 0;
107
+ const origin = await serve((request, response) => {
108
+ sockets.add(request.socket);
109
+ requests += 1;
110
+ if (requests === 1) request.socket.destroy();
111
+ else response.end('{"ok":true}');
112
+ });
113
+ const transport = createHttpTransport(fixture('pinned-ca.pem'));
114
+ await expect(
115
+ transport.post(`${origin}${path}`, { secret: 'private-body' }, config)
116
+ ).resolves.toEqual({ status: 200, data: { ok: true } });
117
+ expect(requests).toBe(2);
118
+ expect(sockets.size).toBe(2);
119
+ expect(warn.mock.calls).toEqual([
120
+ ['[LCA escrow] retrying enclave read', { code: 'ECONNRESET' }],
121
+ ]);
122
+ }
123
+ );
124
+
125
+ it('does not pool successful requests across invocations', async () => {
126
+ const sockets = new Set();
127
+ const connections: (string | undefined)[] = [];
128
+ const origin = await serve((request, response) => {
129
+ sockets.add(request.socket);
130
+ connections.push(request.headers.connection);
131
+ response.end('{"ok":true}');
132
+ });
133
+ const transport = createHttpTransport(fixture('pinned-ca.pem'));
134
+ await transport.post(`${origin}/v1/attest`, {}, config);
135
+ await transport.post(`${origin}/v1/attest`, {}, config);
136
+ expect(sockets.size).toBe(2);
137
+ expect(connections).toEqual(['close', 'close']);
138
+ });
139
+
140
+ it.each([
141
+ '/v1/create-hold',
142
+ '/v1/confirm-hold',
143
+ '/v1/release',
144
+ '/v1/cancel-hold',
145
+ '/v1/apply-enrollment',
146
+ '/v1/authority-rewrap',
147
+ '/v1/carry-pin-verifier',
148
+ '/v1/resend-code',
149
+ '/v1/send-release-warning',
150
+ '/v1/unknown',
151
+ ])('never retries %s even when no response arrived', async path => {
152
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
153
+ let requests = 0;
154
+ const origin = await serve(request => {
155
+ requests += 1;
156
+ request.socket.destroy();
157
+ });
158
+ await expect(
159
+ createHttpTransport(fixture('pinned-ca.pem')).post(`${origin}${path}`, {}, config)
160
+ ).rejects.toMatchObject({ code: 'ECONNRESET' });
161
+ expect(requests).toBe(1);
162
+ expect(warn).not.toHaveBeenCalled();
163
+ });
164
+
165
+ it.each([429, 503, 302])('does not retry or follow HTTP %s', async status => {
166
+ let requests = 0;
167
+ const origin = await serve((_request, response) => {
168
+ requests += 1;
169
+ response.writeHead(status, { Location: '/v1/attest' });
170
+ response.end('{}');
171
+ });
172
+ await expect(
173
+ createHttpTransport(fixture('pinned-ca.pem')).post(`${origin}/v1/attest`, {}, config)
174
+ ).resolves.toMatchObject({ status });
175
+ expect(requests).toBe(1);
176
+ });
177
+
178
+ it('does not retry a timeout', async () => {
179
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
180
+ let requests = 0;
181
+ const origin = await serve(() => {
182
+ requests += 1;
183
+ });
184
+ await expect(
185
+ createHttpTransport(fixture('pinned-ca.pem')).post(
186
+ `${origin}/v1/attest`,
187
+ {},
188
+ { ...config, timeout: 100 }
189
+ )
190
+ ).rejects.toThrow();
191
+ expect(requests).toBe(1);
192
+ expect(warn).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it('does not retry after even partial response headers', async () => {
196
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
197
+ let requests = 0;
198
+ const origin = await serve(request => {
199
+ requests += 1;
200
+ request.socket.write('HTTP/1.1 200 OK\r\n');
201
+ setTimeout(() => request.socket.destroy(), 20);
202
+ });
203
+ await expect(
204
+ createHttpTransport(fixture('pinned-ca.pem')).post(`${origin}/v1/attest`, {}, config)
205
+ ).rejects.toThrow();
206
+ expect(requests).toBe(1);
207
+ expect(warn).not.toHaveBeenCalled();
208
+ });
209
+
210
+ it('keeps the second attempt within the original wall-clock budget', async () => {
211
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
212
+ let requests = 0;
213
+ const origin = await serve(request => {
214
+ requests += 1;
215
+ if (requests === 1) setTimeout(() => request.socket.destroy(), 150);
216
+ });
217
+ const start = performance.now();
218
+ await expect(
219
+ createHttpTransport(fixture('pinned-ca.pem')).post(
220
+ `${origin}/v1/attest`,
221
+ {},
222
+ { ...config, timeout: 300 }
223
+ )
224
+ ).rejects.toThrow();
225
+ expect(performance.now() - start).toBeLessThan(430);
226
+ expect(requests).toBe(2);
227
+ expect(warn).toHaveBeenCalledOnce();
228
+ });
229
+
230
+ it('stops after one retry and preserves sanitized transport failure logging', async () => {
231
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
232
+ let requests = 0;
233
+ const origin = await serve(request => {
234
+ requests += 1;
235
+ request.socket.destroy();
236
+ });
237
+ const enclave = createRemoteEnclave({
238
+ baseUrl: origin,
239
+ token: 'private-token',
240
+ timeoutMs: 1_000,
241
+ caPem: fixture('pinned-ca.pem'),
242
+ });
243
+ const error = await enclave.getAttestation().catch(error => error);
244
+ expect(error).toMatchObject({ reason: 'transport' });
245
+ logEscrowFailure(error);
246
+ expect(requests).toBe(2);
247
+ expect(warn.mock.calls).toEqual([
248
+ ['[LCA escrow] retrying enclave read', { code: 'ECONNRESET' }],
249
+ ['[LCA escrow] enclave operation failed', { reason: 'transport' }],
250
+ ]);
251
+ });
62
252
  });
@@ -1,4 +1,5 @@
1
1
  import { Agent } from 'node:https';
2
+ import { Agent as HttpAgent, ClientRequest } from 'node:http';
2
3
  import axios from 'axios';
3
4
  import { z } from 'zod';
4
5
  import {
@@ -16,6 +17,7 @@ import {
16
17
  import {
17
18
  EscrowBlobError,
18
19
  EscrowPinMismatchError,
20
+ EscrowCodeError,
19
21
  EscrowPolicyError,
20
22
  EscrowUnavailableError,
21
23
  type CarryPinVerifierInput,
@@ -91,15 +93,18 @@ const holdRecordValidator = z
91
93
  ledgerSeq: z.number().int().nonnegative(),
92
94
  })
93
95
  .passthrough();
94
- const validatedHold = (hold: ReleaseRequest['hold']): ReleaseRequest['hold'] => {
95
- if (!holdRecordValidator.safeParse(hold).success) throw new EscrowUnavailableError();
96
+ const validatedHold = (
97
+ hold: ReleaseRequest['hold'],
98
+ reason: 'invalid_hold' | 'invalid_response' = 'invalid_hold'
99
+ ): ReleaseRequest['hold'] => {
100
+ if (!holdRecordValidator.safeParse(hold).success) throw new EscrowUnavailableError(reason);
96
101
  if (hold.hold.policyVersion === 2) {
97
102
  try {
98
103
  AuthorityCommitValidator.parse(
99
104
  JSON.parse(Buffer.from(hold.hold.signature, 'base64').toString('utf8'))
100
105
  );
101
106
  } catch {
102
- throw new EscrowUnavailableError();
107
+ throw new EscrowUnavailableError(reason);
103
108
  }
104
109
  }
105
110
  return hold;
@@ -112,6 +117,9 @@ const releaseResponseValidator = z.object({
112
117
  const errorCodeValidator = z.enum([
113
118
  'policy',
114
119
  'pinMismatch',
120
+ 'codeMismatch',
121
+ 'codeExpired',
122
+ 'codeLocked',
115
123
  'blob',
116
124
  'unavailable',
117
125
  'ledger',
@@ -122,8 +130,10 @@ const errorResponseValidator = z.object({ code: errorCodeValidator, message: z.s
122
130
  const errorForCode = (code: z.infer<typeof errorCodeValidator>): Error => {
123
131
  if (code === 'policy') return new EscrowPolicyError();
124
132
  if (code === 'pinMismatch') return new EscrowPinMismatchError();
133
+ if (code === 'codeMismatch' || code === 'codeExpired' || code === 'codeLocked')
134
+ return new EscrowCodeError(code);
125
135
  if (code === 'blob') return new EscrowBlobError();
126
- return new EscrowUnavailableError(); // 'unavailable' | 'ledger' | 'time'
136
+ return new EscrowUnavailableError(code); // 'unavailable' | 'ledger' | 'time'
127
137
  };
128
138
 
129
139
  export interface RemoteEnclaveTransportResponse {
@@ -141,15 +151,57 @@ export interface RemoteEnclaveTransport {
141
151
 
142
152
  /** With `caPem`, the connection trusts only that CA (system roots are not consulted). */
143
153
  export const createHttpTransport = (caPem?: string): RemoteEnclaveTransport => {
144
- const httpsAgent = caPem ? new Agent({ ca: caPem, keepAlive: true }) : undefined;
154
+ // The host enforces a 60s absolute connection lifetime, even for busy sockets.
155
+ // Do not retain sockets across Lambda freezes (or use Node's global pool).
156
+ const httpsAgent = new Agent({ ...(caPem ? { ca: caPem } : {}), keepAlive: false });
157
+ const httpAgent = new HttpAgent({ keepAlive: false });
145
158
  return {
146
159
  post: async (url, body, config) => {
147
- const response = await axios.post(url, body, {
148
- ...config,
149
- httpsAgent,
150
- validateStatus: () => true,
151
- });
152
- return { status: response.status, data: response.data };
160
+ const path = new URL(url).pathname;
161
+ const safeRead = ['/v1/attest', '/v1/enrollment-state', '/v1/hold-state'].includes(
162
+ path
163
+ );
164
+ const deadline = performance.now() + config.timeout;
165
+ const controller = new AbortController();
166
+ // One wall-clock budget covers connection setup and BOTH attempts.
167
+ const timer = setTimeout(() => controller.abort(), config.timeout);
168
+ try {
169
+ for (let attempt = 0; ; attempt += 1) {
170
+ try {
171
+ const response = await axios.post(url, body, {
172
+ ...config,
173
+ timeout: Math.max(1, Math.ceil(deadline - performance.now())),
174
+ signal: controller.signal,
175
+ httpsAgent,
176
+ httpAgent,
177
+ // Keep the native ClientRequest observable; never redirect a vault POST.
178
+ maxRedirects: 0,
179
+ validateStatus: () => true,
180
+ });
181
+ return { status: response.status, data: response.data };
182
+ } catch (error) {
183
+ const code = axios.isAxiosError(error) ? error.code : undefined;
184
+ if (
185
+ attempt !== 0 ||
186
+ !safeRead ||
187
+ (code !== 'ECONNRESET' && code !== 'EPIPE') ||
188
+ !axios.isAxiosError(error) ||
189
+ error.response ||
190
+ !(error.request instanceof ClientRequest) ||
191
+ error.request.socket?.bytesRead !== 0 ||
192
+ controller.signal.aborted ||
193
+ performance.now() >= deadline
194
+ )
195
+ throw error;
196
+ // No response does NOT prove the server did not execute the request.
197
+ // Never replay mutations: holds, PIN attempts, delivery and enrollment
198
+ // may already be committed. Only the three read paths above are safe.
199
+ console.warn('[LCA escrow] retrying enclave read', { code });
200
+ }
201
+ }
202
+ } finally {
203
+ clearTimeout(timer);
204
+ }
153
205
  },
154
206
  };
155
207
  };
@@ -183,21 +235,24 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
183
235
  let data: unknown;
184
236
  try {
185
237
  const response = await transport.post(`${config.baseUrl}${path}`, body, {
186
- headers: { Authorization: `Bearer ${config.token}` },
238
+ headers: {
239
+ Authorization: `Bearer ${config.token}`,
240
+ ...(path === '/v1/create-hold' ? { 'X-Escrow-Delivery-Evidence': '1' } : {}),
241
+ },
187
242
  timeout: config.timeoutMs,
188
243
  });
189
244
  status = response.status;
190
245
  data = response.data;
191
246
  } catch {
192
- throw new EscrowUnavailableError();
247
+ throw new EscrowUnavailableError('transport');
193
248
  }
194
249
  if (status < 200 || status >= 300) {
195
250
  const parsedError = errorResponseValidator.safeParse(data);
196
251
  if (parsedError.success) throw errorForCode(parsedError.data.code);
197
- throw new EscrowUnavailableError();
252
+ throw new EscrowUnavailableError(status === 429 ? 'rate_limit' : 'http_refusal');
198
253
  }
199
254
  const parsed = validator.safeParse(data);
200
- if (!parsed.success) throw new EscrowUnavailableError();
255
+ if (!parsed.success) throw new EscrowUnavailableError('invalid_response');
201
256
  return parsed.data;
202
257
  };
203
258
 
@@ -217,7 +272,7 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
217
272
  result.enrollmentEpoch !== input.signedIntent.intent.enrollmentEpoch ||
218
273
  (result.envelope === null) !== (input.signedIntent.intent.action === 'disable')
219
274
  )
220
- throw new EscrowUnavailableError();
275
+ throw new EscrowUnavailableError('invalid_response');
221
276
  return result;
222
277
  },
223
278
  getAttestation: (nonce?: Uint8Array): Promise<EnclaveAttestation> =>
@@ -258,26 +313,36 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
258
313
  z.object({ result: AuthorityMutationValidator }).strict()
259
314
  );
260
315
  if (!result.envelope || result.enrollmentEpoch !== input.sourceEnrollmentEpoch)
261
- throw new EscrowUnavailableError();
316
+ throw new EscrowUnavailableError('invalid_response');
262
317
  return { envelope: result.envelope, authority: result };
263
318
  },
264
- createHold: async input => ({
265
- holdRecord: validatedHold(
266
- await call(
267
- '/v1/create-hold',
268
- {
269
- envelope: input.envelope,
270
- holdId: input.holdId,
271
- expectedDid: input.expectedDid,
272
- expectedShareVersion: input.expectedShareVersion,
273
- enrollmentEpoch: input.enrollmentEpoch,
274
- releasePolicy: input.releasePolicy,
275
- clientEphemeralPublicKey: input.clientEphemeralPublicKey,
276
- },
277
- holdRecordValidator
278
- )
279
- ),
280
- }),
319
+ createHold: async input => {
320
+ const result = await call(
321
+ '/v1/create-hold',
322
+ {
323
+ envelope: input.envelope,
324
+ holdId: input.holdId,
325
+ expectedDid: input.expectedDid,
326
+ expectedShareVersion: input.expectedShareVersion,
327
+ enrollmentEpoch: input.enrollmentEpoch,
328
+ releasePolicy: input.releasePolicy,
329
+ clientEphemeralPublicKey: input.clientEphemeralPublicKey,
330
+ },
331
+ holdRecordValidator.extend({
332
+ delivery: z
333
+ .object({
334
+ codeSent: z.boolean(),
335
+ proof: AuthorityHoldReceiptValidator,
336
+ })
337
+ .optional(),
338
+ })
339
+ );
340
+ // Response-only evidence must not be echoed into strict old-image hold DTOs.
341
+ const { delivery, ...holdRecord } = result;
342
+ return { holdRecord: validatedHold(holdRecord, 'invalid_response'), delivery };
343
+ },
344
+ getHoldState: async input =>
345
+ call('/v1/hold-state', input, z.object({ proof: AuthorityHoldReceiptValidator })),
281
346
  cancelHold: async input => {
282
347
  const result = await call(
283
348
  '/v1/cancel-hold',
@@ -291,7 +356,7 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
291
356
  z.object({ ok: z.literal(true), receipt: AuthorityCommitValidator.optional() })
292
357
  );
293
358
  if (input.hold.hold.policyVersion === 2 && !result.receipt)
294
- throw new EscrowUnavailableError();
359
+ throw new EscrowUnavailableError('invalid_response');
295
360
  if (result.receipt) return { receipt: result.receipt };
296
361
  return undefined;
297
362
  },
@@ -362,7 +427,7 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
362
427
  releaseResponseValidator
363
428
  );
364
429
  if (input.hold.hold.policyVersion === 2 && !result.receipt)
365
- throw new EscrowUnavailableError();
430
+ throw new EscrowUnavailableError('invalid_response');
366
431
  return result;
367
432
  },
368
433
  };
@@ -127,7 +127,15 @@ export interface EscrowEnclave {
127
127
  input: RewrapEscrowBlobInput
128
128
  ): Promise<{ envelope: EscrowEnvelope; authority?: AuthorityMutationResult }>;
129
129
  releaseEscrow(input: ReleaseRequest): Promise<ReleaseResult>;
130
- createHold(input: EnclaveCreateHoldInput): Promise<{ holdRecord: EscrowHoldRecord }>;
130
+ createHold(input: EnclaveCreateHoldInput): Promise<{
131
+ holdRecord: EscrowHoldRecord;
132
+ delivery?: { codeSent: boolean; proof: AuthorityHoldReceipt };
133
+ }>;
134
+ getHoldState?(input: {
135
+ expectedDid: string;
136
+ hold: EscrowHoldRecord;
137
+ nonce: string;
138
+ }): Promise<{ proof: AuthorityRecord }>;
131
139
  cancelHold(input: CancelHoldRequest): Promise<void | { receipt: AuthorityRecord }>;
132
140
  /** P9.1 (attack C): authority (nitro) mode only — software mode never
133
141
  * produces an awaiting-confirmation hold, so it has nothing to confirm.
@@ -150,6 +158,12 @@ export class EscrowPolicyError extends Error {
150
158
  this.name = 'EscrowPolicyError';
151
159
  }
152
160
  }
161
+ export class EscrowCodeError extends Error {
162
+ constructor(public readonly reason: 'codeMismatch' | 'codeExpired' | 'codeLocked') {
163
+ super(reason);
164
+ this.name = 'EscrowCodeError';
165
+ }
166
+ }
153
167
  export class EscrowPinMismatchError extends Error {
154
168
  constructor() {
155
169
  super('Incorrect PIN.');
@@ -163,7 +177,17 @@ export class EscrowBlobError extends Error {
163
177
  }
164
178
  }
165
179
  export class EscrowUnavailableError extends Error {
166
- constructor() {
180
+ constructor(
181
+ readonly reason:
182
+ | 'unavailable'
183
+ | 'ledger'
184
+ | 'time'
185
+ | 'transport'
186
+ | 'invalid_response'
187
+ | 'invalid_hold'
188
+ | 'http_refusal'
189
+ | 'rate_limit' = 'unavailable'
190
+ ) {
167
191
  super('Escrow recovery is not available.');
168
192
  this.name = 'EscrowUnavailableError';
169
193
  }
@@ -0,0 +1,103 @@
1
+ import { randomUUID } from 'crypto';
2
+
3
+ import { client } from '@mongo';
4
+ import { createUserKeysIndexes, getUserKeysCollection, type MongoUserKeyType } from '@models';
5
+
6
+ import { getClient } from './helpers/getClient';
7
+
8
+ const makeMockToken = (email: string, uid: string): string => {
9
+ const payload = Buffer.from(JSON.stringify({ sub: uid, email })).toString('base64url');
10
+
11
+ return `header.${payload}.signature`;
12
+ };
13
+
14
+ const makeUser = (
15
+ uid: string,
16
+ email: string,
17
+ recoveryMethods: MongoUserKeyType['recoveryMethods']
18
+ ): MongoUserKeyType => {
19
+ const now = new Date();
20
+
21
+ return {
22
+ contactMethod: { type: 'email', value: email },
23
+ authProviders: [{ type: 'firebase', id: uid }],
24
+ primaryDid: `did:key:z${uid}`,
25
+ linkedDids: [],
26
+ keyProvider: 'sss',
27
+ authShare: { encryptedData: 'auth-share', encryptedDek: '', iv: '' },
28
+ shareVersion: 3,
29
+ shareUpdatedAt: now,
30
+ previousAuthShares: [],
31
+ securityLevel: 'enhanced',
32
+ recoveryMethods,
33
+ migratedFromWeb3Auth: false,
34
+ sssActivationState: 'active',
35
+ createdAt: now,
36
+ updatedAt: now,
37
+ };
38
+ };
39
+
40
+ beforeAll(async () => {
41
+ process.env.IS_E2E_TEST = 'true';
42
+ process.env.SEED ||= 'a'.repeat(64);
43
+ await client.connect();
44
+ await createUserKeysIndexes();
45
+ });
46
+
47
+ afterAll(async () => {
48
+ await client.close();
49
+ });
50
+
51
+ describe('getAuthShare priorEscrowEnrollment', () => {
52
+ const statusFor = async (recoveryMethods: MongoUserKeyType['recoveryMethods']) => {
53
+ const uid = `auth-share-escrow-${randomUUID()}`;
54
+ const email = `${uid}@example.com`;
55
+ const collection = getUserKeysCollection();
56
+ await collection.insertOne(makeUser(uid, email, recoveryMethods));
57
+ try {
58
+ return await getClient().keys.getAuthShare({
59
+ authToken: makeMockToken(email, uid),
60
+ providerType: 'firebase',
61
+ });
62
+ } finally {
63
+ await collection.deleteMany({ 'authProviders.id': uid });
64
+ }
65
+ };
66
+ const now = new Date();
67
+
68
+ it('is false for an escrow method confirmed at the current share version', async () => {
69
+ const status = await statusFor([
70
+ {
71
+ type: 'escrow',
72
+ createdAt: now,
73
+ shareVersion: 3,
74
+ confirmationStatus: 'confirmed',
75
+ confirmedAt: now,
76
+ },
77
+ ]);
78
+ expect(status?.priorEscrowEnrollment).toBe(false);
79
+ expect(status?.recoveryMethods).toHaveLength(1);
80
+ });
81
+
82
+ it('is true for an escrow method left pending by an identity-recovery rebind', async () => {
83
+ const status = await statusFor([
84
+ { type: 'escrow', createdAt: now, shareVersion: 3, confirmationStatus: 'pending' },
85
+ ]);
86
+ expect(status?.priorEscrowEnrollment).toBe(true);
87
+ // The pending method itself is still never exposed.
88
+ expect(status?.recoveryMethods).toEqual([]);
89
+ });
90
+
91
+ it('is false when the account has no escrow method', async () => {
92
+ const status = await statusFor([
93
+ {
94
+ type: 'phrase',
95
+ createdAt: now,
96
+ shareVersion: 3,
97
+ confirmationStatus: 'confirmed',
98
+ confirmedAt: now,
99
+ },
100
+ ]);
101
+ expect(status?.priorEscrowEnrollment).toBe(false);
102
+ });
103
+ });