@learncard/lca-api-service 1.5.0 → 1.5.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.
Files changed (35) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +23 -0
  3. package/bundle.mjs +9 -1
  4. package/config/config.dev.json +1 -1
  5. package/dist/app.d.ts +220 -1
  6. package/dist/index.js +2 -2
  7. package/dist/models/EscrowHold.d.ts +30 -2
  8. package/dist/models/UserKey.d.ts +89 -5
  9. package/dist/publish-metafile.json +1 -0
  10. package/dist/routes/escrow.d.ts +218 -0
  11. package/dist/routes/index.d.ts +4 -4
  12. package/dist/routes/keys.d.ts +2 -1
  13. package/dist/services/escrow-enclave/authorityContract.d.ts +170 -0
  14. package/dist/services/escrow-enclave/types.d.ts +38 -2
  15. package/package.json +9 -10
  16. package/project.json +8 -1
  17. package/serverless.yml +2 -0
  18. package/src/config/environment.ts +1 -1
  19. package/src/config/escrowDeployment.test.ts +8 -0
  20. package/src/jobs/escrowBlobRewrap.ts +45 -2
  21. package/src/jobs/escrowHoldReminders.ts +50 -1
  22. package/src/models/EscrowHold.ts +128 -10
  23. package/src/models/UserKey.ts +59 -8
  24. package/src/routes/escrow.ts +371 -51
  25. package/src/routes/index.ts +1 -1
  26. package/src/routes/keys.ts +5 -0
  27. package/src/services/escrow-enclave/README.md +31 -0
  28. package/src/services/escrow-enclave/authorityContract.ts +126 -0
  29. package/src/services/escrow-enclave/cancellation.ts +2 -2
  30. package/src/services/escrow-enclave/remoteEnclave.test.ts +216 -3
  31. package/src/services/escrow-enclave/remoteEnclave.ts +129 -23
  32. package/src/services/escrow-enclave/types.ts +51 -3
  33. package/test/jobs/escrowHoldReminders.spec.ts +195 -3
  34. package/test/keys-escrow.spec.ts +522 -11
  35. package/test/models/escrow.spec.ts +13 -0
@@ -0,0 +1,126 @@
1
+ import { z } from 'zod';
2
+ import {
3
+ encodeEscrowEnrollmentIntent,
4
+ ESCROW_ALGORITHM,
5
+ ESCROW_ENVELOPE_VERSION,
6
+ } from '@learncard/sss-key-manager';
7
+
8
+ export const safeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
9
+ const label = z
10
+ .string()
11
+ .min(1)
12
+ .max(128)
13
+ .regex(/^[A-Za-z0-9_-]+$/);
14
+ export const AuthorityEnvelopeValidator = z
15
+ .object({
16
+ version: z.literal(ESCROW_ENVELOPE_VERSION),
17
+ algorithm: z.literal(ESCROW_ALGORITHM),
18
+ keyId: z.string().min(1).max(128),
19
+ ephemeralPublicKey: z.string().max(256),
20
+ salt: z.string().max(128),
21
+ iv: z.string().max(64),
22
+ ciphertext: z.string().max(16_384),
23
+ })
24
+ .strict();
25
+ export const AuthorityRecordValidator = z
26
+ .object({
27
+ version: z.literal(1),
28
+ kind: z.enum(['proposal', 'commit', 'snapshot']),
29
+ keyId: z.string().min(1).max(128),
30
+ measurement: z.array(z.number().int().min(0).max(255)).length(32),
31
+ payload: z.string().min(1).max(49_152),
32
+ signature: z.string().length(88),
33
+ })
34
+ .strict();
35
+ export const AuthorityCommitValidator = AuthorityRecordValidator.extend({
36
+ kind: z.literal('commit'),
37
+ payload: z.string().min(1).max(8192),
38
+ });
39
+ // Idempotent-retry attestation for confirm-hold / send-release-warning (see
40
+ // `authority_hold_snapshot` in policy/authority.rs): a fresh call returns a
41
+ // `commit`; a retry of an already-applied operation returns this instead,
42
+ // signed over the hold's current fields rather than a new ledger transition.
43
+ export const AuthorityHoldSnapshotValidator = AuthorityRecordValidator.extend({
44
+ kind: z.literal('snapshot'),
45
+ });
46
+ // confirm-hold and send-release-warning responses carry whichever shape the
47
+ // enclave actually returned; callers must not assume `commit`.
48
+ export const AuthorityHoldReceiptValidator = z.union([
49
+ AuthorityCommitValidator,
50
+ AuthorityHoldSnapshotValidator,
51
+ ]);
52
+ export const SignedEnrollmentIntentValidator = z
53
+ .object({
54
+ intent: z
55
+ .object({
56
+ version: z.literal(2),
57
+ tenant: label,
58
+ did: z.string().length(56),
59
+ authorityGeneration: label,
60
+ expectedEnrollmentVersion: safeInteger,
61
+ action: z.enum(['enroll', 'replace', 'disable', 'carry']),
62
+ sourceBlobIdentity: z
63
+ .string()
64
+ .regex(/^[0-9a-f]{64}$/)
65
+ .nullable(),
66
+ targetBlobIdentity: z
67
+ .string()
68
+ .regex(/^[0-9a-f]{64}$/)
69
+ .nullable(),
70
+ pinAction: z.enum(['set', 'change', 'clear', 'keep']),
71
+ shareVersion: z.number().int().positive().max(0xffffffff),
72
+ enrollmentEpoch: safeInteger.positive(),
73
+ requestId: z.string().min(1).max(112),
74
+ allowRewrap: z.boolean(),
75
+ // P9.1 (attack C): shape only — `encodeEscrowEnrollmentIntent` below
76
+ // enforces normalization, the paired requirement, and the language
77
+ // allow-list; lca-api never re-derives or overrides these values.
78
+ recoveryEmail: z.string().min(3).max(254).nullable(),
79
+ recoveryLanguage: z.string().length(2).nullable(),
80
+ })
81
+ .strict()
82
+ .refine(intent => {
83
+ try {
84
+ encodeEscrowEnrollmentIntent(intent);
85
+ return true;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }, 'Invalid enrollment intent'),
90
+ signature: z.string().length(88),
91
+ })
92
+ .strict();
93
+ export const AuthorityStateValidator = z
94
+ .object({
95
+ tenant: label,
96
+ authorityGeneration: label,
97
+ did: z.string().min(1).max(8192),
98
+ enrollmentVersion: safeInteger,
99
+ enrollmentEpoch: safeInteger,
100
+ status: z.enum(['empty', 'active', 'disabled']),
101
+ envelope: AuthorityEnvelopeValidator.nullable(),
102
+ shareVersion: z.number().int().positive().max(0xffffffff).nullable(),
103
+ hasPin: z.boolean(),
104
+ pinGeneration: safeInteger,
105
+ attemptsUsed: z.number().int().min(0).max(10),
106
+ })
107
+ .strict();
108
+ export const AuthorityStateResponseValidator = z
109
+ .object({
110
+ state: AuthorityStateValidator,
111
+ proof: AuthorityRecordValidator.extend({ kind: z.literal('snapshot') }),
112
+ })
113
+ .strict();
114
+ export const AuthorityMutationValidator = z
115
+ .object({
116
+ envelope: AuthorityEnvelopeValidator.nullable(),
117
+ receipt: AuthorityCommitValidator,
118
+ authorityGeneration: label,
119
+ enrollmentVersion: safeInteger.positive(),
120
+ enrollmentEpoch: safeInteger.positive(),
121
+ })
122
+ .strict();
123
+ export type AuthorityRecord = z.infer<typeof AuthorityRecordValidator>;
124
+ export type AuthorityHoldReceipt = z.infer<typeof AuthorityHoldReceiptValidator>;
125
+ export type AuthorityMutationResult = z.infer<typeof AuthorityMutationValidator>;
126
+ export type AuthorityStateResponse = z.infer<typeof AuthorityStateResponseValidator>;
@@ -40,13 +40,13 @@ export const cancelHoldInEnclave = async (
40
40
  ) {
41
41
  throw new Error('Cancellation material unavailable');
42
42
  }
43
- await getEscrowEnclave().cancelHold({
43
+ const result = await getEscrowEnclave().cancelHold({
44
44
  envelope,
45
45
  hold: hold.holdRecord,
46
46
  clientEphemeralPublicKey: hold.clientEphemeralPublicKey,
47
47
  expectedDid: hold.primaryDid,
48
48
  });
49
- await confirmEscrowEnclaveCancellation(hold._id);
49
+ await confirmEscrowEnclaveCancellation(hold._id, result?.receipt);
50
50
  return true;
51
51
  } catch {
52
52
  // Policy/unknown/terminal errors are ambiguous, not proof of cancellation.
@@ -12,6 +12,35 @@ import {
12
12
  const baseUrl = 'https://enclave-host.internal';
13
13
  const token = 't'.repeat(32);
14
14
  const timeoutMs = 10_000;
15
+ const receipt = {
16
+ version: 1 as const,
17
+ kind: 'commit' as const,
18
+ keyId: 'key-1',
19
+ measurement: Array(32).fill(0),
20
+ payload: 'AQ==',
21
+ signature: Buffer.alloc(64).toString('base64'),
22
+ };
23
+
24
+ // Real Rust shape for the idempotent-retry path (`authority_hold_snapshot`,
25
+ // policy/authority.rs): a signed tuple, never an `AuthorityCommitValidator`.
26
+ const snapshotReceipt = {
27
+ version: 1 as const,
28
+ kind: 'snapshot' as const,
29
+ keyId: 'key-1',
30
+ measurement: Array(32).fill(0),
31
+ payload: Buffer.from(
32
+ JSON.stringify([
33
+ 'learncard-authority-hold-snapshot-v2',
34
+ 'hold-1',
35
+ Array(32).fill(7),
36
+ 'pending',
37
+ 1700000000000,
38
+ 1700086400000,
39
+ 'g1',
40
+ ])
41
+ ).toString('base64'),
42
+ signature: Buffer.alloc(64).toString('base64'),
43
+ };
15
44
 
16
45
  const envelope: EscrowEnvelope = {
17
46
  version: 1,
@@ -223,7 +252,14 @@ describe('createRemoteEnclave', () => {
223
252
  });
224
253
 
225
254
  it('rewraps an escrow blob sealed under a previous key onto the current key', async () => {
226
- const post = vi.fn().mockResolvedValue(respond(200, { envelope }));
255
+ const authority = {
256
+ envelope,
257
+ receipt,
258
+ authorityGeneration: 'g1',
259
+ enrollmentVersion: 2,
260
+ enrollmentEpoch: 1,
261
+ };
262
+ const post = vi.fn().mockResolvedValue(respond(200, { result: authority }));
227
263
  const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
228
264
  const input = {
229
265
  envelope,
@@ -232,10 +268,10 @@ describe('createRemoteEnclave', () => {
232
268
  sourceEnrollmentEpoch: 1,
233
269
  };
234
270
 
235
- await expect(enclave.rewrapEscrowBlob(input)).resolves.toEqual({ envelope });
271
+ await expect(enclave.rewrapEscrowBlob(input)).resolves.toEqual({ envelope, authority });
236
272
 
237
273
  expect(post).toHaveBeenCalledWith(
238
- `${baseUrl}/v1/rewrap-escrow-blob`,
274
+ `${baseUrl}/v1/authority-rewrap`,
239
275
  {
240
276
  envelope,
241
277
  expectedDid: 'did:example:alice',
@@ -246,6 +282,183 @@ describe('createRemoteEnclave', () => {
246
282
  );
247
283
  });
248
284
 
285
+ it('preserves v2 cancellation receipts and stable release IDs, refusing absent commit evidence', async () => {
286
+ const v2 = {
287
+ ...hold,
288
+ hold: {
289
+ ...hold.hold,
290
+ policyVersion: 2,
291
+ signature: Buffer.from(JSON.stringify(receipt)).toString('base64'),
292
+ },
293
+ };
294
+ const post = vi.fn().mockResolvedValue(respond(200, { ok: true, receipt }));
295
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
296
+ const input = {
297
+ envelope,
298
+ hold: v2,
299
+ expectedDid: v2.hold.did,
300
+ clientEphemeralPublicKey: v2.hold.clientEphemeralPublicKey,
301
+ requestId: 'stable-id',
302
+ };
303
+ await expect(enclave.cancelHold(input)).resolves.toEqual({ receipt });
304
+ post.mockResolvedValue(respond(200, { sealed: envelope, receipt }));
305
+ await expect(enclave.releaseEscrow(input)).resolves.toEqual({ sealed: envelope, receipt });
306
+ expect(post.mock.calls.at(-1)?.[1]).toMatchObject({ requestId: 'stable-id' });
307
+ post.mockResolvedValue(respond(200, { sealed: envelope }));
308
+ await expect(enclave.releaseEscrow(input)).rejects.toBeInstanceOf(EscrowUnavailableError);
309
+ post.mockResolvedValue(respond(200, { ok: true }));
310
+ await expect(enclave.cancelHold(input)).rejects.toBeInstanceOf(EscrowUnavailableError);
311
+ });
312
+
313
+ // [A] confirm-hold/send-release-warning: a fresh call returns a signed
314
+ // `commit`; an idempotent retry (lost response, hourly job re-run)
315
+ // returns a signed `snapshot` instead — both are real response shapes
316
+ // the enclave can return, never a stub that only ever returns `commit`.
317
+ const confirmInput = {
318
+ envelope,
319
+ hold,
320
+ clientEphemeralPublicKey: hold.hold.clientEphemeralPublicKey,
321
+ expectedDid: hold.hold.did,
322
+ code: '12345678',
323
+ };
324
+ it('confirmHold accepts a fresh commit receipt and returns the authoritative confirmation time', async () => {
325
+ const post = vi
326
+ .fn()
327
+ .mockResolvedValue(
328
+ respond(200, { confirmed: true, receipt, confirmedHiMs: 1700000000000 })
329
+ );
330
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
331
+
332
+ await expect(enclave.confirmHold!(confirmInput)).resolves.toEqual({
333
+ receipt,
334
+ confirmedAtMs: 1700000000000,
335
+ });
336
+ });
337
+ it('confirmHold accepts a retry snapshot receipt (not a commit) and returns the SAME confirmation time', async () => {
338
+ const post = vi.fn().mockResolvedValue(
339
+ respond(200, {
340
+ confirmed: true,
341
+ receipt: snapshotReceipt,
342
+ confirmedHiMs: 1700000000000,
343
+ })
344
+ );
345
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
346
+
347
+ await expect(enclave.confirmHold!(confirmInput)).resolves.toEqual({
348
+ receipt: snapshotReceipt,
349
+ confirmedAtMs: 1700000000000,
350
+ });
351
+ });
352
+ it('confirmHold fails closed when the receipt is neither a commit nor a snapshot', async () => {
353
+ const post = vi.fn().mockResolvedValue(
354
+ respond(200, {
355
+ confirmed: true,
356
+ receipt: { ...receipt, kind: 'proposal' },
357
+ confirmedHiMs: 1700000000000,
358
+ })
359
+ );
360
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
361
+
362
+ await expect(enclave.confirmHold!(confirmInput)).rejects.toBeInstanceOf(
363
+ EscrowUnavailableError
364
+ );
365
+ });
366
+ it('confirmHold fails closed when confirmedHiMs is missing (pre-fix response shape)', async () => {
367
+ const post = vi.fn().mockResolvedValue(respond(200, { confirmed: true, receipt }));
368
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
369
+
370
+ await expect(enclave.confirmHold!(confirmInput)).rejects.toBeInstanceOf(
371
+ EscrowUnavailableError
372
+ );
373
+ });
374
+
375
+ const warningInput = { holdId: hold.hold.holdId, expectedDid: hold.hold.did };
376
+ it('sendReleaseWarning accepts a fresh commit receipt and returns the authoritative send time', async () => {
377
+ const post = vi
378
+ .fn()
379
+ .mockResolvedValue(
380
+ respond(200, { sent: true, receipt, releaseWarningSentHiMs: 1700086400000 })
381
+ );
382
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
383
+
384
+ await expect(enclave.sendReleaseWarning!(warningInput)).resolves.toEqual({
385
+ receipt,
386
+ releaseWarningSentAtMs: 1700086400000,
387
+ });
388
+ });
389
+ it('sendReleaseWarning accepts a retry snapshot receipt (not a commit) and returns the SAME send time', async () => {
390
+ const post = vi.fn().mockResolvedValue(
391
+ respond(200, {
392
+ sent: true,
393
+ receipt: snapshotReceipt,
394
+ releaseWarningSentHiMs: 1700086400000,
395
+ })
396
+ );
397
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
398
+
399
+ await expect(enclave.sendReleaseWarning!(warningInput)).resolves.toEqual({
400
+ receipt: snapshotReceipt,
401
+ releaseWarningSentAtMs: 1700086400000,
402
+ });
403
+ });
404
+ it('sendReleaseWarning fails closed when releaseWarningSentHiMs is missing (pre-fix response shape)', async () => {
405
+ const post = vi.fn().mockResolvedValue(respond(200, { sent: true, receipt }));
406
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
407
+
408
+ await expect(enclave.sendReleaseWarning!(warningInput)).rejects.toBeInstanceOf(
409
+ EscrowUnavailableError
410
+ );
411
+ });
412
+
413
+ it('forwards signed enrollment once and rejects a proposal or mismatched result generation', async () => {
414
+ const input = {
415
+ signedIntent: {
416
+ intent: {
417
+ version: 2 as const,
418
+ tenant: 'learncard',
419
+ did: 'did:key:z6MktwupdmLXVVqTzCw4i46r4uGyosGXRnR3XjN4Zq7oMMsw',
420
+ authorityGeneration: 'g1',
421
+ expectedEnrollmentVersion: 0,
422
+ action: 'enroll' as const,
423
+ sourceBlobIdentity: null,
424
+ targetBlobIdentity: 'ab'.repeat(32),
425
+ pinAction: 'keep' as const,
426
+ shareVersion: 1,
427
+ enrollmentEpoch: 1,
428
+ requestId: 'request',
429
+ allowRewrap: true,
430
+ recoveryEmail: 'owner@example.com',
431
+ recoveryLanguage: 'en',
432
+ },
433
+ signature: Buffer.alloc(64).toString('base64'),
434
+ },
435
+ sourceEnvelope: null,
436
+ targetEnvelope: envelope,
437
+ };
438
+ const result = {
439
+ envelope,
440
+ receipt,
441
+ authorityGeneration: 'g1',
442
+ enrollmentVersion: 1,
443
+ enrollmentEpoch: 1,
444
+ };
445
+ const post = vi.fn().mockResolvedValue(respond(200, { result }));
446
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
447
+ await expect(enclave.applyEnrollmentIntent!(input)).resolves.toEqual(result);
448
+ expect(post).toHaveBeenCalledTimes(1);
449
+ expect(post.mock.calls[0]?.slice(0, 2)).toEqual([`${baseUrl}/v1/apply-enrollment`, input]);
450
+ post.mockResolvedValue(respond(200, { result: { ...result, authorityGeneration: 'g2' } }));
451
+ await expect(enclave.applyEnrollmentIntent!(input)).rejects.toBeInstanceOf(
452
+ EscrowUnavailableError
453
+ );
454
+ post.mockResolvedValue(
455
+ respond(200, { result: { ...result, receipt: { ...receipt, kind: 'proposal' } } })
456
+ );
457
+ await expect(enclave.applyEnrollmentIntent!(input)).rejects.toBeInstanceOf(
458
+ EscrowUnavailableError
459
+ );
460
+ });
461
+
249
462
  it.each([
250
463
  ['policy', EscrowPolicyError],
251
464
  ['pinMismatch', EscrowPinMismatchError],
@@ -1,6 +1,13 @@
1
1
  import { Agent } from 'node:https';
2
2
  import axios from 'axios';
3
3
  import { z } from 'zod';
4
+ import {
5
+ AuthorityCommitValidator,
6
+ AuthorityHoldReceiptValidator,
7
+ AuthorityMutationValidator,
8
+ AuthorityStateResponseValidator,
9
+ safeInteger,
10
+ } from './authorityContract';
4
11
  import {
5
12
  ESCROW_ALGORITHM,
6
13
  ESCROW_ENVELOPE_VERSION,
@@ -12,11 +19,14 @@ import {
12
19
  EscrowPolicyError,
13
20
  EscrowUnavailableError,
14
21
  type CarryPinVerifierInput,
22
+ type ConfirmHoldRequest,
15
23
  type EnclaveAttestation,
16
24
  type EscrowEnclave,
17
25
  type ReleaseRequest,
18
26
  type ReleaseResult,
27
+ type ResendCodeRequest,
19
28
  type RewrapEscrowBlobInput,
29
+ type SendReleaseWarningRequest,
20
30
  type VerifyEscrowBlobInput,
21
31
  type VerifyEscrowBlobResult,
22
32
  } from './types';
@@ -58,7 +68,6 @@ const verifyBlobResponseValidator = z.union([
58
68
  ]);
59
69
 
60
70
  const carryPinVerifierResponseValidator = z.object({ envelope: envelopeValidator });
61
- const rewrapEscrowBlobResponseValidator = z.object({ envelope: envelopeValidator });
62
71
 
63
72
  // Opaque SignedHoldRecord JSON: validate shape while preserving signed extensions.
64
73
  const holdRecordValidator = z
@@ -74,8 +83,8 @@ const holdRecordValidator = z
74
83
  clientEphemeralPublicKey: z.string().min(1),
75
84
  createdLo: z.number().int().nonnegative(),
76
85
  createdHi: z.number().int().nonnegative(),
77
- policyVersion: z.number().int().positive(),
78
- signature: z.string().min(1),
86
+ policyVersion: z.union([z.literal(1), z.literal(2)]),
87
+ signature: z.string().min(1).max(8192),
79
88
  })
80
89
  .passthrough(),
81
90
  holdDurationMs: z.number().int().nonnegative(),
@@ -84,9 +93,21 @@ const holdRecordValidator = z
84
93
  .passthrough();
85
94
  const validatedHold = (hold: ReleaseRequest['hold']): ReleaseRequest['hold'] => {
86
95
  if (!holdRecordValidator.safeParse(hold).success) throw new EscrowUnavailableError();
96
+ if (hold.hold.policyVersion === 2) {
97
+ try {
98
+ AuthorityCommitValidator.parse(
99
+ JSON.parse(Buffer.from(hold.hold.signature, 'base64').toString('utf8'))
100
+ );
101
+ } catch {
102
+ throw new EscrowUnavailableError();
103
+ }
104
+ }
87
105
  return hold;
88
106
  };
89
- const releaseResponseValidator = z.object({ sealed: envelopeValidator });
107
+ const releaseResponseValidator = z.object({
108
+ sealed: envelopeValidator,
109
+ receipt: AuthorityCommitValidator.optional(),
110
+ });
90
111
 
91
112
  const errorCodeValidator = z.enum([
92
113
  'policy',
@@ -181,6 +202,24 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
181
202
  };
182
203
 
183
204
  return {
205
+ getEnrollmentState: input =>
206
+ call('/v1/enrollment-state', input, AuthorityStateResponseValidator),
207
+ applyEnrollmentIntent: async input => {
208
+ const { result } = await call(
209
+ '/v1/apply-enrollment',
210
+ input,
211
+ z.object({ result: AuthorityMutationValidator }).strict()
212
+ );
213
+ if (
214
+ result.authorityGeneration !== input.signedIntent.intent.authorityGeneration ||
215
+ result.enrollmentVersion !==
216
+ input.signedIntent.intent.expectedEnrollmentVersion + 1 ||
217
+ result.enrollmentEpoch !== input.signedIntent.intent.enrollmentEpoch ||
218
+ (result.envelope === null) !== (input.signedIntent.intent.action === 'disable')
219
+ )
220
+ throw new EscrowUnavailableError();
221
+ return result;
222
+ },
184
223
  getAttestation: (nonce?: Uint8Array): Promise<EnclaveAttestation> =>
185
224
  call('/v1/attest', { nonce: Array.from(nonce ?? []) }, attestResponseValidator),
186
225
  verifyEscrowBlob: (input: VerifyEscrowBlobInput): Promise<VerifyEscrowBlobResult> =>
@@ -207,47 +246,110 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
207
246
  },
208
247
  carryPinVerifierResponseValidator
209
248
  ),
210
- rewrapEscrowBlob: (input: RewrapEscrowBlobInput): Promise<{ envelope: EscrowEnvelope }> =>
211
- call(
212
- '/v1/rewrap-escrow-blob',
249
+ rewrapEscrowBlob: async (input: RewrapEscrowBlobInput) => {
250
+ const { result } = await call(
251
+ '/v1/authority-rewrap',
213
252
  {
214
253
  envelope: input.envelope,
215
254
  expectedDid: input.expectedDid,
216
255
  expectedShareVersion: input.expectedShareVersion,
217
256
  sourceEnrollmentEpoch: input.sourceEnrollmentEpoch,
218
257
  },
219
- rewrapEscrowBlobResponseValidator
220
- ),
258
+ z.object({ result: AuthorityMutationValidator }).strict()
259
+ );
260
+ if (!result.envelope || result.enrollmentEpoch !== input.sourceEnrollmentEpoch)
261
+ throw new EscrowUnavailableError();
262
+ return { envelope: result.envelope, authority: result };
263
+ },
221
264
  createHold: async input => ({
222
- holdRecord: await call(
223
- '/v1/create-hold',
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
+ }),
281
+ cancelHold: async input => {
282
+ const result = await call(
283
+ '/v1/cancel-hold',
224
284
  {
225
285
  envelope: input.envelope,
226
- holdId: input.holdId,
286
+ hold: validatedHold(input.hold),
287
+ clientEphemeralPublicKey: input.clientEphemeralPublicKey,
227
288
  expectedDid: input.expectedDid,
228
- expectedShareVersion: input.expectedShareVersion,
229
- enrollmentEpoch: input.enrollmentEpoch,
230
- releasePolicy: input.releasePolicy,
289
+ ...(input.requestId ? { requestId: input.requestId } : {}),
290
+ },
291
+ z.object({ ok: z.literal(true), receipt: AuthorityCommitValidator.optional() })
292
+ );
293
+ if (input.hold.hold.policyVersion === 2 && !result.receipt)
294
+ throw new EscrowUnavailableError();
295
+ if (result.receipt) return { receipt: result.receipt };
296
+ return undefined;
297
+ },
298
+ confirmHold: async (input: ConfirmHoldRequest) => {
299
+ const result = await call(
300
+ '/v1/confirm-hold',
301
+ {
302
+ envelope: input.envelope,
303
+ hold: validatedHold(input.hold),
231
304
  clientEphemeralPublicKey: input.clientEphemeralPublicKey,
305
+ expectedDid: input.expectedDid,
306
+ code: input.code,
307
+ ...(input.requestId ? { requestId: input.requestId } : {}),
232
308
  },
233
- holdRecordValidator
234
- ),
235
- }),
236
- cancelHold: async input => {
237
- await call(
238
- '/v1/cancel-hold',
309
+ z.object({
310
+ confirmed: z.literal(true),
311
+ receipt: AuthorityHoldReceiptValidator,
312
+ confirmedHiMs: safeInteger,
313
+ })
314
+ );
315
+ return { receipt: result.receipt, confirmedAtMs: result.confirmedHiMs };
316
+ },
317
+ resendCode: async (input: ResendCodeRequest) => {
318
+ const result = await call(
319
+ '/v1/resend-code',
239
320
  {
240
321
  envelope: input.envelope,
241
322
  hold: validatedHold(input.hold),
242
323
  clientEphemeralPublicKey: input.clientEphemeralPublicKey,
243
324
  expectedDid: input.expectedDid,
325
+ ...(input.requestId ? { requestId: input.requestId } : {}),
326
+ },
327
+ z.object({ sent: z.literal(true), receipt: AuthorityCommitValidator })
328
+ );
329
+ return { receipt: result.receipt };
330
+ },
331
+ sendReleaseWarning: async (input: SendReleaseWarningRequest) => {
332
+ const result = await call(
333
+ '/v1/send-release-warning',
334
+ {
335
+ holdId: input.holdId,
336
+ expectedDid: input.expectedDid,
337
+ ...(input.requestId ? { requestId: input.requestId } : {}),
244
338
  },
245
- z.object({ ok: z.literal(true) })
339
+ z.object({
340
+ sent: z.literal(true),
341
+ receipt: AuthorityHoldReceiptValidator,
342
+ releaseWarningSentHiMs: safeInteger,
343
+ })
246
344
  );
345
+ return {
346
+ receipt: result.receipt,
347
+ releaseWarningSentAtMs: result.releaseWarningSentHiMs,
348
+ };
247
349
  },
248
350
  releaseEscrow: async (input: ReleaseRequest): Promise<ReleaseResult> => {
249
351
  validatedHold(input.hold);
250
- return call(
352
+ const result = await call(
251
353
  '/v1/release',
252
354
  {
253
355
  envelope: input.envelope,
@@ -255,9 +357,13 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
255
357
  clientEphemeralPublicKey: input.clientEphemeralPublicKey,
256
358
  expectedDid: input.expectedDid,
257
359
  ...(input.pinProof !== undefined ? { pinProof: input.pinProof } : {}),
360
+ ...(input.requestId ? { requestId: input.requestId } : {}),
258
361
  },
259
362
  releaseResponseValidator
260
363
  );
364
+ if (input.hold.hold.policyVersion === 2 && !result.receipt)
365
+ throw new EscrowUnavailableError();
366
+ return result;
261
367
  },
262
368
  };
263
369
  };