@learncard/lca-api-service 1.5.1 → 1.5.3

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 (41) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +32 -0
  3. package/config/config.dev.json +1 -1
  4. package/dist/app.d.ts +239 -1
  5. package/dist/index.js +2 -2
  6. package/dist/models/EscrowHold.d.ts +30 -2
  7. package/dist/models/UserKey.d.ts +89 -5
  8. package/dist/publish-metafile.json +1 -1
  9. package/dist/routes/ai.test.d.ts +1 -0
  10. package/dist/routes/escrow.d.ts +237 -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/logFailure.d.ts +1 -0
  15. package/dist/services/escrow-enclave/logFailure.test.d.ts +1 -0
  16. package/dist/services/escrow-enclave/types.d.ts +55 -3
  17. package/esbuildPlugins.cjs +9 -6
  18. package/package.json +10 -10
  19. package/src/config/environment.ts +1 -1
  20. package/src/config/escrowDeployment.test.ts +8 -0
  21. package/src/jobs/escrowBlobRewrap.ts +45 -2
  22. package/src/jobs/escrowHoldReminders.ts +50 -1
  23. package/src/models/EscrowHold.ts +128 -10
  24. package/src/models/UserKey.ts +59 -8
  25. package/src/routes/ai.test.ts +103 -0
  26. package/src/routes/ai.ts +2 -1
  27. package/src/routes/escrow.ts +386 -53
  28. package/src/routes/index.ts +1 -1
  29. package/src/routes/keys.ts +5 -0
  30. package/src/services/escrow-enclave/README.md +31 -0
  31. package/src/services/escrow-enclave/authorityContract.ts +126 -0
  32. package/src/services/escrow-enclave/cancellation.ts +2 -2
  33. package/src/services/escrow-enclave/logFailure.test.ts +64 -0
  34. package/src/services/escrow-enclave/logFailure.ts +29 -0
  35. package/src/services/escrow-enclave/remoteEnclave.test.ts +315 -4
  36. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +182 -2
  37. package/src/services/escrow-enclave/remoteEnclave.ts +202 -31
  38. package/src/services/escrow-enclave/types.ts +77 -5
  39. package/test/jobs/escrowHoldReminders.spec.ts +195 -3
  40. package/test/keys-escrow.spec.ts +587 -11
  41. package/test/models/escrow.spec.ts +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@learncard/lca-api-service",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
4
4
  "description": "",
5
5
  "engines": {
6
6
  "node": ">=24.12 <25"
@@ -63,21 +63,21 @@
63
63
  "@fastify/formbody": "^8.0.2",
64
64
  "@fastify/rate-limit": "^10.2.0",
65
65
  "@fastify/static": "^10.1.1",
66
- "@learncard/core": "9.4.38",
67
- "@learncard/did-web-plugin": "1.1.38",
68
- "@learncard/didkit-plugin": "1.10.3",
69
- "@learncard/didkit-plugin-node": "0.3.3",
66
+ "@learncard/core": "9.4.40",
67
+ "@learncard/did-web-plugin": "1.1.40",
68
+ "@learncard/didkit-plugin": "1.10.5",
69
+ "@learncard/didkit-plugin-node": "0.3.5",
70
70
  "@learncard/email-templates": "1.2.1",
71
- "@learncard/helpers": "1.6.2",
72
- "@learncard/init": "2.5.3",
73
- "@learncard/types": "5.23.0",
74
- "@learncard/sss-key-manager": "0.2.1",
71
+ "@learncard/helpers": "1.7.0",
72
+ "@learncard/init": "2.5.5",
73
+ "@learncard/types": "5.23.2",
74
+ "@learncard/sss-key-manager": "0.2.3",
75
75
  "@sentry/serverless": "^7.61.0",
76
76
  "@trpc/server": "11.8.0",
77
77
  "@types/jsonwebtoken": "^9.0.8",
78
78
  "@types/lodash": "^4.14.191",
79
79
  "@types/node-forge": "^1.3.11",
80
- "axios": "^0.33.0",
80
+ "axios": "^0.34.0",
81
81
  "dag-jose-utils": "2.0.0",
82
82
  "did-jwt": "5.9.0",
83
83
  "dotenv": "^16.0.3",
@@ -64,7 +64,7 @@ export const lcaApiEnvironmentShape = {
64
64
  * silently DoS recovery. See services/escrow-ledger-monitor/README.md. */
65
65
  ESCROW_RELEASE_KILL_SWITCH: optionalEnvironmentBoolean,
66
66
  ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS: optionalEnvironmentString
67
- .transform(value => (value === undefined ? 10_000 : Number(value)))
67
+ .transform(value => (value === undefined ? 15_000 : Number(value)))
68
68
  .pipe(z.number().int().positive().max(30_000)),
69
69
  ESCROW_HOLD_DURATION_MS: optionalEnvironmentString
70
70
  .transform(value => (value === undefined ? 604_800_000 : Number(value)))
@@ -101,6 +101,14 @@ describe('escrow deployment wiring', () => {
101
101
  );
102
102
  });
103
103
 
104
+ it('keeps every checked-in enclave timeout above the host forwarding deadline', () => {
105
+ // enclave 10s < host 12s < lca-api; an equal or shorter value races the response.
106
+ for (const [stage, config] of Object.entries(stages)) {
107
+ const value = (config as Record<string, string>).ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS;
108
+ if (value !== undefined) expect(Number(value), stage).toBeGreaterThan(12_000);
109
+ }
110
+ });
111
+
104
112
  it('never checks in escrow credentials', () => {
105
113
  for (const [stage, config] of Object.entries(stages)) {
106
114
  for (const name of [...SOURCES.bundle, ...SOURCES.toggle]) {
@@ -7,7 +7,10 @@ import {
7
7
  getEnclaveAttestationIdentity,
8
8
  getEscrowEnclave,
9
9
  isEscrowEnabled,
10
+ isEscrowRemoteMode,
10
11
  } from '../services/escrow-enclave';
12
+ import { randomBytes } from 'crypto';
13
+ import { EscrowUnavailableError } from '../services/escrow-enclave/types';
11
14
 
12
15
  const DEFAULT_REWRAP_BATCH_LIMIT = 200;
13
16
 
@@ -90,7 +93,46 @@ export const runEscrowBlobRewrap = async (
90
93
  skippedPendingHold += 1;
91
94
  continue;
92
95
  }
93
- const { envelope } = await getEscrowEnclave().rewrapEscrowBlob({
96
+ const enclave = getEscrowEnclave();
97
+ if (isEscrowRemoteMode()) {
98
+ if (!enclave.getEnrollmentState || !blob.authorityGeneration)
99
+ throw new EscrowUnavailableError();
100
+ const snapshot = await enclave.getEnrollmentState({
101
+ expectedDid: userKey.primaryDid,
102
+ nonce: randomBytes(32).toString('hex'),
103
+ });
104
+ const current = snapshot.state;
105
+ if (
106
+ current.status !== 'active' ||
107
+ !current.envelope ||
108
+ current.authorityGeneration !== blob.authorityGeneration ||
109
+ current.did !== userKey.primaryDid ||
110
+ current.shareVersion !== blob.shareVersion ||
111
+ current.enrollmentEpoch !== blob.enrollmentEpoch
112
+ )
113
+ throw new EscrowUnavailableError();
114
+ if (current.envelope.keyId === attestation.keyId) {
115
+ const stored = await rewrapEscrowBlobByAuthProvider(
116
+ authProvider,
117
+ blob,
118
+ userKey.escrowPin,
119
+ current.envelope,
120
+ {
121
+ enclaveKeyId: attestation.keyId,
122
+ enclaveMode: attestation.mode,
123
+ measurements: attestation.measurements,
124
+ },
125
+ {
126
+ authorityGeneration: current.authorityGeneration,
127
+ enrollmentVersion: current.enrollmentVersion,
128
+ stateProof: snapshot.proof,
129
+ }
130
+ );
131
+ if (stored) rewrapped += 1;
132
+ continue;
133
+ }
134
+ }
135
+ const { envelope, authority } = await enclave.rewrapEscrowBlob({
94
136
  envelope: blob.envelope,
95
137
  expectedDid: userKey.primaryDid,
96
138
  expectedShareVersion: blob.shareVersion,
@@ -105,7 +147,8 @@ export const runEscrowBlobRewrap = async (
105
147
  enclaveKeyId: attestation.keyId,
106
148
  enclaveMode: attestation.mode,
107
149
  measurements: attestation.measurements,
108
- }
150
+ },
151
+ authority
109
152
  );
110
153
  if (stored) rewrapped += 1;
111
154
  // else: lost the compare-and-swap race (a concurrent re-enroll,
@@ -9,13 +9,19 @@ import {
9
9
  findEscrowHoldsPendingStartNotification,
10
10
  releaseEscrowReminderClaim,
11
11
  recordEscrowStartNotificationAttempt,
12
+ recordEscrowHoldNotification,
13
+ computeEffectiveReleaseAfter,
12
14
  } from '@models';
13
15
  import { environment } from '@environment';
14
16
  import {
15
17
  cancelHoldInEnclave,
16
18
  logEscrowCancellationPending,
17
19
  } from '../services/escrow-enclave/cancellation';
18
- import { isEscrowEnabled, notifyEscrowHoldEvent } from '../services/escrow-enclave';
20
+ import {
21
+ getEscrowEnclave,
22
+ isEscrowEnabled,
23
+ notifyEscrowHoldEvent,
24
+ } from '../services/escrow-enclave';
19
25
 
20
26
  // Two queues of ten 3-second deliveries leave half the 120-second Lambda
21
27
  // budget for database work. Least-recently-attempted ordering prevents starvation.
@@ -148,6 +154,11 @@ export const runEscrowHoldReminders = async (
148
154
  }
149
155
  }
150
156
 
157
+ // P9.1 (attack C): `sendReleaseWarning` is defined only by the remote
158
+ // (nitro/authority) backend — software mode never produces a hold the
159
+ // enclave itself must warn about, since it has no confirmation step to
160
+ // have gated release behind in the first place.
161
+ const sendReleaseWarning = getEscrowEnclave().sendReleaseWarning;
151
162
  for (const candidate of candidates) {
152
163
  let claimed: Awaited<ReturnType<typeof claimEscrowHoldForReminder>> = null;
153
164
  try {
@@ -155,6 +166,44 @@ export const runEscrowHoldReminders = async (
155
166
  claimed = await claimEscrowHoldForReminder(candidate._id, now);
156
167
  if (!claimed) continue;
157
168
 
169
+ // P9.1 (attack C): an authority-mode Hold-policy hold's security
170
+ // channel is the enclave's own sealed-blob-sourced email, not this
171
+ // Mongo-recipient-sourced one, which an attacker who controls the
172
+ // suppressed-notification side of the threat model could silence
173
+ // or redirect. send-release-warning both notifies the real owner
174
+ // and records the timestamp release is independently gated on.
175
+ if (sendReleaseWarning && claimed.releasePolicy !== 'pin' && claimed.holdRecord) {
176
+ const { releaseWarningSentAtMs } = await sendReleaseWarning({
177
+ holdId: claimed.holdRecord.hold.holdId,
178
+ expectedDid: claimed.primaryDid,
179
+ });
180
+ // P2-a: the enclave call above is idempotent (a repeat never
181
+ // resends or moves its recorded timestamp), so persisting
182
+ // this acknowledgment AFTER it succeeds is safe to retry: if
183
+ // this write is lost (crash, Mongo failure), the hold stays
184
+ // selected next run, the enclave call is a no-op, and this
185
+ // write finally lands. Without it, every run re-called the
186
+ // enclave for every hold in the 24h window forever.
187
+ // [B]: also persist the effective availability from the
188
+ // authoritative confirmation/warning times — a late
189
+ // (catch-up) warning can push real availability later than
190
+ // the hold's existing stored `releaseAfter` estimate, which
191
+ // otherwise keeps advertising an already-passed deadline.
192
+ await recordEscrowHoldNotification(
193
+ claimed._id,
194
+ 'reminder',
195
+ claimed.confirmedAt
196
+ ? computeEffectiveReleaseAfter({
197
+ confirmedAtMs: claimed.confirmedAt.getTime(),
198
+ holdDurationMs: claimed.holdRecord.holdDurationMs,
199
+ releaseWarningSentAtMs,
200
+ })
201
+ : undefined
202
+ );
203
+ reminded += 1;
204
+ continue;
205
+ }
206
+
158
207
  const userKey = await findUserKeyByAuthProvider(
159
208
  claimed.authProvider.type,
160
209
  claimed.authProvider.id
@@ -1,5 +1,9 @@
1
1
  import { createHash, createHmac, randomBytes } from 'crypto';
2
2
  import { z } from 'zod';
3
+ import {
4
+ AuthorityCommitValidator,
5
+ type AuthorityRecord,
6
+ } from '../services/escrow-enclave/authorityContract';
3
7
  import type { Collection } from 'mongodb';
4
8
  import mongodb from '@mongo';
5
9
  import { environment } from '@environment';
@@ -13,6 +17,40 @@ export const ESCROW_HOLDS_COLLECTION = 'escrowholds';
13
17
  export const ESCROW_HOLD_STALE_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
14
18
  export const ESCROW_HOLD_RESTART_MIN_AGE_MS = environment.ESCROW_HOLD_RESTART_MIN_AGE_MS;
15
19
  export const ESCROW_HOLD_REMINDER_WINDOW_MS = 24 * 60 * 60 * 1000;
20
+ // Roughtime's release lower bound can lag its creation/confirmation upper
21
+ // bound (see escrow-enclave-app's D2); advertise an additional minute on top
22
+ // of both enclave release gates below, and still allow retries when trusted
23
+ // time is briefly unavailable.
24
+ export const ESCROW_RELEASE_TIME_MARGIN_MS = 60_000;
25
+ // Mirrors `RELEASE_WARNING_MIN_GAP_MS` in policy/authority.rs.
26
+ export const ESCROW_RELEASE_WARNING_GAP_MS = 24 * 60 * 60 * 1000;
27
+
28
+ /**
29
+ * The enclave gates release on BOTH `confirmed_hi_ms + holdDuration` AND
30
+ * `release_warning_sent_hi_ms + 24h` (see `authority_release_with_receipt`,
31
+ * policy/authority.rs ~663-671) — a hold is only actually releasable at the
32
+ * LATER of the two. A warning sent after the 7-day window has already
33
+ * elapsed (a late/catch-up send, P2-b) pushes real availability later than
34
+ * `releaseAfter` computed from confirmation alone; callers must recompute
35
+ * and persist this whenever either authoritative timestamp changes, using
36
+ * the enclave's own `confirmed_hi_ms`/`release_warning_sent_hi_ms` — never a
37
+ * locally observed `now` (see [A]/[A2]).
38
+ */
39
+ export const computeEffectiveReleaseAfter = (params: {
40
+ confirmedAtMs: number;
41
+ holdDurationMs: number;
42
+ releaseWarningSentAtMs?: number;
43
+ }): Date => {
44
+ const confirmedAvailabilityMs =
45
+ params.confirmedAtMs + params.holdDurationMs + ESCROW_RELEASE_TIME_MARGIN_MS;
46
+ const warningAvailabilityMs =
47
+ params.releaseWarningSentAtMs === undefined
48
+ ? undefined
49
+ : params.releaseWarningSentAtMs +
50
+ ESCROW_RELEASE_WARNING_GAP_MS +
51
+ ESCROW_RELEASE_TIME_MARGIN_MS;
52
+ return new Date(Math.max(confirmedAvailabilityMs, warningAvailabilityMs ?? 0));
53
+ };
16
54
  // Opaque JSON validated for shape, not signature authenticity. Preserve signed extensions.
17
55
  export const EscrowHoldRecordValidator = z
18
56
  .object({
@@ -51,6 +89,8 @@ export const EscrowHoldValidator = z.object({
51
89
  cancelReason: z.enum(['pin-mismatch', 'pin-locked', 'superseded', 'release-failed']).optional(),
52
90
  cancelledAt: z.date().optional(),
53
91
  enclaveCancelPendingAt: z.date().optional(),
92
+ enclaveCancelReceipt: AuthorityCommitValidator.optional(),
93
+ enclaveReleaseReceipt: AuthorityCommitValidator.optional(),
54
94
  enclaveCancelConfirmedAt: z.date().optional(),
55
95
  enclaveCancelAttempts: z.number().int().nonnegative().optional(),
56
96
  // Retain ciphertext for revocation even after account removal or re-enrollment.
@@ -69,6 +109,14 @@ export const EscrowHoldValidator = z.object({
69
109
  .max(4)
70
110
  .optional(),
71
111
  startedNotificationPending: z.boolean().optional(),
112
+ // P9.1 (attack C): set at creation only for an authority-mode Hold-policy
113
+ // hold (the enclave gates its own release on a confirmed email code, not
114
+ // just this flag). `confirmedAt` is set once `confirmRecovery` succeeds,
115
+ // at which point `releaseAfter` is also recomputed from the confirmation
116
+ // time (not the original creation time) so Mongo's own stale-expiry and
117
+ // reminder-window queries stay consistent with the enclave's real gate.
118
+ requiresConfirmation: z.boolean().optional(),
119
+ confirmedAt: z.date().optional(),
72
120
  reminderClaimedUntil: z.date().optional(),
73
121
  startedNotificationAttemptedAt: z.date().optional(),
74
122
  reminderAttemptedAt: z.date().optional(),
@@ -259,7 +307,10 @@ export const recordEscrowEnclaveCancelAttempt = async (id: string): Promise<bool
259
307
  return result.modifiedCount > 0;
260
308
  };
261
309
 
262
- export const confirmEscrowEnclaveCancellation = async (id: string): Promise<void> => {
310
+ export const confirmEscrowEnclaveCancellation = async (
311
+ id: string,
312
+ receipt?: AuthorityRecord
313
+ ): Promise<void> => {
263
314
  const now = new Date();
264
315
  await getEscrowHoldsCollection().updateOne(
265
316
  {
@@ -269,12 +320,28 @@ export const confirmEscrowEnclaveCancellation = async (id: string): Promise<void
269
320
  enclaveCancelConfirmedAt: { $exists: false },
270
321
  },
271
322
  {
272
- $set: { enclaveCancelConfirmedAt: now, updatedAt: now },
323
+ $set: {
324
+ enclaveCancelConfirmedAt: now,
325
+ updatedAt: now,
326
+ ...(receipt
327
+ ? { enclaveCancelReceipt: AuthorityCommitValidator.parse(receipt) }
328
+ : {}),
329
+ },
273
330
  $unset: { enclaveCancelPendingAt: '', enclaveEnvelope: '' },
274
331
  }
275
332
  );
276
333
  };
277
334
 
335
+ export const recordEscrowReleaseReceipt = async (
336
+ id: string,
337
+ receipt: AuthorityRecord
338
+ ): Promise<void> => {
339
+ await getEscrowHoldsCollection().updateOne(
340
+ { _id: id, status: 'completed' },
341
+ { $set: { enclaveReleaseReceipt: AuthorityCommitValidator.parse(receipt) } }
342
+ );
343
+ };
344
+
278
345
  export const cancelEscrowHold = async (
279
346
  id: string,
280
347
  cancelledBy: 'did' | 'system',
@@ -330,6 +397,22 @@ export const cancelEscrowHoldByCancelToken = async (
330
397
  { returnDocument: 'after' }
331
398
  );
332
399
  };
400
+ /**
401
+ * Records a successful confirmRecovery and corrects releaseAfter to the
402
+ * confirmation time (the enclave's own release gate is confirmation-based,
403
+ * not creation-based). Never overwrites an already-confirmed row.
404
+ */
405
+ export const recordEscrowHoldConfirmation = async (
406
+ id: string,
407
+ confirmedAt: Date,
408
+ releaseAfter: Date
409
+ ): Promise<EscrowHold | null> =>
410
+ getEscrowHoldsCollection().findOneAndUpdate(
411
+ { _id: id, status: 'pending', confirmedAt: { $exists: false } },
412
+ { $set: { confirmedAt, releaseAfter, updatedAt: confirmedAt } },
413
+ { returnDocument: 'after' }
414
+ );
415
+
333
416
  export const completeEscrowHold = async (id: string): Promise<EscrowHold | null> => {
334
417
  const now = new Date();
335
418
  return getEscrowHoldsCollection().findOneAndUpdate(
@@ -437,7 +520,24 @@ export const recordEscrowStartNotificationAttempt = async (
437
520
  * excludes them in practice within a few ms of job execution; the explicit
438
521
  * releasePolicy filter documents that exclusion instead of relying on
439
522
  * timing alone. Legacy rows predating `releasePolicy` are treated as `hold`,
440
- * matching `findPendingEscrowHoldByAuthProvider`'s own `$or`.
523
+ * matching `findPendingEscrowHoldByAuthProvider`'s own `$or`. An
524
+ * authority-mode hold still awaiting its email confirmation is excluded
525
+ * outright: its stored `releaseAfter` is only a rough creation-time estimate
526
+ * until confirmation recomputes it, and the enclave's own
527
+ * `authority_send_release_warning` would reject a pre-confirmation hold
528
+ * anyway (see policy/authority.rs), so excluding it here avoids a
529
+ * predictable, recurring no-op failure every run.
530
+ *
531
+ * Deliberately has no `releaseAfter > now` lower bound (catch-up, P2-b): the
532
+ * mandatory warning gate means release can never complete without a
533
+ * successfully recorded warning, regardless of how stale Mongo's own
534
+ * `releaseAfter` estimate is. An outage (SES down, this job not running)
535
+ * spanning an entire 24h reminder window must not permanently strand the
536
+ * hold — it stays selected, however overdue, until a warning is finally
537
+ * recorded (`notifications.kind` gains `'reminder'`). Real availability is
538
+ * then governed by the enclave's own gate, `release_warning_sent_hi_ms +
539
+ * 24h` (see `authority_release_with_receipt` in policy/authority.rs), not
540
+ * by this stale estimate.
441
541
  */
442
542
  export const findEscrowHoldsDueForReminder = async (
443
543
  now: Date,
@@ -446,11 +546,21 @@ export const findEscrowHoldsDueForReminder = async (
446
546
  getEscrowHoldsCollection()
447
547
  .find({
448
548
  status: 'pending',
449
- $or: [{ releasePolicy: 'hold' as const }, { releasePolicy: { $exists: false } }],
450
- releaseAfter: {
451
- $gt: now,
452
- $lte: new Date(now.getTime() + ESCROW_HOLD_REMINDER_WINDOW_MS),
453
- },
549
+ $and: [
550
+ {
551
+ $or: [
552
+ { releasePolicy: 'hold' as const },
553
+ { releasePolicy: { $exists: false } },
554
+ ],
555
+ },
556
+ {
557
+ $or: [
558
+ { requiresConfirmation: { $ne: true } },
559
+ { confirmedAt: { $exists: true } },
560
+ ],
561
+ },
562
+ ],
563
+ releaseAfter: { $lte: new Date(now.getTime() + ESCROW_HOLD_REMINDER_WINDOW_MS) },
454
564
  'notifications.kind': { $ne: 'reminder' },
455
565
  })
456
566
  .sort({ reminderAttemptedAt: 1, releaseAfter: 1 })
@@ -494,10 +604,17 @@ export const hashEscrowResumeToken = (token: string): string =>
494
604
  createHash('sha256').update(token).digest('hex');
495
605
  export const generateEscrowResumeToken = (): string => randomBytes(32).toString('hex');
496
606
 
497
- /** Appends a delivery record; a missing holdId is a no-op rather than a throw. */
607
+ /**
608
+ * Appends a delivery record; a missing holdId is a no-op rather than a throw.
609
+ * `releaseAfter` lets a `'reminder'` notification also persist the hold's
610
+ * effective availability (see `computeEffectiveReleaseAfter`) — a late
611
+ * release-warning send can push real availability later than the stored
612
+ * estimate [B]; omit it for notification kinds with no such recomputation.
613
+ */
498
614
  export const recordEscrowHoldNotification = async (
499
615
  holdId: string,
500
- kind: EscrowHold['notifications'][number]['kind']
616
+ kind: EscrowHold['notifications'][number]['kind'],
617
+ releaseAfter?: Date
501
618
  ): Promise<void> => {
502
619
  const now = new Date();
503
620
  await getEscrowHoldsCollection().updateOne(
@@ -507,6 +624,7 @@ export const recordEscrowHoldNotification = async (
507
624
  $set: {
508
625
  updatedAt: now,
509
626
  ...(kind === 'started' ? { startedNotificationPending: false } : {}),
627
+ ...(releaseAfter ? { releaseAfter } : {}),
510
628
  },
511
629
  }
512
630
  );
@@ -1,5 +1,11 @@
1
1
  import { z } from 'zod';
2
2
  import { escrowBlobIdentity } from '../services/escrow-enclave/blobIdentity';
3
+ import {
4
+ AuthorityCommitValidator,
5
+ AuthorityRecordValidator,
6
+ type AuthorityMutationResult,
7
+ type AuthorityRecord,
8
+ } from '../services/escrow-enclave/authorityContract';
3
9
  import type { Filter } from 'mongodb';
4
10
  import { ESCROW_PIN_MAX_ATTEMPTS } from '@learncard/sss-key-manager';
5
11
  import { EscrowPinStatusValidator as SharedEscrowPinStatusValidator } from '@learncard/types';
@@ -64,6 +70,12 @@ export const EscrowEnvelopeValidator = z
64
70
  .strict();
65
71
 
66
72
  export const EscrowBlobValidator = z.object({
73
+ authorityGeneration: z.string().min(1).max(128).optional(),
74
+ enrollmentVersion: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(),
75
+ commitReceipt: AuthorityCommitValidator.optional(),
76
+ authorityStateProof: AuthorityRecordValidator.extend({
77
+ kind: z.literal('snapshot'),
78
+ }).optional(),
67
79
  enrollmentEpoch: z.number().int().positive(),
68
80
  blobHash: z.string().regex(/^[0-9a-f]{64}$/),
69
81
  envelope: EscrowEnvelopeValidator,
@@ -738,7 +750,12 @@ export const setEscrowBlobByAuthProvider = async (
738
750
  blob: Omit<EscrowBlob, 'enrollmentEpoch' | 'blobHash'>,
739
751
  expectedShareVersion: number,
740
752
  pin?: { salt: string },
741
- carryFrom?: { blob: EscrowBlob; pin: NonNullable<MongoUserKeyType['escrowPin']> }
753
+ carryFrom?: { blob: EscrowBlob; pin: NonNullable<MongoUserKeyType['escrowPin']> },
754
+ authority?: {
755
+ enrollmentEpoch: number;
756
+ expectedBlob?: EscrowBlob;
757
+ expectedPin?: MongoUserKeyType['escrowPin'];
758
+ }
742
759
  ): Promise<MongoUserKeyType | null> => {
743
760
  const now = new Date();
744
761
  const parsed = EscrowBlobValidator.omit({ enrollmentEpoch: true, blobHash: true }).safeParse(
@@ -749,7 +766,7 @@ export const setEscrowBlobByAuthProvider = async (
749
766
  const assembled = EscrowBlobValidator.safeParse({
750
767
  ...parsed.data,
751
768
  blobHash,
752
- enrollmentEpoch: 1,
769
+ enrollmentEpoch: authority?.enrollmentEpoch ?? 1,
753
770
  });
754
771
  if (!assembled.success) throw new Error('Invalid escrow payload');
755
772
  const { enrollmentEpoch: _initialEpoch, ...validated } = assembled.data;
@@ -764,6 +781,12 @@ export const setEscrowBlobByAuthProvider = async (
764
781
  shareVersion: expectedShareVersion,
765
782
  escrowOptedOutAt: { $exists: false },
766
783
  ...(carryFrom ? { escrowBlob: carryFrom.blob, escrowPin: carryFrom.pin } : {}),
784
+ ...(authority
785
+ ? {
786
+ escrowBlob: authority.expectedBlob ?? { $exists: false },
787
+ escrowPin: authority.expectedPin ?? { $exists: false },
788
+ }
789
+ : {}),
767
790
  },
768
791
  [
769
792
  {
@@ -772,9 +795,14 @@ export const setEscrowBlobByAuthProvider = async (
772
795
  $mergeObjects: [
773
796
  { $literal: validated },
774
797
  {
775
- enrollmentEpoch: {
776
- $add: [{ $ifNull: ['$escrowBlob.enrollmentEpoch', 0] }, 1],
777
- },
798
+ enrollmentEpoch: authority
799
+ ? authority.enrollmentEpoch
800
+ : {
801
+ $add: [
802
+ { $ifNull: ['$escrowBlob.enrollmentEpoch', 0] },
803
+ 1,
804
+ ],
805
+ },
778
806
  },
779
807
  ],
780
808
  },
@@ -837,7 +865,11 @@ export const rewrapEscrowBlobByAuthProvider = async (
837
865
  oldBlob: EscrowBlob,
838
866
  oldPin: MongoUserKeyType['escrowPin'],
839
867
  newEnvelope: EscrowBlob['envelope'],
840
- identity: Pick<EscrowBlob, 'enclaveKeyId' | 'enclaveMode' | 'measurements'>
868
+ identity: Pick<EscrowBlob, 'enclaveKeyId' | 'enclaveMode' | 'measurements'>,
869
+ authority?: Pick<AuthorityMutationResult, 'authorityGeneration' | 'enrollmentVersion'> & {
870
+ receipt?: AuthorityRecord;
871
+ stateProof?: AuthorityRecord;
872
+ }
841
873
  ): Promise<MongoUserKeyType | null> => {
842
874
  const now = new Date();
843
875
  const blobHash = escrowBlobIdentity(newEnvelope);
@@ -850,6 +882,14 @@ export const rewrapEscrowBlobByAuthProvider = async (
850
882
  blobHash,
851
883
  enrollmentEpoch: oldBlob.enrollmentEpoch,
852
884
  createdAt: now,
885
+ ...(authority
886
+ ? {
887
+ authorityGeneration: authority.authorityGeneration,
888
+ enrollmentVersion: authority.enrollmentVersion,
889
+ commitReceipt: authority.receipt,
890
+ authorityStateProof: authority.stateProof,
891
+ }
892
+ : {}),
853
893
  });
854
894
  if (!parsed.success || parsed.data.enclaveKeyId !== parsed.data.envelope.keyId) return null;
855
895
  const filter: Filter<MongoUserKeyType> = {
@@ -885,11 +925,22 @@ export const findUserKeysWithEscrowBlobKeyId = async (
885
925
 
886
926
  export const clearEscrowByAuthProvider = async (
887
927
  authProvider: AuthProviderMapping,
888
- { optOut }: { optOut: boolean }
928
+ {
929
+ optOut,
930
+ expected,
931
+ }: { optOut: boolean; expected?: { blob?: EscrowBlob; pin?: MongoUserKeyType['escrowPin'] } }
889
932
  ): Promise<MongoUserKeyType | null> => {
890
933
  const now = new Date();
891
934
  return getUserKeysCollection().findOneAndUpdate(
892
- getAuthProviderFilter(authProvider),
935
+ {
936
+ ...getAuthProviderFilter(authProvider),
937
+ ...(expected
938
+ ? {
939
+ escrowBlob: expected.blob ?? { $exists: false },
940
+ escrowPin: expected.pin ?? { $exists: false },
941
+ }
942
+ : {}),
943
+ },
893
944
  {
894
945
  $unset: { escrowBlob: '', escrowPin: '' },
895
946
  $pull: { recoveryMethods: { type: 'escrow' } },
@@ -0,0 +1,103 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ SKILL_ICON_MAX_CHARACTERS_PER_REQUEST,
4
+ SKILL_ICON_MAX_NAMES_PER_REQUEST,
5
+ } from '@learncard/types';
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ create: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('@environment', () => ({ environment: { OPENAI_API_KEY: 'test-key' } }));
12
+ vi.mock('openai', () => ({
13
+ default: class {
14
+ chat = { completions: { create: mocks.create } };
15
+ },
16
+ }));
17
+ vi.mock('filestack-js', () => ({ init: () => ({}) }));
18
+ vi.mock('@routes', async () => {
19
+ const { initTRPC } = await import('@trpc/server');
20
+ const t = initTRPC.create();
21
+ return { t, didAndChallengeRoute: t.procedure };
22
+ });
23
+
24
+ import { aiRouter } from './ai';
25
+
26
+ const caller = aiRouter.createCaller({ user: { did: 'did:example:123' } } as never);
27
+
28
+ const respondWithIcons = (): void => {
29
+ mocks.create.mockImplementation(async ({ messages }: { messages: { content: string }[] }) => {
30
+ const names = [...messages[1]!.content.matchAll(/^- (.*)$/gm)].map(match => match[1]);
31
+
32
+ return {
33
+ choices: [
34
+ {
35
+ message: {
36
+ content: JSON.stringify({
37
+ items: names.map(name => ({ name, icon: '🎯' })),
38
+ }),
39
+ },
40
+ },
41
+ ],
42
+ };
43
+ });
44
+ };
45
+
46
+ describe('generateSkillIcons input validation', () => {
47
+ beforeEach(() => {
48
+ vi.clearAllMocks();
49
+ respondWithIcons();
50
+ });
51
+
52
+ it('accepts a batch at both limits', async () => {
53
+ const names = Array.from({ length: SKILL_ICON_MAX_NAMES_PER_REQUEST }, (_, i) =>
54
+ `${i}`.padStart(
55
+ SKILL_ICON_MAX_CHARACTERS_PER_REQUEST / SKILL_ICON_MAX_NAMES_PER_REQUEST,
56
+ 'x'
57
+ )
58
+ );
59
+
60
+ const result = await caller.generateSkillIcons({ names });
61
+
62
+ expect(Object.keys(result)).toHaveLength(SKILL_ICON_MAX_NAMES_PER_REQUEST);
63
+ expect(mocks.create).toHaveBeenCalledTimes(1);
64
+ });
65
+
66
+ it('rejects too many names with BAD_REQUEST before calling the model', async () => {
67
+ const names = Array.from(
68
+ { length: SKILL_ICON_MAX_NAMES_PER_REQUEST + 1 },
69
+ (_, i) => `s${i}`
70
+ );
71
+
72
+ await expect(caller.generateSkillIcons({ names })).rejects.toMatchObject({
73
+ code: 'BAD_REQUEST',
74
+ });
75
+ expect(mocks.create).not.toHaveBeenCalled();
76
+ });
77
+
78
+ it('rejects names totalling more than the character limit with BAD_REQUEST', async () => {
79
+ const names = ['a'.repeat(SKILL_ICON_MAX_CHARACTERS_PER_REQUEST), 'b'];
80
+
81
+ await expect(caller.generateSkillIcons({ names })).rejects.toMatchObject({
82
+ code: 'BAD_REQUEST',
83
+ });
84
+ expect(mocks.create).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it('rejects a single name over the character limit', async () => {
88
+ const names = ['a'.repeat(SKILL_ICON_MAX_CHARACTERS_PER_REQUEST + 1)];
89
+
90
+ await expect(caller.generateSkillIcons({ names })).rejects.toMatchObject({
91
+ code: 'BAD_REQUEST',
92
+ });
93
+ });
94
+
95
+ it('still rejects empty lists and empty names', async () => {
96
+ await expect(caller.generateSkillIcons({ names: [] })).rejects.toMatchObject({
97
+ code: 'BAD_REQUEST',
98
+ });
99
+ await expect(caller.generateSkillIcons({ names: [''] })).rejects.toMatchObject({
100
+ code: 'BAD_REQUEST',
101
+ });
102
+ });
103
+ });