@learncard/lca-api-service 1.4.0 → 1.5.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 (188) hide show
  1. package/.env.example +73 -0
  2. package/AGENTS.md +106 -10
  3. package/CHANGELOG.md +43 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/config/config.dev.json +23 -0
  6. package/config/config.example.json +22 -0
  7. package/config/config.json +1 -0
  8. package/config/config.production.json +12 -0
  9. package/config/config.scouts.dev.json +11 -0
  10. package/config/config.scouts.production.json +11 -0
  11. package/dist/app.d.ts +67 -3
  12. package/dist/cache/getDel.d.ts +2 -0
  13. package/dist/cache/getDel.test.d.ts +1 -0
  14. package/dist/cache/login-tickets.d.ts +12 -0
  15. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  16. package/dist/config/cacheEnvironment.d.ts +16 -0
  17. package/dist/config/environment.d.ts +101 -0
  18. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  19. package/dist/config/escrowDeployment.test.d.ts +1 -0
  20. package/dist/config/oidcEnvironment.d.ts +74 -0
  21. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  22. package/dist/config/stageConfig.d.ts +62 -0
  23. package/dist/config/stageConfig.test.d.ts +1 -0
  24. package/dist/didkit_wasm_bg.wasm +0 -0
  25. package/dist/docker-entry.d.ts +1 -1
  26. package/dist/helpers/auth.helpers.d.ts +1 -1
  27. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  28. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  29. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  30. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  31. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  32. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  33. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  34. package/dist/helpers/oidc.helpers.d.ts +47 -0
  35. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  36. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  37. package/dist/helpers/social-token.helpers.d.ts +19 -0
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +2 -2
  40. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  41. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  42. package/dist/models/AuthSubject.d.ts +20 -0
  43. package/dist/models/EscrowHold.d.ts +79 -1
  44. package/dist/models/Notification.d.ts +5 -0
  45. package/dist/models/UserKey.d.ts +11 -1
  46. package/dist/models/index.d.ts +1 -0
  47. package/dist/oidc.d.ts +5 -0
  48. package/dist/routes/auth.d.ts +31 -0
  49. package/dist/routes/escrow.d.ts +18 -4
  50. package/dist/routes/keys.d.ts +1 -0
  51. package/dist/routes/notifications.d.ts +5 -0
  52. package/dist/routes/signing-authority.d.ts +14 -0
  53. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  54. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  55. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  56. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  57. package/dist/services/escrow-enclave/index.d.ts +16 -0
  58. package/dist/services/escrow-enclave/notifications.d.ts +19 -4
  59. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  60. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  61. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  62. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  63. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  64. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  65. package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
  66. package/dist/services/escrow-enclave/types.d.ts +52 -8
  67. package/dist/types/notifications.d.ts +5 -0
  68. package/esbuildPlugins.cjs +1 -0
  69. package/lambda.ts +27 -117
  70. package/lambdaApp.ts +146 -0
  71. package/oidcLambda.test.ts +38 -0
  72. package/oidcLambda.ts +18 -0
  73. package/oidcLambdaApp.ts +11 -0
  74. package/package.json +23 -13
  75. package/scripts/README.md +88 -0
  76. package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
  77. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  78. package/scripts/escrow-blob-mode-report.ts +465 -0
  79. package/scripts/keycloak-admin.ts +108 -0
  80. package/scripts/keycloak-provider-links.ts +147 -0
  81. package/scripts/print-seed-rollout-flags.ts +21 -0
  82. package/scripts/provision-keycloak-users.ts +419 -0
  83. package/seedMigrationApp.test.ts +145 -0
  84. package/seedMigrationApp.ts +50 -0
  85. package/seedMigrationLambda.test.ts +30 -135
  86. package/seedMigrationLambda.ts +15 -46
  87. package/serverless-local.yml +25 -1
  88. package/serverless.function-env.cjs +58 -0
  89. package/serverless.yml +172 -37
  90. package/src/accesslayer/notifications/index.ts +9 -2
  91. package/src/accesslayer/signing-authority/index.ts +4 -1
  92. package/src/app.ts +2 -0
  93. package/src/cache/getDel.test.ts +34 -0
  94. package/src/cache/getDel.ts +32 -0
  95. package/src/cache/index.ts +1 -1
  96. package/src/cache/login-tickets.ts +26 -0
  97. package/src/config/applyDockerStageConfig.ts +6 -0
  98. package/src/config/cacheEnvironment.ts +34 -0
  99. package/src/config/environment.test.ts +45 -2
  100. package/src/config/environment.ts +90 -23
  101. package/src/config/escrowBundleMode.test.ts +52 -0
  102. package/src/config/escrowDeployment.test.ts +114 -0
  103. package/src/config/oidcEnvironment.ts +59 -0
  104. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  105. package/src/config/stageConfig.test.ts +26 -0
  106. package/src/config/stageConfig.ts +24 -0
  107. package/src/docker-entry.ts +9 -0
  108. package/src/helpers/auth.helpers.ts +68 -30
  109. package/src/helpers/escrowCancelLink.test.ts +60 -0
  110. package/src/helpers/escrowCancelLink.ts +38 -0
  111. package/src/helpers/escrowCancelToken.test.ts +105 -0
  112. package/src/helpers/escrowCancelToken.ts +47 -0
  113. package/src/helpers/keycloak.helpers.ts +120 -0
  114. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  115. package/src/helpers/login-ticket.helpers.ts +91 -0
  116. package/src/helpers/oidc.helpers.ts +354 -0
  117. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  118. package/src/helpers/rate-limit.helpers.ts +73 -0
  119. package/src/helpers/social-token.helpers.ts +144 -0
  120. package/src/index.ts +1 -0
  121. package/src/jobs/escrowBlobRewrap.ts +121 -0
  122. package/src/jobs/escrowHoldReminders.ts +177 -0
  123. package/src/models/AuthSubject.ts +147 -0
  124. package/src/models/EscrowHold.ts +321 -5
  125. package/src/models/UserKey.ts +91 -6
  126. package/src/models/index.ts +1 -0
  127. package/src/oidc.ts +371 -0
  128. package/src/routes/auth.ts +173 -0
  129. package/src/routes/escrow.ts +281 -34
  130. package/src/routes/keys.ts +5 -0
  131. package/src/routes/signing-authority.ts +39 -1
  132. package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
  133. package/src/services/escrow-enclave/README.md +81 -10
  134. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  135. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  136. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  137. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  138. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  139. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  140. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  141. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  142. package/src/services/escrow-enclave/cancellation.ts +59 -0
  143. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  144. package/src/services/escrow-enclave/index.ts +145 -7
  145. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  146. package/src/services/escrow-enclave/notifications.ts +340 -6
  147. package/src/services/escrow-enclave/production.test.ts +34 -0
  148. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  149. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  150. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  151. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  152. package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
  153. package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
  154. package/src/services/escrow-enclave/types.ts +63 -17
  155. package/test/auth-tickets.spec.ts +480 -0
  156. package/test/helpers/keycloak-broker.ts +181 -0
  157. package/test/helpers/live-mongo.ts +9 -0
  158. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  159. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  160. package/test/keycloak-admin.spec.ts +58 -0
  161. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  162. package/test/keycloak-migration.integration.spec.ts +266 -0
  163. package/test/keycloak-provider-links.spec.ts +201 -0
  164. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  165. package/test/keycloak-verify.integration.spec.ts +100 -0
  166. package/test/keycloak-verify.spec.ts +307 -0
  167. package/test/keys-escrow.spec.ts +963 -24
  168. package/test/models/authSubjectIndexes.spec.ts +132 -0
  169. package/test/models/escrow.spec.ts +375 -13
  170. package/test/notifications.spec.ts +4 -0
  171. package/test/oidc-e2e/broker.spec.ts +116 -0
  172. package/test/oidc-e2e/compose.yaml +33 -0
  173. package/test/oidc-e2e/fixtures.ts +245 -0
  174. package/test/oidc-e2e/lock.test.mjs +73 -0
  175. package/test/oidc-e2e/playwright.config.ts +21 -0
  176. package/test/oidc-e2e/run.mjs +312 -0
  177. package/test/oidc-e2e/tsconfig.json +10 -0
  178. package/test/oidc-e2e/types.ts +21 -0
  179. package/test/oidc.integration.spec.ts +54 -0
  180. package/test/oidc.spec.ts +697 -0
  181. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  182. package/test/provision-keycloak-users.spec.ts +299 -0
  183. package/test/signing-authority.spec.ts +50 -0
  184. package/tsconfig.build.json +1 -1
  185. package/tsconfig.json +1 -0
  186. package/vitest.config.ts +24 -1
  187. package/vitest.integration.config.ts +20 -3
  188. package/serverless.escrow-env.cjs +0 -20
@@ -0,0 +1,132 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const collection = vi.hoisted(() => ({
4
+ indexes: vi.fn(),
5
+ dropIndex: vi.fn(async () => undefined),
6
+ createIndex: vi.fn(async () => 'index'),
7
+ findOneAndUpdate: vi.fn(),
8
+ }));
9
+
10
+ vi.mock('@mongo', () => ({ default: { collection: () => collection } }));
11
+
12
+ const createAuthSubjectIndexes = async (): Promise<void> => {
13
+ const model = await import('../../src/models/AuthSubject');
14
+ await model.createAuthSubjectIndexes();
15
+ };
16
+
17
+ beforeEach(() => {
18
+ vi.resetModules();
19
+ collection.indexes.mockReset().mockResolvedValue([{ name: '_id_', key: { _id: 1 } }]);
20
+ collection.dropIndex.mockReset().mockResolvedValue(undefined);
21
+ collection.createIndex.mockReset().mockResolvedValue('index');
22
+ collection.findOneAndUpdate.mockReset().mockResolvedValue({ subject: 'subject' });
23
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
24
+ });
25
+ afterEach(() => vi.restoreAllMocks());
26
+
27
+ describe('createAuthSubjectIndexes', () => {
28
+ it('indexes subject without uniqueness so linked sign-in methods can share it', async () => {
29
+ await createAuthSubjectIndexes();
30
+
31
+ expect(collection.createIndex).toHaveBeenCalledWith({ subject: 1 });
32
+ expect(collection.createIndex).not.toHaveBeenCalledWith(
33
+ { subject: 1 },
34
+ expect.objectContaining({ unique: true })
35
+ );
36
+ });
37
+
38
+ it('keeps identityKey unique', async () => {
39
+ await createAuthSubjectIndexes();
40
+
41
+ expect(collection.createIndex).toHaveBeenCalledWith({ identityKey: 1 }, { unique: true });
42
+ });
43
+
44
+ it('drops a pre-existing unique subject index before recreating it', async () => {
45
+ collection.indexes.mockResolvedValue([
46
+ { name: '_id_', key: { _id: 1 } },
47
+ { name: 'subject_1', key: { subject: 1 }, unique: true },
48
+ ]);
49
+
50
+ await createAuthSubjectIndexes();
51
+
52
+ expect(collection.dropIndex).toHaveBeenCalledWith('subject_1');
53
+ expect(collection.dropIndex.mock.invocationCallOrder[0]).toBeLessThan(
54
+ collection.createIndex.mock.invocationCallOrder[0] ?? Infinity
55
+ );
56
+ });
57
+
58
+ it('leaves a non-unique subject index alone', async () => {
59
+ collection.indexes.mockResolvedValue([{ name: 'subject_1', key: { subject: 1 } }]);
60
+
61
+ await createAuthSubjectIndexes();
62
+
63
+ expect(collection.dropIndex).not.toHaveBeenCalled();
64
+ });
65
+
66
+ it('treats a missing collection as having no indexes', async () => {
67
+ collection.indexes.mockRejectedValue(Object.assign(new Error('ns'), { code: 26 }));
68
+
69
+ await createAuthSubjectIndexes();
70
+
71
+ expect(collection.createIndex).toHaveBeenCalledTimes(2);
72
+ });
73
+
74
+ it('tolerates the legacy index disappearing concurrently', async () => {
75
+ collection.indexes.mockResolvedValue([
76
+ { name: 'subject_1', key: { subject: 1 }, unique: true },
77
+ ]);
78
+ collection.dropIndex.mockRejectedValueOnce(Object.assign(new Error('gone'), { code: 27 }));
79
+
80
+ await expect(createAuthSubjectIndexes()).resolves.toBeUndefined();
81
+ });
82
+ });
83
+
84
+ describe('AuthSubject index readiness', () => {
85
+ it.each(['subject', 'identityKey'])(
86
+ 'rejects all waiters when %s indexing fails, then retries',
87
+ async failedIndex => {
88
+ const { createIndex, findOneAndUpdate } = collection;
89
+ const { ensureAuthSubjectIndexes, getOrCreateAuthSubject } =
90
+ await import('../../src/models/AuthSubject');
91
+ const failure = new Error('index unavailable');
92
+ let rejectIndex!: (error: Error) => void;
93
+ const pendingIndex = new Promise<string>((_resolve, reject) => {
94
+ rejectIndex = reject;
95
+ });
96
+ if (failedIndex === 'identityKey') createIndex.mockResolvedValueOnce('subject');
97
+ createIndex.mockImplementationOnce(() => pendingIndex);
98
+
99
+ const ready = ensureAuthSubjectIndexes();
100
+ expect(ensureAuthSubjectIndexes()).toBe(ready);
101
+ const waiters = Promise.allSettled([
102
+ ready,
103
+ getOrCreateAuthSubject('email:test@example.com', { emailVerified: true }),
104
+ getOrCreateAuthSubject('email:test@example.com', { emailVerified: true }),
105
+ ]);
106
+ rejectIndex(failure);
107
+ expect(await waiters).toEqual(
108
+ Array.from({ length: 3 }, () => ({ status: 'rejected', reason: failure }))
109
+ );
110
+ expect(findOneAndUpdate).not.toHaveBeenCalled();
111
+
112
+ createIndex.mockClear();
113
+ const completed: string[] = [];
114
+ createIndex.mockImplementation(async (key: Record<string, number>) => {
115
+ completed.push(Object.keys(key)[0]!);
116
+ return 'index';
117
+ });
118
+ findOneAndUpdate.mockImplementation(async () => {
119
+ expect(completed).toEqual(['subject', 'identityKey']);
120
+ return { subject: 'subject' };
121
+ });
122
+ await getOrCreateAuthSubject('email:test@example.com', { emailVerified: true });
123
+ expect(createIndex.mock.calls).toEqual([
124
+ [{ subject: 1 }],
125
+ [{ identityKey: 1 }, { unique: true }],
126
+ ]);
127
+ expect(findOneAndUpdate).toHaveBeenCalledOnce();
128
+ await ensureAuthSubjectIndexes();
129
+ expect(createIndex).toHaveBeenCalledTimes(2);
130
+ }
131
+ );
132
+ });
@@ -1,6 +1,10 @@
1
+ import { randomUUID } from 'crypto';
1
2
  import { beforeAll, afterAll, beforeEach, describe, it, expect } from 'vitest';
2
3
  import { client } from '@mongo';
4
+ import { EscrowEnvelopeValidator } from '@models';
5
+ import vectors from '../../../../../packages/sss-key-manager/src/__fixtures__/escrow-vectors.json';
3
6
  import {
7
+ type EscrowHold,
4
8
  createUserKeysIndexes,
5
9
  getUserKeysCollection,
6
10
  upsertUserKeyByAuthProvider,
@@ -13,32 +17,34 @@ import {
13
17
  getEscrowHoldsCollection,
14
18
  createEscrowHold,
15
19
  cancelEscrowHold,
20
+ cancelEscrowHoldByCancelToken,
16
21
  completeEscrowHold,
17
22
  expireStaleEscrowHolds,
18
23
  ESCROW_HOLD_STALE_WINDOW_MS,
19
24
  generateEscrowResumeToken,
20
25
  hashEscrowResumeToken,
21
26
  findPendingEscrowHoldByAuthProvider,
27
+ findEscrowHoldById,
28
+ findEscrowHoldsDueForReminder,
29
+ claimEscrowHoldForReminder,
30
+ recordEscrowHoldNotification,
31
+ rotateEscrowCancelToken,
22
32
  reserveEscrowPinAttempt,
23
33
  refundEscrowPinAttempt,
34
+ rewrapEscrowBlobByAuthProvider,
35
+ findUserKeysWithEscrowBlobKeyId,
24
36
  type EscrowBlob,
25
37
  type AuthProviderMapping,
26
38
  } from '@models';
27
39
 
28
40
  const provider: AuthProviderMapping = { type: 'firebase', id: 'escrow-model-test' };
29
- const blob: EscrowBlob = {
30
- envelope: {
31
- version: 1,
32
- algorithm: 'P-256-HKDF-SHA256-AES-256-GCM',
33
- keyId: 'test',
34
- ephemeralPublicKey: 'key',
35
- salt: 'salt',
36
- iv: 'iv',
37
- ciphertext: '$literal-data',
38
- },
39
- enclaveKeyId: 'test',
41
+ const firstVector = vectors.blobs[0];
42
+ if (!firstVector) throw new Error('escrow-vectors.json has no blobs');
43
+ const blob: Omit<EscrowBlob, 'enrollmentEpoch' | 'blobHash'> = {
44
+ envelope: EscrowEnvelopeValidator.parse(firstVector.envelope),
45
+ enclaveKeyId: firstVector.envelope.keyId,
40
46
  enclaveMode: 'software',
41
- measurements: {},
47
+ measurements: { pcr0: '$literal-data' },
42
48
  shareVersion: 1,
43
49
  createdAt: new Date(),
44
50
  };
@@ -47,6 +53,23 @@ const createHold = (
47
53
  releasePolicy: 'hold' | 'pin' = 'hold'
48
54
  ): ReturnType<typeof createEscrowHold> =>
49
55
  createEscrowHold({
56
+ holdRecord: {
57
+ hold: {
58
+ holdId: randomUUID(),
59
+ did: 'did:key:test',
60
+ shareVersion: 1,
61
+ blobHash: 'ab'.repeat(32),
62
+ enrollmentEpoch: 1,
63
+ releasePolicy: 'hold',
64
+ clientEphemeralPublicKey: 'public-key',
65
+ createdLo: 0,
66
+ createdHi: 0,
67
+ policyVersion: 1,
68
+ signature: 'test-signature',
69
+ },
70
+ holdDurationMs: 0,
71
+ ledgerSeq: 0,
72
+ },
50
73
  authProvider: provider,
51
74
  primaryDid: 'did:key:test',
52
75
  shareVersion: 1,
@@ -135,6 +158,62 @@ describe('escrow model invariants', () => {
135
158
  (await findUserKeyByAuthProvider(provider.type, provider.id))?.escrowPin?.failedAttempts
136
159
  ).toBe(1);
137
160
  });
161
+ it('validates required opaque hold records before insert', async () => {
162
+ const valid = await createHold();
163
+ expect(valid._id).toBe(valid.holdRecord.hold.holdId);
164
+ await cancelEscrowHold(valid._id, 'did');
165
+ const input = {
166
+ ...valid,
167
+ holdRecord: {
168
+ ...valid.holdRecord,
169
+ hold: { ...valid.holdRecord.hold, holdId: randomUUID(), signedExtension: 'keep' },
170
+ signedExtension: 'keep',
171
+ },
172
+ };
173
+ const opaque = await createEscrowHold(input);
174
+ expect(opaque.holdRecord).toEqual(input.holdRecord);
175
+ await cancelEscrowHold(opaque._id, 'did');
176
+ for (const holdRecord of [
177
+ undefined,
178
+ {},
179
+ { ...valid.holdRecord, ledgerSeq: 'invalid' },
180
+ { ...valid.holdRecord, hold: { ...valid.holdRecord.hold, enrollmentEpoch: 0 } },
181
+ ]) {
182
+ // Simulate untyped/legacy input at the runtime schema boundary.
183
+ await expect(
184
+ createEscrowHold({ ...input, holdRecord } as unknown as Parameters<
185
+ typeof createEscrowHold
186
+ >[0])
187
+ ).rejects.toThrow('Invalid escrow hold');
188
+ }
189
+ });
190
+ it('computes the Rust-order fixed blob hash and increments epochs atomically', async () => {
191
+ const first = await setEscrowBlobByAuthProvider(provider, blob, 1);
192
+ expect(first?.escrowBlob?.enrollmentEpoch).toBe(1);
193
+ expect(first?.escrowBlob?.blobHash).toBe(
194
+ '8090217ef11060bfa380c4a4b26d76a64811216176ecb60749652fabf3b09425'
195
+ );
196
+ const results = await Promise.all([
197
+ setEscrowBlobByAuthProvider(provider, blob, 1),
198
+ setEscrowBlobByAuthProvider(provider, blob, 1),
199
+ ]);
200
+ expect(results.map(result => result?.escrowBlob?.enrollmentEpoch).sort()).toEqual([2, 3]);
201
+ expect(results[0]?.escrowBlob?.measurements.pcr0).toBe('$literal-data');
202
+ await expect(
203
+ setEscrowBlobByAuthProvider(
204
+ provider,
205
+ { ...blob, envelope: { ...blob.envelope, ciphertext: '' } },
206
+ 1
207
+ )
208
+ ).rejects.toThrow('Invalid escrow payload');
209
+ expect(
210
+ await setEscrowBlobByAuthProvider(provider, { ...blob, enclaveKeyId: 'other' }, 1)
211
+ ).toBeNull();
212
+ expect(
213
+ (await findUserKeyByAuthProvider(provider.type, provider.id))?.escrowBlob
214
+ ?.enrollmentEpoch
215
+ ).toBe(3);
216
+ });
138
217
  it('atomically replaces escrow without duplicate methods and preserves literal values', async () => {
139
218
  await setEscrowBlobByAuthProvider(provider, blob, 1);
140
219
  const result = await setEscrowBlobByAuthProvider(provider, blob, 1);
@@ -144,11 +223,118 @@ describe('escrow model invariants', () => {
144
223
  confirmationStatus: 'confirmed',
145
224
  shareVersion: 1,
146
225
  });
147
- expect(result?.escrowBlob).toEqual(blob);
226
+ expect(result?.escrowBlob).toEqual({
227
+ ...blob,
228
+ enrollmentEpoch: 2,
229
+ blobHash: expect.stringMatching(/^[0-9a-f]{64}$/),
230
+ });
148
231
  expect(
149
232
  await setEscrowBlobByAuthProvider(provider, { ...blob, shareVersion: 2 }, 2)
150
233
  ).toBeNull();
151
234
  });
235
+ it('rewraps a previous-key blob onto the current key (P9.3): same epoch and version, escrowPin untouched', async () => {
236
+ const stored = await setEscrowBlobByAuthProvider(provider, blob, 1, {
237
+ salt: Buffer.alloc(16).toString('base64'),
238
+ });
239
+ const oldBlob = stored!.escrowBlob!;
240
+ const oldPin = stored!.escrowPin!;
241
+ const newEnvelope: EscrowBlob['envelope'] = {
242
+ ...blob.envelope,
243
+ keyId: 'current',
244
+ ciphertext: Buffer.alloc(32, 1).toString('base64'),
245
+ };
246
+ const rewrapped = await rewrapEscrowBlobByAuthProvider(
247
+ provider,
248
+ oldBlob,
249
+ oldPin,
250
+ newEnvelope,
251
+ {
252
+ enclaveKeyId: 'current',
253
+ enclaveMode: 'software',
254
+ measurements: {},
255
+ }
256
+ );
257
+ expect(rewrapped?.escrowBlob).toEqual({
258
+ envelope: newEnvelope,
259
+ enclaveKeyId: 'current',
260
+ enclaveMode: 'software',
261
+ measurements: {},
262
+ shareVersion: oldBlob.shareVersion,
263
+ enrollmentEpoch: oldBlob.enrollmentEpoch,
264
+ blobHash: expect.stringMatching(/^[0-9a-f]{64}$/),
265
+ createdAt: expect.any(Date),
266
+ });
267
+ expect(rewrapped?.escrowBlob?.blobHash).not.toBe(oldBlob.blobHash);
268
+ // escrowPin is byte-identical to what was read before the rewrap — the
269
+ // model never touches it, and the CAS filter proves it wasn't touched
270
+ // by anything else concurrently either.
271
+ expect(rewrapped?.escrowPin).toEqual(oldPin);
272
+ // Lost race: presenting the now-stale oldBlob/oldPin again is a no-op,
273
+ // not an error — a later job run should simply re-observe and retry.
274
+ expect(
275
+ await rewrapEscrowBlobByAuthProvider(provider, oldBlob, oldPin, newEnvelope, {
276
+ enclaveKeyId: 'current',
277
+ enclaveMode: 'software',
278
+ measurements: {},
279
+ })
280
+ ).toBeNull();
281
+ });
282
+ it('rewrapEscrowBlobByAuthProvider: concurrent runs never double-write; a mid-run re-enroll wins the race', async () => {
283
+ const stored = await setEscrowBlobByAuthProvider(provider, blob, 1, {
284
+ salt: Buffer.alloc(16).toString('base64'),
285
+ });
286
+ const oldBlob = stored!.escrowBlob!;
287
+ const oldPin = stored!.escrowPin!;
288
+ const attempt = (ciphertext: string): ReturnType<typeof rewrapEscrowBlobByAuthProvider> =>
289
+ rewrapEscrowBlobByAuthProvider(
290
+ provider,
291
+ oldBlob,
292
+ oldPin,
293
+ { ...blob.envelope, keyId: 'current', ciphertext },
294
+ { enclaveKeyId: 'current', enclaveMode: 'software', measurements: {} }
295
+ );
296
+ const [first, second] = await Promise.all([
297
+ attempt(Buffer.alloc(32, 2).toString('base64')),
298
+ attempt(Buffer.alloc(32, 3).toString('base64')),
299
+ ]);
300
+ expect([first, second].filter(result => result !== null)).toHaveLength(1);
301
+ // A concurrent re-enrollment (a NEW escrowBlob, different from oldBlob)
302
+ // between read and write also wins the race against a stale rewrap.
303
+ const reenrolled = await setEscrowBlobByAuthProvider(provider, blob, 1);
304
+ expect(reenrolled?.escrowBlob?.envelope.keyId).toBe(blob.enclaveKeyId);
305
+ expect(
306
+ await rewrapEscrowBlobByAuthProvider(
307
+ provider,
308
+ oldBlob,
309
+ oldPin,
310
+ {
311
+ ...blob.envelope,
312
+ keyId: 'current',
313
+ ciphertext: Buffer.alloc(32, 4).toString('base64'),
314
+ },
315
+ { enclaveKeyId: 'current', enclaveMode: 'software', measurements: {} }
316
+ )
317
+ ).toBeNull();
318
+ });
319
+ it('findUserKeysWithEscrowBlobKeyId: matches given keyIds, excludes opted-out accounts', async () => {
320
+ const stored = await setEscrowBlobByAuthProvider(provider, blob, 1);
321
+ expect(
322
+ (await findUserKeysWithEscrowBlobKeyId([blob.enclaveKeyId], 10)).map(
323
+ u => u.authProviders[0]?.id
324
+ )
325
+ ).toContain(provider.id);
326
+ expect(await findUserKeysWithEscrowBlobKeyId(['nonexistent-key'], 10)).toEqual([]);
327
+ await clearEscrowByAuthProvider(provider, { optOut: true });
328
+ await getUserKeysCollection().updateOne(
329
+ { 'authProviders.id': provider.id },
330
+ { $set: { escrowBlob: stored!.escrowBlob! } }
331
+ );
332
+ expect(
333
+ (await findUserKeysWithEscrowBlobKeyId([blob.enclaveKeyId], 10)).some(
334
+ u => u.authProviders[0]?.id === provider.id
335
+ )
336
+ ).toBe(false);
337
+ });
152
338
  it('clears with and without opting out; explicit opt-in permits enrollment again', async () => {
153
339
  await setEscrowBlobByAuthProvider(provider, blob, 1);
154
340
  const cleared = await clearEscrowByAuthProvider(provider, { optOut: false });
@@ -207,4 +393,180 @@ describe('escrow model invariants', () => {
207
393
  expect(hashEscrowResumeToken(token)).toMatch(/^[0-9a-f]{64}$/);
208
394
  expect(hashEscrowResumeToken(token)).not.toBe(token);
209
395
  });
396
+ it('cancels via link token exactly once, rejecting wrong, reused, or hash-less holds', async () => {
397
+ const cancelTokenHash = 'a'.repeat(64);
398
+ const hold = await createEscrowHold({
399
+ holdRecord: {
400
+ hold: {
401
+ holdId: randomUUID(),
402
+ did: 'did:key:test',
403
+ shareVersion: 1,
404
+ blobHash: 'ab'.repeat(32),
405
+ enrollmentEpoch: 1,
406
+ releasePolicy: 'hold',
407
+ clientEphemeralPublicKey: 'public-key',
408
+ createdLo: 0,
409
+ createdHi: 0,
410
+ policyVersion: 1,
411
+ signature: 'test-signature',
412
+ },
413
+ holdDurationMs: 0,
414
+ ledgerSeq: 0,
415
+ },
416
+ authProvider: provider,
417
+ primaryDid: 'did:key:test',
418
+ shareVersion: 1,
419
+ identityProofType: 'auth-token',
420
+ requestedAt: new Date(),
421
+ releaseAfter: new Date(),
422
+ releasePolicy: 'hold',
423
+ clientEphemeralPublicKey: 'public-key',
424
+ resumeTokenHash: hashEscrowResumeToken(generateEscrowResumeToken()),
425
+ cancelTokenHash,
426
+ });
427
+ expect(await cancelEscrowHoldByCancelToken(hold._id, 'b'.repeat(64))).toBeNull();
428
+ const cancelled = await cancelEscrowHoldByCancelToken(hold._id, cancelTokenHash);
429
+ expect(cancelled).toMatchObject({ status: 'cancelled', cancelledBy: 'link' });
430
+ expect(cancelled?.cancelTokenUsedAt).toBeInstanceOf(Date);
431
+ expect(cancelled?.cancelReason).toBeUndefined();
432
+ // Burned: the hash still matches, but cancelTokenUsedAt no longer $exists-fails the filter.
433
+ expect(await cancelEscrowHoldByCancelToken(hold._id, cancelTokenHash)).toBeNull();
434
+ // A pending hold with no cancelTokenHash at all never matches an exact-hash filter.
435
+ const noToken = await createHold();
436
+ expect(await cancelEscrowHoldByCancelToken(noToken._id, cancelTokenHash)).toBeNull();
437
+ });
438
+ it('records notifications by appending to the array without disturbing other fields', async () => {
439
+ const hold = await createHold();
440
+ await recordEscrowHoldNotification(hold._id, 'started');
441
+ let stored = await findEscrowHoldById(hold._id);
442
+ expect(stored?.notifications).toHaveLength(1);
443
+ expect(stored?.notifications[0]).toMatchObject({ kind: 'started' });
444
+ expect(stored?.notifications[0]?.sentAt).toBeInstanceOf(Date);
445
+ expect(stored?.status).toBe('pending');
446
+ await recordEscrowHoldNotification(hold._id, 'cancelled');
447
+ stored = await findEscrowHoldById(hold._id);
448
+ expect(stored?.notifications.map(entry => entry.kind)).toEqual(['started', 'cancelled']);
449
+ // A missing hold id is a no-op, never a throw.
450
+ await expect(
451
+ recordEscrowHoldNotification('does-not-exist', 'completed')
452
+ ).resolves.toBeUndefined();
453
+ });
454
+ it('rotates the cancel token only for pending holds, leaving terminal holds untouched', async () => {
455
+ const originalHash = 'a'.repeat(64);
456
+ const hold = await createEscrowHold({
457
+ holdRecord: {
458
+ hold: {
459
+ holdId: randomUUID(),
460
+ did: 'did:key:test',
461
+ shareVersion: 1,
462
+ blobHash: 'ab'.repeat(32),
463
+ enrollmentEpoch: 1,
464
+ releasePolicy: 'hold',
465
+ clientEphemeralPublicKey: 'public-key',
466
+ createdLo: 0,
467
+ createdHi: 0,
468
+ policyVersion: 1,
469
+ signature: 'test-signature',
470
+ },
471
+ holdDurationMs: 0,
472
+ ledgerSeq: 0,
473
+ },
474
+ authProvider: provider,
475
+ primaryDid: 'did:key:test',
476
+ shareVersion: 1,
477
+ identityProofType: 'auth-token',
478
+ requestedAt: new Date(),
479
+ releaseAfter: new Date(),
480
+ releasePolicy: 'hold',
481
+ clientEphemeralPublicKey: 'public-key',
482
+ resumeTokenHash: hashEscrowResumeToken(generateEscrowResumeToken()),
483
+ cancelTokenHash: originalHash,
484
+ });
485
+
486
+ const token = await rotateEscrowCancelToken(hold._id);
487
+ expect(token).toMatch(/^[0-9a-f]{64}$/);
488
+ const rotated = await findEscrowHoldById(hold._id);
489
+ expect(rotated?.cancelTokenHash).toMatch(/^[0-9a-f]{64}$/);
490
+ expect(rotated?.cancelTokenHash).toBe(originalHash);
491
+ expect(rotated?.cancelTokenHashes).toHaveLength(1);
492
+ expect(await rotateEscrowCancelToken(hold._id)).toBe(token);
493
+ expect((await findEscrowHoldById(hold._id))?.cancelTokenHashes).toHaveLength(1);
494
+
495
+ // Cancelling burns the pending-only precondition: further rotation is a no-op.
496
+ const cancelled = await cancelEscrowHold(hold._id, 'did');
497
+ const hashAfterCancel = cancelled?.cancelTokenHash;
498
+ expect(await rotateEscrowCancelToken(hold._id)).toBeNull();
499
+ expect((await findEscrowHoldById(hold._id))?.cancelTokenHash).toBe(hashAfterCancel);
500
+
501
+ const completedHold = await createHold();
502
+ await completeEscrowHold(completedHold._id);
503
+ expect(await rotateEscrowCancelToken(completedHold._id)).toBeNull();
504
+ expect((await findEscrowHoldById(completedHold._id))?.cancelTokenHash).toBeUndefined();
505
+
506
+ expect(await rotateEscrowCancelToken('does-not-exist')).toBeNull();
507
+ });
508
+ it('finds only pending hold-policy holds due within 24h and claims each at most once', async () => {
509
+ const now = new Date();
510
+ const hour = 60 * 60 * 1000;
511
+ // Each candidate needs its own authProvider: the partial unique index
512
+ // allows only one pending hold-policy hold per (authProvider, policy).
513
+ const createHoldFor = (releaseAfter: Date, releasePolicy: 'hold' | 'pin' = 'hold') =>
514
+ createEscrowHold({
515
+ holdRecord: {
516
+ hold: {
517
+ holdId: randomUUID(),
518
+ did: 'did:key:test',
519
+ shareVersion: 1,
520
+ blobHash: 'ab'.repeat(32),
521
+ enrollmentEpoch: 1,
522
+ releasePolicy: 'hold',
523
+ clientEphemeralPublicKey: 'public-key',
524
+ createdLo: 0,
525
+ createdHi: 0,
526
+ policyVersion: 1,
527
+ signature: 'test-signature',
528
+ },
529
+ holdDurationMs: 0,
530
+ ledgerSeq: 0,
531
+ },
532
+ authProvider: { type: 'firebase', id: `escrow-reminder-${randomUUID()}` },
533
+ primaryDid: 'did:key:test',
534
+ shareVersion: 1,
535
+ identityProofType: 'auth-token',
536
+ requestedAt: new Date(),
537
+ releaseAfter,
538
+ releasePolicy,
539
+ clientEphemeralPublicKey: 'public-key',
540
+ resumeTokenHash: hashEscrowResumeToken(generateEscrowResumeToken()),
541
+ });
542
+
543
+ const dueSoon = await createHoldFor(new Date(now.getTime() + 23 * hour));
544
+ const dueLate = await createHoldFor(new Date(now.getTime() + 25 * hour));
545
+ const alreadyReminded = await createHoldFor(new Date(now.getTime() + hour));
546
+ await recordEscrowHoldNotification(alreadyReminded._id, 'reminder');
547
+ const pinHold = await createHoldFor(now, 'pin');
548
+ const completedHold = await createHoldFor(new Date(now.getTime() + 2 * hour));
549
+ await completeEscrowHold(completedHold._id);
550
+
551
+ const due = await findEscrowHoldsDueForReminder(now, 200);
552
+ const dueIds = due.map(hold => hold._id);
553
+ expect(dueIds).toContain(dueSoon._id);
554
+ expect(dueIds).not.toContain(dueLate._id);
555
+ expect(dueIds).not.toContain(alreadyReminded._id);
556
+ expect(dueIds).not.toContain(pinHold._id);
557
+ expect(dueIds).not.toContain(completedHold._id);
558
+
559
+ // Two concurrent claims for the same hold: MongoDB serializes the
560
+ // findOneAndUpdate CAS, so exactly one observes the pre-push state.
561
+ const [first, second] = await Promise.all([
562
+ claimEscrowHoldForReminder(dueSoon._id, now),
563
+ claimEscrowHoldForReminder(dueSoon._id, now),
564
+ ]);
565
+ const winners = [first, second].filter((claim): claim is EscrowHold => claim !== null);
566
+ expect(winners).toHaveLength(1);
567
+ expect(winners[0]?.notifications.filter(entry => entry.kind === 'reminder')).toHaveLength(
568
+ 0
569
+ );
570
+ expect(await claimEscrowHoldForReminder(dueSoon._id, now)).toBeNull();
571
+ });
210
572
  });
@@ -390,6 +390,10 @@ describe('Notifications', () => {
390
390
 
391
391
  it('records exactly one E2E push attempt per delivery window', async () => {
392
392
  vi.stubEnv('IS_E2E_TEST', 'true');
393
+ // Earlier notification cases also write E2E attempts into this cache.
394
+ // Isolate this window-count assertion just as we clean up afterward.
395
+ const previousKeys = await cache.keys('e2e:push-attempt:*');
396
+ if (previousKeys?.length) await cache.delete(previousKeys);
393
397
 
394
398
  try {
395
399
  await expect(sendRefresh('window-1', 1)).resolves.toBe(true);