@learncard/lca-api-service 1.5.1 → 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.
@@ -1,4 +1,15 @@
1
- import type { EscrowEnvelope } from '@learncard/sss-key-manager';
1
+ import type { EscrowEnvelope, SignedEscrowEnrollmentIntent } from '@learncard/sss-key-manager';
2
+ import type {
3
+ AuthorityRecord,
4
+ AuthorityHoldReceipt,
5
+ AuthorityMutationResult,
6
+ AuthorityStateResponse,
7
+ } from './authorityContract';
8
+ export interface ApplyEnrollmentInput {
9
+ signedIntent: SignedEscrowEnrollmentIntent;
10
+ sourceEnvelope: EscrowEnvelope | null;
11
+ targetEnvelope: EscrowEnvelope | null;
12
+ }
2
13
  // Hold records are independent of host Mongo status and deadlines.
3
14
 
4
15
  export interface EnclaveAttestation {
@@ -41,11 +52,25 @@ export interface EnclaveCreateHoldInput {
41
52
  clientEphemeralPublicKey: string;
42
53
  }
43
54
  export interface CancelHoldRequest {
55
+ requestId?: string;
44
56
  envelope: EscrowEnvelope;
45
57
  hold: EscrowHoldRecord;
46
58
  clientEphemeralPublicKey: string;
47
59
  expectedDid: string;
48
60
  }
61
+ /** P9.1 (attack C): `code` is the one-time code itself (compared against a
62
+ * stored hash enclave-side), never a proof — unlike `ReleaseRequest.pinProof`. */
63
+ export interface ConfirmHoldRequest extends CancelHoldRequest {
64
+ code: string;
65
+ }
66
+ export type ResendCodeRequest = CancelHoldRequest;
67
+ /** P9.1 (attack C): system-initiated (lca-api's hourly job) — no envelope or
68
+ * signed hold, since there is no user action to authenticate. */
69
+ export interface SendReleaseWarningRequest {
70
+ requestId?: string;
71
+ holdId: string;
72
+ expectedDid: string;
73
+ }
49
74
  export interface VerifyEscrowBlobInput {
50
75
  envelope: EscrowEnvelope;
51
76
  expectedDid: string;
@@ -60,6 +85,7 @@ export interface ReleaseRequest extends CancelHoldRequest {
60
85
  }
61
86
  export interface ReleaseResult {
62
87
  sealed: EscrowEnvelope;
88
+ receipt?: AuthorityRecord;
63
89
  }
64
90
  export interface CarryPinVerifierInput {
65
91
  sourceEnvelope: EscrowEnvelope;
@@ -80,6 +106,12 @@ export interface RewrapEscrowBlobInput {
80
106
  sourceEnrollmentEpoch: number;
81
107
  }
82
108
  export interface EscrowEnclave {
109
+ /** Remote authority only; software mode must not impersonate signed authority. */
110
+ getEnrollmentState?(input: {
111
+ expectedDid: string;
112
+ nonce: string;
113
+ }): Promise<AuthorityStateResponse>;
114
+ applyEnrollmentIntent?(input: ApplyEnrollmentInput): Promise<AuthorityMutationResult>;
83
115
  /** `nonce` binds a nitro attestation to one client-generated challenge (64 hex chars,
84
116
  * decoded to bytes); the software backend has no freshness story and ignores it. */
85
117
  getAttestation(nonce?: Uint8Array): Promise<EnclaveAttestation>;
@@ -91,10 +123,26 @@ export interface EscrowEnclave {
91
123
  * `EscrowBlobError`) unless the envelope's own keyId is a recognised
92
124
  * previous key — never the current key (nothing to migrate) or an
93
125
  * unknown one. */
94
- rewrapEscrowBlob(input: RewrapEscrowBlobInput): Promise<{ envelope: EscrowEnvelope }>;
126
+ rewrapEscrowBlob(
127
+ input: RewrapEscrowBlobInput
128
+ ): Promise<{ envelope: EscrowEnvelope; authority?: AuthorityMutationResult }>;
95
129
  releaseEscrow(input: ReleaseRequest): Promise<ReleaseResult>;
96
130
  createHold(input: EnclaveCreateHoldInput): Promise<{ holdRecord: EscrowHoldRecord }>;
97
- cancelHold(input: CancelHoldRequest): Promise<void>;
131
+ cancelHold(input: CancelHoldRequest): Promise<void | { receipt: AuthorityRecord }>;
132
+ /** P9.1 (attack C): authority (nitro) mode only — software mode never
133
+ * produces an awaiting-confirmation hold, so it has nothing to confirm.
134
+ * `confirmedAtMs` is the enclave's authoritative confirmation time
135
+ * (present on both a fresh confirm and an idempotent retry); callers
136
+ * must use it instead of a locally-observed clock to compute deadlines. */
137
+ confirmHold?(
138
+ input: ConfirmHoldRequest
139
+ ): Promise<{ receipt: AuthorityHoldReceipt; confirmedAtMs: number }>;
140
+ resendCode?(input: ResendCodeRequest): Promise<{ receipt: AuthorityRecord }>;
141
+ /** `releaseWarningSentAtMs` is the enclave's authoritative warning-sent
142
+ * time, present on both a fresh send and an idempotent retry. */
143
+ sendReleaseWarning?(
144
+ input: SendReleaseWarningRequest
145
+ ): Promise<{ receipt: AuthorityHoldReceipt; releaseWarningSentAtMs: number }>;
98
146
  }
99
147
  export class EscrowPolicyError extends Error {
100
148
  constructor() {
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'crypto';
2
- import { beforeAll, afterAll, afterEach, describe, it, expect } from 'vitest';
2
+ import { beforeAll, afterAll, afterEach, beforeEach, describe, it, expect } from 'vitest';
3
3
  import { client } from '@mongo';
4
4
  import {
5
5
  createUserKeysIndexes,
@@ -14,10 +14,14 @@ import {
14
14
  generateEscrowResumeToken,
15
15
  hashEscrowResumeToken,
16
16
  recordEscrowHoldNotification,
17
+ computeEffectiveReleaseAfter,
18
+ ESCROW_RELEASE_TIME_MARGIN_MS,
19
+ ESCROW_RELEASE_WARNING_GAP_MS,
17
20
  type AuthProviderMapping,
18
21
  } from '@models';
19
22
  import {
20
23
  __setEscrowEnclaveForTests,
24
+ getEscrowEnclave,
21
25
  notifyEscrowHoldEvent,
22
26
  } from '../../src/services/escrow-enclave';
23
27
  import { escrowCancelTokenMatches, hashEscrowCancelToken } from '@helpers/escrowCancelToken';
@@ -91,6 +95,12 @@ beforeAll(async () => {
91
95
  await createEscrowHoldsIndexes();
92
96
  });
93
97
 
98
+ // The reminder job scans every due hold (including overdue catch-up), so holds
99
+ // left behind by other spec files sharing this Mongo would be picked up.
100
+ beforeEach(async () => {
101
+ await getEscrowHoldsCollection().deleteMany({});
102
+ });
103
+
94
104
  afterEach(async () => {
95
105
  const ids = seededProviderIds.splice(0);
96
106
  if (ids.length === 0) return;
@@ -114,10 +124,11 @@ describe('runEscrowHoldReminders', () => {
114
124
  );
115
125
  for (const hold of holds) await cancelEscrowHold(hold._id, 'did');
116
126
  // These legacy test records lack ciphertext: fail closed, but still rotate
117
- // the bounded queue rather than retrying the same first three forever.
127
+ // the bounded queue rather than retrying the same first ones forever. The
128
+ // default 15s enclave timeout fits two calls in the job's 30s enclave budget.
118
129
  await runEscrowHoldReminders({ now, limit: 200 });
119
130
  const firstRun = await Promise.all(holds.map(hold => findEscrowHoldById(hold._id)));
120
- expect(firstRun.map(hold => hold?.enclaveCancelAttempts).sort()).toEqual([0, 1, 1, 1]);
131
+ expect(firstRun.map(hold => hold?.enclaveCancelAttempts).sort()).toEqual([0, 0, 1, 1]);
121
132
  const unattempted = firstRun.find(hold => hold?.enclaveCancelAttempts === 0)!;
122
133
  await runEscrowHoldReminders({ now, limit: 1 });
123
134
  expect((await findEscrowHoldById(unattempted._id))?.enclaveCancelAttempts).toBe(1);
@@ -153,6 +164,187 @@ describe('runEscrowHoldReminders', () => {
153
164
  expect(attempts[1]).toBe(reminder._id);
154
165
  });
155
166
 
167
+ it('calls the enclave sendReleaseWarning instead of its own reminder notification for an authority-mode hold', async () => {
168
+ const now = new Date();
169
+ const hold = await seed(new Date(now.getTime() + HOUR));
170
+ await getEscrowHoldsCollection().updateOne(
171
+ { _id: hold._id },
172
+ { $set: { requiresConfirmation: true, confirmedAt: now } }
173
+ );
174
+ const calls: { holdId: string; expectedDid: string }[] = [];
175
+ // This file has no per-test enclave reset (unlike keys-escrow.spec.ts's
176
+ // beforeEach): Object.assign mutates the shared software singleton in
177
+ // place, so it must be cleaned up or it leaks into every later test.
178
+ const software = getEscrowEnclave() as unknown as Record<string, unknown>;
179
+ software.sendReleaseWarning = async (params: { holdId: string; expectedDid: string }) => {
180
+ calls.push(params);
181
+ return { success: true as const };
182
+ };
183
+ try {
184
+ const notified: string[] = [];
185
+ const notify: typeof notifyEscrowHoldEvent = async event => {
186
+ notified.push(event.hold._id);
187
+ return true;
188
+ };
189
+ const result = await runEscrowHoldReminders({ now, notify });
190
+ expect(result.reminded).toBe(1);
191
+ expect(calls).toEqual([
192
+ { holdId: hold.holdRecord.hold.holdId, expectedDid: hold.primaryDid },
193
+ ]);
194
+ expect(notified).toHaveLength(0);
195
+ const stored = await findEscrowHoldById(hold._id);
196
+ // P2-a: a successful enclave warning is now acknowledged in Mongo
197
+ // (previously left empty forever, causing a re-send every run).
198
+ expect(stored?.notifications.map(entry => entry.kind)).toEqual(['reminder']);
199
+ } finally {
200
+ delete software.sendReleaseWarning;
201
+ }
202
+ });
203
+
204
+ it('sends the release warning exactly once across consecutive hourly runs (P2-a)', async () => {
205
+ const now = new Date();
206
+ const hold = await seed(new Date(now.getTime() + HOUR));
207
+ await getEscrowHoldsCollection().updateOne(
208
+ { _id: hold._id },
209
+ { $set: { requiresConfirmation: true, confirmedAt: now } }
210
+ );
211
+ const calls: { holdId: string; expectedDid: string }[] = [];
212
+ const software = getEscrowEnclave() as unknown as Record<string, unknown>;
213
+ software.sendReleaseWarning = async (params: { holdId: string; expectedDid: string }) => {
214
+ calls.push(params);
215
+ return { success: true as const };
216
+ };
217
+ try {
218
+ const first = await runEscrowHoldReminders({ now });
219
+ const secondRun = await runEscrowHoldReminders({ now: new Date(now.getTime() + HOUR) });
220
+ expect(first.reminded).toBe(1);
221
+ expect(secondRun.reminded).toBe(0);
222
+ expect(calls).toHaveLength(1);
223
+ expect(
224
+ (await findEscrowHoldById(hold._id))?.notifications.map(entry => entry.kind)
225
+ ).toEqual(['reminder']);
226
+ } finally {
227
+ delete software.sendReleaseWarning;
228
+ }
229
+ });
230
+
231
+ it('a lost acknowledgment write is safe to retry and eventually stops re-calling the enclave (P2-a)', async () => {
232
+ const now = new Date();
233
+ const hold = await seed(new Date(now.getTime() + HOUR));
234
+ await getEscrowHoldsCollection().updateOne(
235
+ { _id: hold._id },
236
+ { $set: { requiresConfirmation: true, confirmedAt: now } }
237
+ );
238
+ const calls: { holdId: string; expectedDid: string }[] = [];
239
+ const software = getEscrowEnclave() as unknown as Record<string, unknown>;
240
+ software.sendReleaseWarning = async (params: { holdId: string; expectedDid: string }) => {
241
+ calls.push(params);
242
+ return {
243
+ receipt: {
244
+ version: 1 as const,
245
+ kind: calls.length === 1 ? ('commit' as const) : ('snapshot' as const),
246
+ keyId: 'test-key',
247
+ measurement: Array.from({ length: 32 }, () => 0),
248
+ payload:
249
+ calls.length === 1
250
+ ? 'AQ=='
251
+ : Buffer.from(
252
+ JSON.stringify([
253
+ 'learncard-authority-hold-snapshot-v2',
254
+ params.holdId,
255
+ Array.from({ length: 32 }, () => 7),
256
+ 'pending',
257
+ now.getTime(),
258
+ now.getTime(),
259
+ 'g1',
260
+ ])
261
+ ).toString('base64'),
262
+ signature: Buffer.alloc(64).toString('base64'),
263
+ },
264
+ releaseWarningSentAtMs: now.getTime(),
265
+ };
266
+ };
267
+ try {
268
+ const first = await runEscrowHoldReminders({ now });
269
+ expect(first.reminded).toBe(1);
270
+ expect(calls).toHaveLength(1);
271
+ // Simulate the acknowledgment write being lost (e.g. a crash right
272
+ // after a successful enclave call) rather than mocking Mongo, so
273
+ // this exercises the job's own catch-up path: a real enclave would
274
+ // not re-send or move its timestamp on this retry (covered by the
275
+ // Rust-side idempotence test), but lca-api must not get stuck
276
+ // either, so it retries until its own acknowledgment lands.
277
+ await getEscrowHoldsCollection().updateOne(
278
+ { _id: hold._id },
279
+ { $set: { notifications: [], releaseAfter: hold.releaseAfter } }
280
+ );
281
+ const second = await runEscrowHoldReminders({ now });
282
+ expect(second.reminded).toBe(1);
283
+ expect(calls).toHaveLength(2);
284
+ expect(
285
+ (await findEscrowHoldById(hold._id))?.notifications.map(entry => entry.kind)
286
+ ).toEqual(['reminder']);
287
+ const third = await runEscrowHoldReminders({ now });
288
+ expect(third.reminded).toBe(0);
289
+ expect(calls).toHaveLength(2);
290
+ } finally {
291
+ delete software.sendReleaseWarning;
292
+ }
293
+ });
294
+
295
+ // [B] A catch-up warning sent well after the 7-day window elapsed (an
296
+ // outage, or the job simply not running for a while) must advertise an
297
+ // availability time derived from the LATER gate — the enclave's own
298
+ // `release_warning_sent_hi_ms + 24h` — not the hold's original,
299
+ // long-since-passed creation/confirmation-based estimate, which would
300
+ // otherwise keep telling the client it's ready when the enclave would
301
+ // still refuse release.
302
+ it('[B] an overdue warning advances the stored releaseAfter to the warning-based availability time', async () => {
303
+ const now = new Date();
304
+ // Confirmed 10 days ago: its own 7-day-plus-margin gate has long
305
+ // since passed, so only the warning-based gate can be the later one.
306
+ const confirmedAt = new Date(now.getTime() - 10 * 24 * HOUR);
307
+ const hold = await seed(new Date(confirmedAt.getTime() + HOUR));
308
+ await getEscrowHoldsCollection().updateOne(
309
+ { _id: hold._id },
310
+ { $set: { requiresConfirmation: true, confirmedAt } }
311
+ );
312
+ const releaseWarningSentAtMs = now.getTime();
313
+ const software = getEscrowEnclave() as unknown as Record<string, unknown>;
314
+ software.sendReleaseWarning = async () => ({
315
+ receipt: {
316
+ version: 1 as const,
317
+ kind: 'commit' as const,
318
+ keyId: 'test-key',
319
+ measurement: Array.from({ length: 32 }, () => 0),
320
+ payload: 'AQ==',
321
+ signature: Buffer.alloc(64).toString('base64'),
322
+ },
323
+ releaseWarningSentAtMs,
324
+ });
325
+ try {
326
+ const result = await runEscrowHoldReminders({ now });
327
+ expect(result.reminded).toBe(1);
328
+ const stored = await findEscrowHoldById(hold._id);
329
+ const expected = computeEffectiveReleaseAfter({
330
+ confirmedAtMs: confirmedAt.getTime(),
331
+ holdDurationMs: hold.holdRecord.holdDurationMs,
332
+ releaseWarningSentAtMs,
333
+ });
334
+ // Sanity: the warning-based gate really is the later one here.
335
+ expect(expected.getTime()).toBe(
336
+ releaseWarningSentAtMs +
337
+ ESCROW_RELEASE_WARNING_GAP_MS +
338
+ ESCROW_RELEASE_TIME_MARGIN_MS
339
+ );
340
+ expect(stored?.releaseAfter.getTime()).toBe(expected.getTime());
341
+ // And specifically later than the original (long-passed) estimate.
342
+ expect(stored!.releaseAfter.getTime()).toBeGreaterThan(hold.releaseAfter.getTime());
343
+ } finally {
344
+ delete software.sendReleaseWarning;
345
+ }
346
+ });
347
+
156
348
  it('retries an undelivered start notification with a cancel link', async () => {
157
349
  const now = new Date();
158
350
  const hold = await seed(new Date(now.getTime() + 7 * 24 * HOUR));