@learncard/lca-api-service 1.3.2 → 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 (240) hide show
  1. package/.env.example +99 -2
  2. package/AGENTS.md +155 -44
  3. package/CHANGELOG.md +84 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/compose.env +5 -0
  6. package/config/config.dev.json +23 -0
  7. package/config/config.example.json +22 -0
  8. package/config/config.json +1 -0
  9. package/config/config.production.json +12 -0
  10. package/config/config.scouts.dev.json +11 -0
  11. package/config/config.scouts.production.json +11 -0
  12. package/dist/accesslayer/signing-authority/index.d.ts +8 -2
  13. package/dist/app.d.ts +361 -8
  14. package/dist/cache/challenges.d.ts +2 -0
  15. package/dist/cache/getDel.d.ts +2 -0
  16. package/dist/cache/getDel.test.d.ts +1 -0
  17. package/dist/cache/login-tickets.d.ts +12 -0
  18. package/dist/cache/recoverySessions.d.ts +42 -0
  19. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  20. package/dist/config/cacheEnvironment.d.ts +16 -0
  21. package/dist/config/environment.d.ts +253 -2
  22. package/dist/config/environment.test.d.ts +1 -0
  23. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  24. package/dist/config/escrowDeployment.test.d.ts +1 -0
  25. package/dist/config/oidcEnvironment.d.ts +74 -0
  26. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  27. package/dist/config/stageConfig.d.ts +62 -0
  28. package/dist/config/stageConfig.test.d.ts +1 -0
  29. package/dist/didkit_wasm_bg.wasm +0 -0
  30. package/dist/docker-entry.d.ts +1 -1
  31. package/dist/helpers/auth.helpers.d.ts +1 -1
  32. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  33. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  34. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  35. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  36. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  37. package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
  38. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  39. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  40. package/dist/helpers/oidc.helpers.d.ts +47 -0
  41. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  42. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  43. package/dist/helpers/rateLimit.helpers.d.ts +4 -0
  44. package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
  45. package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
  46. package/dist/helpers/social-token.helpers.d.ts +19 -0
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.js +2 -2
  49. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  50. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  51. package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
  52. package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
  53. package/dist/models/AuthSubject.d.ts +20 -0
  54. package/dist/models/EscrowHold.d.ts +153 -0
  55. package/dist/models/Notification.d.ts +5 -0
  56. package/dist/models/SigningAuthority.d.ts +7 -1
  57. package/dist/models/UserKey.d.ts +326 -3
  58. package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
  59. package/dist/models/index.d.ts +2 -0
  60. package/dist/oidc.d.ts +5 -0
  61. package/dist/routes/auth.d.ts +31 -0
  62. package/dist/routes/escrow.d.ts +164 -0
  63. package/dist/routes/index.d.ts +6 -0
  64. package/dist/routes/keys.d.ts +182 -7
  65. package/dist/routes/notifications.d.ts +5 -0
  66. package/dist/routes/qr-login.d.ts +1 -1
  67. package/dist/routes/signing-authority.d.ts +14 -0
  68. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  69. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  70. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  71. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  72. package/dist/services/escrow-enclave/index.d.ts +25 -0
  73. package/dist/services/escrow-enclave/notifications.d.ts +24 -0
  74. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  75. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  76. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  77. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  78. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  79. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  80. package/dist/services/escrow-enclave/softwareEnclave.d.ts +27 -0
  81. package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
  82. package/dist/services/escrow-enclave/types.d.ts +111 -0
  83. package/dist/types/notifications.d.ts +5 -0
  84. package/dist/types/seed-encryption.d.ts +31 -0
  85. package/dist/types/seed-migration.d.ts +49 -0
  86. package/esbuildPlugins.cjs +1 -0
  87. package/lambda.ts +27 -112
  88. package/lambdaApp.ts +146 -0
  89. package/oidcLambda.test.ts +38 -0
  90. package/oidcLambda.ts +18 -0
  91. package/oidcLambdaApp.ts +11 -0
  92. package/package.json +30 -14
  93. package/scripts/README.md +88 -0
  94. package/scripts/check-seed-encryption-infrastructure.mjs +139 -0
  95. package/scripts/check-signing-authority-redis.mjs +93 -0
  96. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  97. package/scripts/escrow-blob-mode-report.ts +465 -0
  98. package/scripts/keycloak-admin.ts +108 -0
  99. package/scripts/keycloak-provider-links.ts +147 -0
  100. package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
  101. package/scripts/migrate-signing-authority-seeds.mjs +105 -0
  102. package/scripts/print-seed-rollout-flags.ts +21 -0
  103. package/scripts/provision-keycloak-users.ts +419 -0
  104. package/seedMigrationApp.test.ts +145 -0
  105. package/seedMigrationApp.ts +50 -0
  106. package/seedMigrationLambda.test.ts +40 -0
  107. package/seedMigrationLambda.ts +19 -0
  108. package/serverless-local.yml +28 -1
  109. package/serverless.function-env.cjs +58 -0
  110. package/serverless.yml +294 -26
  111. package/src/accesslayer/notifications/index.ts +9 -2
  112. package/src/accesslayer/signing-authority/create.ts +11 -7
  113. package/src/accesslayer/signing-authority/index.ts +4 -1
  114. package/src/app.ts +4 -0
  115. package/src/cache/challenges.ts +12 -2
  116. package/src/cache/getDel.test.ts +34 -0
  117. package/src/cache/getDel.ts +32 -0
  118. package/src/cache/in-memory-lru.ts +1 -0
  119. package/src/cache/index.ts +1 -1
  120. package/src/cache/login-tickets.ts +26 -0
  121. package/src/cache/recoverySessions.ts +252 -0
  122. package/src/config/applyDockerStageConfig.ts +6 -0
  123. package/src/config/cacheEnvironment.ts +34 -0
  124. package/src/config/environment.test.ts +159 -0
  125. package/src/config/environment.ts +164 -0
  126. package/src/config/escrowBundleMode.test.ts +52 -0
  127. package/src/config/escrowDeployment.test.ts +114 -0
  128. package/src/config/oidcEnvironment.ts +59 -0
  129. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  130. package/src/config/stageConfig.test.ts +26 -0
  131. package/src/config/stageConfig.ts +24 -0
  132. package/src/docker-entry.ts +13 -0
  133. package/src/helpers/auth.helpers.ts +68 -30
  134. package/src/helpers/escrowCancelLink.test.ts +60 -0
  135. package/src/helpers/escrowCancelLink.ts +38 -0
  136. package/src/helpers/escrowCancelToken.test.ts +105 -0
  137. package/src/helpers/escrowCancelToken.ts +47 -0
  138. package/src/helpers/keycloak.helpers.ts +120 -0
  139. package/src/helpers/learnCard.helpers.test.ts +11 -1
  140. package/src/helpers/learnCard.helpers.ts +57 -45
  141. package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
  142. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  143. package/src/helpers/login-ticket.helpers.ts +91 -0
  144. package/src/helpers/oidc.helpers.ts +354 -0
  145. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  146. package/src/helpers/rate-limit.helpers.ts +73 -0
  147. package/src/helpers/rateLimit.helpers.ts +112 -0
  148. package/src/helpers/seedEncryption.helpers.test.ts +427 -0
  149. package/src/helpers/seedEncryption.helpers.ts +372 -0
  150. package/src/helpers/signingAuthority.helpers.ts +4 -1
  151. package/src/helpers/social-token.helpers.ts +144 -0
  152. package/src/index.ts +1 -0
  153. package/src/jobs/escrowBlobRewrap.ts +121 -0
  154. package/src/jobs/escrowHoldReminders.ts +177 -0
  155. package/src/migrations/signingAuthoritySeeds.ts +430 -0
  156. package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
  157. package/src/models/AuthSubject.ts +147 -0
  158. package/src/models/EscrowHold.ts +541 -0
  159. package/src/models/SigningAuthority.ts +14 -3
  160. package/src/models/UserKey.escrowPin.test.ts +49 -0
  161. package/src/models/UserKey.ts +1109 -40
  162. package/src/models/index.ts +2 -0
  163. package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
  164. package/src/oidc.ts +371 -0
  165. package/src/routes/auth.ts +173 -0
  166. package/src/routes/credentials.ts +8 -0
  167. package/src/routes/escrow.ts +1073 -0
  168. package/src/routes/firebase.ts +90 -48
  169. package/src/routes/index.ts +98 -5
  170. package/src/routes/keys.ts +1022 -174
  171. package/src/routes/qr-login.ts +51 -53
  172. package/src/routes/signing-authority.ts +39 -1
  173. package/src/services/delivery/adapters/postmark.adapter.ts +48 -1
  174. package/src/services/escrow-enclave/README.md +122 -0
  175. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  176. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  177. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  178. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  179. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  180. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  181. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  182. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  183. package/src/services/escrow-enclave/cancellation.ts +59 -0
  184. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  185. package/src/services/escrow-enclave/index.ts +174 -0
  186. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  187. package/src/services/escrow-enclave/notifications.ts +348 -0
  188. package/src/services/escrow-enclave/production.test.ts +34 -0
  189. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  190. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  191. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  192. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  193. package/src/services/escrow-enclave/softwareEnclave.test.ts +450 -0
  194. package/src/services/escrow-enclave/softwareEnclave.ts +254 -0
  195. package/src/services/escrow-enclave/types.ts +122 -0
  196. package/src/types/seed-encryption.ts +51 -0
  197. package/src/types/seed-migration.ts +61 -0
  198. package/test/auth-tickets.spec.ts +480 -0
  199. package/test/credentials.spec.ts +2 -0
  200. package/test/helpers/keycloak-broker.ts +181 -0
  201. package/test/helpers/live-mongo.ts +9 -0
  202. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  203. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  204. package/test/keycloak-admin.spec.ts +58 -0
  205. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  206. package/test/keycloak-migration.integration.spec.ts +266 -0
  207. package/test/keycloak-provider-links.spec.ts +201 -0
  208. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  209. package/test/keycloak-verify.integration.spec.ts +100 -0
  210. package/test/keycloak-verify.spec.ts +307 -0
  211. package/test/keys-escrow.spec.ts +2328 -0
  212. package/test/keys-identity-rebind.spec.ts +456 -0
  213. package/test/keys-lifecycle-invariants.spec.ts +785 -0
  214. package/test/keys-recovery-confirmation.spec.ts +586 -0
  215. package/test/keys-recovery-rate-limit.spec.ts +123 -0
  216. package/test/keys-secret-leakage.spec.ts +265 -0
  217. package/test/models/authSubjectIndexes.spec.ts +132 -0
  218. package/test/models/escrow.spec.ts +572 -0
  219. package/test/notifications.spec.ts +4 -0
  220. package/test/oidc-e2e/broker.spec.ts +116 -0
  221. package/test/oidc-e2e/compose.yaml +33 -0
  222. package/test/oidc-e2e/fixtures.ts +245 -0
  223. package/test/oidc-e2e/lock.test.mjs +73 -0
  224. package/test/oidc-e2e/playwright.config.ts +21 -0
  225. package/test/oidc-e2e/run.mjs +312 -0
  226. package/test/oidc-e2e/tsconfig.json +10 -0
  227. package/test/oidc-e2e/types.ts +21 -0
  228. package/test/oidc.integration.spec.ts +54 -0
  229. package/test/oidc.spec.ts +697 -0
  230. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  231. package/test/provision-keycloak-users.spec.ts +299 -0
  232. package/test/signing-authority-http.spec.ts +161 -0
  233. package/test/signing-authority-seed-migration.spec.ts +414 -0
  234. package/test/signing-authority.spec.ts +90 -0
  235. package/tsconfig.build.json +1 -1
  236. package/tsconfig.json +1 -0
  237. package/vitest.config.ts +24 -1
  238. package/vitest.integration.config.ts +25 -3
  239. package/dist/cache/signing-authority.d.ts +0 -6
  240. package/src/cache/signing-authority.ts +0 -37
@@ -0,0 +1,262 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { beforeAll, afterAll, afterEach, describe, it, expect } from 'vitest';
3
+ import { client } from '@mongo';
4
+ import { encryptEscrowBlob, generateEscrowKeyPair } from '@learncard/sss-key-manager';
5
+ import {
6
+ createUserKeysIndexes,
7
+ getUserKeysCollection,
8
+ upsertUserKeyByAuthProvider,
9
+ setEscrowBlobByAuthProvider,
10
+ findUserKeyByAuthProvider,
11
+ createEscrowHoldsIndexes,
12
+ getEscrowHoldsCollection,
13
+ createEscrowHold,
14
+ generateEscrowResumeToken,
15
+ hashEscrowResumeToken,
16
+ type AuthProviderMapping,
17
+ type EscrowBlob,
18
+ } from '@models';
19
+ import { __setEscrowEnclaveForTests } from '../../src/services/escrow-enclave';
20
+ import { runEscrowBlobRewrap } from '../../src/jobs/escrowBlobRewrap';
21
+
22
+ const seededProviderIds: string[] = [];
23
+
24
+ const makeProvider = (): AuthProviderMapping => {
25
+ const provider: AuthProviderMapping = {
26
+ type: 'firebase',
27
+ id: `escrow-rewrap-job-${randomUUID()}`,
28
+ };
29
+ seededProviderIds.push(provider.id);
30
+ return provider;
31
+ };
32
+
33
+ let previousKeys: { publicKey: string; privateKey: string };
34
+ let currentKeys: { publicKey: string; privateKey: string };
35
+
36
+ /**
37
+ * Seeds a UserKey with an escrow blob sealed under the given (public)
38
+ * key/keyId. Only creates the account on the FIRST call for a given
39
+ * provider — a second call (used to simulate a mid-run re-enrollment onto a
40
+ * different key) must not re-run `upsertUserKeyByAuthProvider` with the same
41
+ * `authShare`, which would bump the account's own `shareVersion` and break
42
+ * the escrow blob's `expectedShareVersion: 1` CAS below.
43
+ */
44
+ const seedBlob = async (
45
+ provider: AuthProviderMapping,
46
+ keyId: string,
47
+ publicKey: string
48
+ ): Promise<EscrowBlob> => {
49
+ if (!(await findUserKeyByAuthProvider(provider.type, provider.id))) {
50
+ await upsertUserKeyByAuthProvider(
51
+ { type: 'email', value: `${provider.id}@example.com` },
52
+ provider,
53
+ {
54
+ primaryDid: 'did:key:test',
55
+ authShare: { encryptedData: 'data', encryptedDek: 'dek', iv: 'iv' },
56
+ }
57
+ );
58
+ }
59
+ const envelope = await encryptEscrowBlob(
60
+ { recoveryShare: 'ab'.repeat(33), did: 'did:key:test', shareVersion: 1 },
61
+ publicKey,
62
+ keyId
63
+ );
64
+ const stored = await setEscrowBlobByAuthProvider(
65
+ provider,
66
+ {
67
+ envelope,
68
+ enclaveKeyId: keyId,
69
+ enclaveMode: 'software',
70
+ measurements: {},
71
+ shareVersion: 1,
72
+ createdAt: new Date(),
73
+ },
74
+ 1
75
+ );
76
+ return stored!.escrowBlob!;
77
+ };
78
+
79
+ beforeAll(async () => {
80
+ process.env.IS_E2E_TEST = 'true';
81
+ process.env.SEED ||= 'a'.repeat(64);
82
+ previousKeys = await generateEscrowKeyPair();
83
+ currentKeys = await generateEscrowKeyPair();
84
+ process.env.ESCROW_ENCLAVE_MODE = 'software';
85
+ process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON = JSON.stringify({
86
+ current: currentKeys.privateKey,
87
+ previous: previousKeys.privateKey,
88
+ });
89
+ process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID = 'current';
90
+ __setEscrowEnclaveForTests(undefined);
91
+ await client.connect();
92
+ await createUserKeysIndexes();
93
+ await createEscrowHoldsIndexes();
94
+ });
95
+
96
+ afterEach(async () => {
97
+ const ids = seededProviderIds.splice(0);
98
+ if (ids.length === 0) return;
99
+ await getUserKeysCollection().deleteMany({ 'authProviders.id': { $in: ids } });
100
+ await getEscrowHoldsCollection().deleteMany({ 'authProvider.id': { $in: ids } });
101
+ });
102
+
103
+ afterAll(async () => {
104
+ delete process.env.ESCROW_ENCLAVE_MODE;
105
+ delete process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON;
106
+ delete process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID;
107
+ __setEscrowEnclaveForTests(undefined);
108
+ await client.close();
109
+ });
110
+
111
+ describe('runEscrowBlobRewrap', () => {
112
+ it('migrates a previous-key blob onto the current key: keyId now current, epoch and version unchanged, escrowPin untouched', async () => {
113
+ const provider = makeProvider();
114
+ const oldBlob = await seedBlob(provider, 'previous', previousKeys.publicKey);
115
+ await getUserKeysCollection().updateOne(
116
+ { 'authProviders.id': provider.id },
117
+ {
118
+ $set: {
119
+ escrowPin: {
120
+ salt: Buffer.alloc(16).toString('base64'),
121
+ failedAttempts: 3,
122
+ enabledAt: new Date(),
123
+ shareVersion: 1,
124
+ },
125
+ },
126
+ }
127
+ );
128
+
129
+ const result = await runEscrowBlobRewrap();
130
+
131
+ expect(result).toEqual({ rewrapped: 1, skippedPendingHold: 0, failed: 0 });
132
+ const stored = await findUserKeyByAuthProvider(provider.type, provider.id);
133
+ expect(stored?.escrowBlob?.enclaveKeyId).toBe('current');
134
+ expect(stored?.escrowBlob?.envelope.keyId).toBe('current');
135
+ expect(stored?.escrowBlob?.shareVersion).toBe(1);
136
+ expect(stored?.escrowBlob?.enrollmentEpoch).toBe(oldBlob.enrollmentEpoch);
137
+ expect(stored?.escrowBlob?.blobHash).not.toBe(oldBlob.blobHash);
138
+ expect(stored?.escrowPin?.failedAttempts).toBe(3);
139
+ // Idempotent: nothing left to migrate on a second run.
140
+ expect(await runEscrowBlobRewrap()).toEqual({
141
+ rewrapped: 0,
142
+ skippedPendingHold: 0,
143
+ failed: 0,
144
+ });
145
+ });
146
+
147
+ it('skips (does not touch) an account with a pending recovery hold', async () => {
148
+ const provider = makeProvider();
149
+ const oldBlob = await seedBlob(provider, 'previous', previousKeys.publicKey);
150
+ await createEscrowHold({
151
+ holdRecord: {
152
+ hold: {
153
+ holdId: randomUUID(),
154
+ did: 'did:key:test',
155
+ shareVersion: 1,
156
+ blobHash: oldBlob.blobHash,
157
+ enrollmentEpoch: oldBlob.enrollmentEpoch,
158
+ releasePolicy: 'hold',
159
+ clientEphemeralPublicKey: 'public-key',
160
+ createdLo: 0,
161
+ createdHi: 0,
162
+ policyVersion: 1,
163
+ signature: 'test-signature',
164
+ },
165
+ holdDurationMs: 0,
166
+ ledgerSeq: 0,
167
+ },
168
+ authProvider: provider,
169
+ primaryDid: 'did:key:test',
170
+ shareVersion: 1,
171
+ identityProofType: 'auth-token',
172
+ requestedAt: new Date(),
173
+ releaseAfter: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
174
+ releasePolicy: 'hold',
175
+ clientEphemeralPublicKey: 'public-key',
176
+ resumeTokenHash: hashEscrowResumeToken(generateEscrowResumeToken()),
177
+ });
178
+
179
+ const result = await runEscrowBlobRewrap();
180
+
181
+ expect(result).toEqual({ rewrapped: 0, skippedPendingHold: 1, failed: 0 });
182
+ const stored = await findUserKeyByAuthProvider(provider.type, provider.id);
183
+ expect(stored?.escrowBlob).toEqual(oldBlob);
184
+ });
185
+
186
+ it('never touches a blob sealed under a truly unknown (never current, never previous) key', async () => {
187
+ const provider = makeProvider();
188
+ const unknown = await generateEscrowKeyPair();
189
+ const oldBlob = await seedBlob(provider, 'totally-unknown', unknown.publicKey);
190
+
191
+ const result = await runEscrowBlobRewrap();
192
+
193
+ expect(result).toEqual({ rewrapped: 0, skippedPendingHold: 0, failed: 0 });
194
+ const stored = await findUserKeyByAuthProvider(provider.type, provider.id);
195
+ expect(stored?.escrowBlob).toEqual(oldBlob);
196
+ });
197
+
198
+ it('concurrent runs never double-write the same account (CAS)', async () => {
199
+ const provider = makeProvider();
200
+ await seedBlob(provider, 'previous', previousKeys.publicKey);
201
+
202
+ const [a, b] = await Promise.all([runEscrowBlobRewrap(), runEscrowBlobRewrap()]);
203
+
204
+ expect(a.rewrapped + b.rewrapped).toBe(1);
205
+ expect(a.failed + b.failed).toBe(0);
206
+ const stored = await findUserKeyByAuthProvider(provider.type, provider.id);
207
+ expect(stored?.escrowBlob?.enclaveKeyId).toBe('current');
208
+ });
209
+
210
+ it('a user who re-enrolled onto the current key mid-run wins the race (CAS no-op)', async () => {
211
+ const provider = makeProvider();
212
+ await seedBlob(provider, 'previous', previousKeys.publicKey);
213
+ // Simulates a concurrent P9.2 sign-in re-enroll landing between the
214
+ // job's candidate read and its write: the account is already on the
215
+ // current key by the time the job's CAS write is attempted.
216
+ await seedBlob(provider, 'current', currentKeys.publicKey);
217
+
218
+ const result = await runEscrowBlobRewrap();
219
+
220
+ expect(result).toEqual({ rewrapped: 0, skippedPendingHold: 0, failed: 0 });
221
+ });
222
+
223
+ it('no-ops without querying Mongo when the enclave has no previous keys configured', async () => {
224
+ process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON = JSON.stringify({
225
+ current: currentKeys.privateKey,
226
+ });
227
+ __setEscrowEnclaveForTests(undefined);
228
+ try {
229
+ const provider = makeProvider();
230
+ const oldBlob = await seedBlob(provider, 'previous', previousKeys.publicKey);
231
+
232
+ const result = await runEscrowBlobRewrap();
233
+
234
+ expect(result).toEqual({ rewrapped: 0, skippedPendingHold: 0, failed: 0 });
235
+ const stored = await findUserKeyByAuthProvider(provider.type, provider.id);
236
+ expect(stored?.escrowBlob).toEqual(oldBlob);
237
+ } finally {
238
+ process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON = JSON.stringify({
239
+ current: currentKeys.privateKey,
240
+ previous: previousKeys.privateKey,
241
+ });
242
+ __setEscrowEnclaveForTests(undefined);
243
+ }
244
+ });
245
+
246
+ it('no-ops without touching the database when escrow is disabled', async () => {
247
+ const provider = makeProvider();
248
+ const oldBlob = await seedBlob(provider, 'previous', previousKeys.publicKey);
249
+ delete process.env.ESCROW_ENCLAVE_MODE;
250
+ __setEscrowEnclaveForTests(undefined);
251
+
252
+ try {
253
+ const result = await runEscrowBlobRewrap();
254
+ expect(result).toEqual({ rewrapped: 0, skippedPendingHold: 0, failed: 0 });
255
+ const stored = await findUserKeyByAuthProvider(provider.type, provider.id);
256
+ expect(stored?.escrowBlob).toEqual(oldBlob);
257
+ } finally {
258
+ process.env.ESCROW_ENCLAVE_MODE = 'software';
259
+ __setEscrowEnclaveForTests(undefined);
260
+ }
261
+ });
262
+ });
@@ -0,0 +1,319 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { beforeAll, afterAll, afterEach, describe, it, expect } from 'vitest';
3
+ import { client } from '@mongo';
4
+ import {
5
+ createUserKeysIndexes,
6
+ getUserKeysCollection,
7
+ upsertUserKeyByAuthProvider,
8
+ createEscrowHoldsIndexes,
9
+ getEscrowHoldsCollection,
10
+ createEscrowHold,
11
+ findEscrowHoldById,
12
+ completeEscrowHold,
13
+ cancelEscrowHold,
14
+ generateEscrowResumeToken,
15
+ hashEscrowResumeToken,
16
+ recordEscrowHoldNotification,
17
+ type AuthProviderMapping,
18
+ } from '@models';
19
+ import {
20
+ __setEscrowEnclaveForTests,
21
+ notifyEscrowHoldEvent,
22
+ } from '../../src/services/escrow-enclave';
23
+ import { escrowCancelTokenMatches, hashEscrowCancelToken } from '@helpers/escrowCancelToken';
24
+ import { runEscrowHoldReminders } from '../../src/jobs/escrowHoldReminders';
25
+
26
+ const HOUR = 60 * 60 * 1000;
27
+ const seededProviderIds: string[] = [];
28
+
29
+ const makeProvider = (): AuthProviderMapping => {
30
+ const provider: AuthProviderMapping = {
31
+ type: 'firebase',
32
+ id: `escrow-reminder-job-${randomUUID()}`,
33
+ };
34
+ seededProviderIds.push(provider.id);
35
+ return provider;
36
+ };
37
+
38
+ /** Every seed() call gets its own authProvider (see test/models/escrow.spec.ts
39
+ * for why: only one pending hold-policy hold is allowed per authProvider). */
40
+ const seed = async (releaseAfter: Date, releasePolicy: 'hold' | 'pin' = 'hold') => {
41
+ const provider = makeProvider();
42
+ await upsertUserKeyByAuthProvider(
43
+ { type: 'email', value: `${provider.id}@example.com` },
44
+ provider,
45
+ {
46
+ primaryDid: 'did:key:test',
47
+ authShare: { encryptedData: 'data', encryptedDek: 'dek', iv: 'iv' },
48
+ }
49
+ );
50
+ return createEscrowHold({
51
+ holdRecord: {
52
+ hold: {
53
+ holdId: randomUUID(),
54
+ did: 'did:key:test',
55
+ shareVersion: 1,
56
+ blobHash: 'ab'.repeat(32),
57
+ enrollmentEpoch: 1,
58
+ releasePolicy: 'hold',
59
+ clientEphemeralPublicKey: 'public-key',
60
+ createdLo: 0,
61
+ createdHi: 0,
62
+ policyVersion: 1,
63
+ signature: 'test-signature',
64
+ },
65
+ holdDurationMs: 0,
66
+ ledgerSeq: 0,
67
+ },
68
+ authProvider: provider,
69
+ primaryDid: 'did:key:test',
70
+ shareVersion: 1,
71
+ identityProofType: 'auth-token',
72
+ requestedAt: new Date(),
73
+ releaseAfter,
74
+ releasePolicy,
75
+ clientEphemeralPublicKey: 'public-key',
76
+ resumeTokenHash: hashEscrowResumeToken(generateEscrowResumeToken()),
77
+ });
78
+ };
79
+
80
+ beforeAll(async () => {
81
+ process.env.IS_E2E_TEST = 'true';
82
+ process.env.SEED ||= 'a'.repeat(64);
83
+ process.env.ESCROW_ENCLAVE_MODE = 'software';
84
+ process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON = JSON.stringify({
85
+ dummy: 'x'.repeat(10),
86
+ });
87
+ process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID = 'dummy';
88
+ __setEscrowEnclaveForTests(undefined);
89
+ await client.connect();
90
+ await createUserKeysIndexes();
91
+ await createEscrowHoldsIndexes();
92
+ });
93
+
94
+ afterEach(async () => {
95
+ const ids = seededProviderIds.splice(0);
96
+ if (ids.length === 0) return;
97
+ await getUserKeysCollection().deleteMany({ 'authProviders.id': { $in: ids } });
98
+ await getEscrowHoldsCollection().deleteMany({ 'authProvider.id': { $in: ids } });
99
+ });
100
+
101
+ afterAll(async () => {
102
+ delete process.env.ESCROW_ENCLAVE_MODE;
103
+ delete process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON;
104
+ delete process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID;
105
+ __setEscrowEnclaveForTests(undefined);
106
+ await client.close();
107
+ });
108
+
109
+ describe('runEscrowHoldReminders', () => {
110
+ it('bounds cancellation retries and gives unattempted cancellations priority', async () => {
111
+ const now = new Date();
112
+ const holds = await Promise.all(
113
+ Array.from({ length: 4 }, () => seed(new Date(now.getTime() + 7 * 24 * HOUR)))
114
+ );
115
+ for (const hold of holds) await cancelEscrowHold(hold._id, 'did');
116
+ // These legacy test records lack ciphertext: fail closed, but still rotate
117
+ // the bounded queue rather than retrying the same first three forever.
118
+ await runEscrowHoldReminders({ now, limit: 200 });
119
+ const firstRun = await Promise.all(holds.map(hold => findEscrowHoldById(hold._id)));
120
+ expect(firstRun.map(hold => hold?.enclaveCancelAttempts).sort()).toEqual([0, 1, 1, 1]);
121
+ const unattempted = firstRun.find(hold => hold?.enclaveCancelAttempts === 0)!;
122
+ await runEscrowHoldReminders({ now, limit: 1 });
123
+ expect((await findEscrowHoldById(unattempted._id))?.enclaveCancelAttempts).toBe(1);
124
+ for (const hold of holds) {
125
+ const stored = await findEscrowHoldById(hold._id);
126
+ expect(stored?.enclaveCancelPendingAt).toBeInstanceOf(Date);
127
+ expect(stored?.enclaveCancelConfirmedAt).toBeUndefined();
128
+ }
129
+ });
130
+
131
+ it('budgets both queues and moves failed starts behind unattempted holds', async () => {
132
+ const now = new Date();
133
+ const starts = await Promise.all(
134
+ Array.from({ length: 11 }, () => seed(new Date(now.getTime() + 7 * 24 * HOUR)))
135
+ );
136
+ await getEscrowHoldsCollection().updateMany(
137
+ { _id: { $in: starts.map(hold => hold._id) } },
138
+ { $set: { startedNotificationPending: true } }
139
+ );
140
+ const reminder = await seed(new Date(now.getTime() + HOUR));
141
+ const attempts: string[] = [];
142
+ const notify: typeof notifyEscrowHoldEvent = async event => {
143
+ attempts.push(event.hold._id);
144
+ return false;
145
+ };
146
+ await runEscrowHoldReminders({ now, limit: 200, notify });
147
+ expect(attempts).toHaveLength(11); // Ten starts plus one reminder, not 200 starts.
148
+ expect(attempts).toContain(reminder._id);
149
+ const untouched = starts.find(hold => !attempts.includes(hold._id))!;
150
+ attempts.length = 0;
151
+ await runEscrowHoldReminders({ now: new Date(now.getTime() + HOUR / 2), limit: 1, notify });
152
+ expect(attempts[0]).toBe(untouched._id);
153
+ expect(attempts[1]).toBe(reminder._id);
154
+ });
155
+
156
+ it('retries an undelivered start notification with a cancel link', async () => {
157
+ const now = new Date();
158
+ const hold = await seed(new Date(now.getTime() + 7 * 24 * HOUR));
159
+ await getEscrowHoldsCollection().updateOne(
160
+ { _id: hold._id },
161
+ { $set: { startedNotificationPending: true } }
162
+ );
163
+ const failed = await runEscrowHoldReminders({
164
+ now,
165
+ notify: event =>
166
+ notifyEscrowHoldEvent(event, {
167
+ send: async () => {
168
+ throw new Error('Postmark unavailable');
169
+ },
170
+ sendPush: async () => ({ successCount: 1, failureCount: 0, failedTokens: [] }),
171
+ }),
172
+ });
173
+ expect(failed.failed).toBe(1);
174
+ expect((await findEscrowHoldById(hold._id))?.startedNotificationPending).toBe(true);
175
+ let cancelUrl = '';
176
+ await runEscrowHoldReminders({
177
+ now,
178
+ notify: event =>
179
+ notifyEscrowHoldEvent(event, {
180
+ send: async notification => {
181
+ if ('templateModel' in notification)
182
+ cancelUrl = String(notification.templateModel.cancelUrl);
183
+ },
184
+ sendPush: async () => ({ successCount: 0, failureCount: 0, failedTokens: [] }),
185
+ }),
186
+ });
187
+ const stored = await findEscrowHoldById(hold._id);
188
+ expect(stored?.startedNotificationPending).toBe(false);
189
+ expect(stored?.notifications.map(entry => entry.kind)).toEqual(['started']);
190
+ expect(cancelUrl).toContain(hold._id);
191
+ });
192
+
193
+ it('failed reminder delivery preserves the original cancel link and retries next run', async () => {
194
+ const now = new Date();
195
+ const hold = await seed(new Date(now.getTime() + HOUR));
196
+ const originalToken = 'a'.repeat(64);
197
+ await getEscrowHoldsCollection().updateOne(
198
+ { _id: hold._id },
199
+ { $set: { cancelTokenHash: hashEscrowCancelToken(originalToken) } }
200
+ );
201
+ const result = await runEscrowHoldReminders({
202
+ now,
203
+ notify: event =>
204
+ notifyEscrowHoldEvent(event, {
205
+ send: async () => {
206
+ throw new Error('Postmark unavailable');
207
+ },
208
+ sendPush: async () => ({ successCount: 1, failureCount: 0, failedTokens: [] }),
209
+ }),
210
+ });
211
+ expect(result.failed).toBe(1);
212
+ const stored = await findEscrowHoldById(hold._id);
213
+ expect(escrowCancelTokenMatches(stored!, originalToken)).toBe(true);
214
+ expect(stored?.notifications).toEqual([]);
215
+ expect((await runEscrowHoldReminders({ now })).reminded).toBe(1);
216
+ expect(escrowCancelTokenMatches((await findEscrowHoldById(hold._id))!, originalToken)).toBe(
217
+ true
218
+ );
219
+ });
220
+
221
+ it('reminds a hold due within 24h exactly once, even racing two full job runs', async () => {
222
+ const now = new Date();
223
+ const hold = await seed(new Date(now.getTime() + 23 * HOUR));
224
+
225
+ const [a, b] = await Promise.all([
226
+ runEscrowHoldReminders({ now }),
227
+ runEscrowHoldReminders({ now }),
228
+ ]);
229
+
230
+ expect(a.reminded + b.reminded).toBe(1);
231
+ expect(a.failed + b.failed).toBe(0);
232
+ const stored = await findEscrowHoldById(hold._id);
233
+ expect(stored?.notifications.filter(entry => entry.kind === 'reminder')).toHaveLength(1);
234
+ });
235
+
236
+ it('does not remind a hold due more than 24h out', async () => {
237
+ const now = new Date();
238
+ const hold = await seed(new Date(now.getTime() + 25 * HOUR));
239
+
240
+ const result = await runEscrowHoldReminders({ now });
241
+
242
+ expect(result.reminded).toBe(0);
243
+ expect((await findEscrowHoldById(hold._id))?.notifications).toEqual([]);
244
+ });
245
+
246
+ it('skips completed and cancelled holds', async () => {
247
+ const now = new Date();
248
+ const completed = await seed(new Date(now.getTime() + HOUR));
249
+ await completeEscrowHold(completed._id);
250
+ const cancelled = await seed(new Date(now.getTime() + 2 * HOUR));
251
+ await cancelEscrowHold(cancelled._id, 'did');
252
+
253
+ const result = await runEscrowHoldReminders({ now });
254
+
255
+ expect(result.reminded).toBe(0);
256
+ expect((await findEscrowHoldById(completed._id))?.notifications).toEqual([]);
257
+ expect((await findEscrowHoldById(cancelled._id))?.notifications).toEqual([]);
258
+ });
259
+
260
+ it('skips holds already reminded', async () => {
261
+ const now = new Date();
262
+ const hold = await seed(new Date(now.getTime() + HOUR));
263
+ await recordEscrowHoldNotification(hold._id, 'reminder');
264
+
265
+ const result = await runEscrowHoldReminders({ now });
266
+
267
+ expect(result.reminded).toBe(0);
268
+ expect((await findEscrowHoldById(hold._id))?.notifications).toHaveLength(1);
269
+ });
270
+
271
+ it('expires stale holds and reports the count alongside reminders sent in the same run', async () => {
272
+ const now = new Date();
273
+ const stale = await seed(new Date(now.getTime() - 31 * 24 * HOUR));
274
+ const due = await seed(new Date(now.getTime() + HOUR));
275
+
276
+ const result = await runEscrowHoldReminders({ now });
277
+
278
+ expect(result.expired).toBe(1);
279
+ expect(result.reminded).toBe(1);
280
+ expect((await findEscrowHoldById(stale._id))?.status).toBe('expired');
281
+ expect((await findEscrowHoldById(due._id))?.notifications.map(entry => entry.kind)).toEqual(
282
+ ['reminder']
283
+ );
284
+ });
285
+
286
+ it('counts a notifier failure without marking sent and retries on the next run', async () => {
287
+ const now = new Date();
288
+ const hold = await seed(new Date(now.getTime() + HOUR));
289
+
290
+ const result = await runEscrowHoldReminders({
291
+ now,
292
+ notify: async () => {
293
+ throw new Error('delivery exploded');
294
+ },
295
+ });
296
+
297
+ expect(result).toEqual({ reminded: 0, expired: 0, failed: 1 });
298
+ const stored = await findEscrowHoldById(hold._id);
299
+ expect(stored?.notifications.map(entry => entry.kind)).toEqual([]);
300
+ const rerun = await runEscrowHoldReminders({ now });
301
+ expect(rerun).toEqual({ reminded: 1, expired: 0, failed: 0 });
302
+ });
303
+
304
+ it('no-ops without touching the database when escrow is disabled', async () => {
305
+ const now = new Date();
306
+ const stale = await seed(new Date(now.getTime() - 31 * 24 * HOUR));
307
+ delete process.env.ESCROW_ENCLAVE_MODE;
308
+ __setEscrowEnclaveForTests(undefined);
309
+
310
+ try {
311
+ const result = await runEscrowHoldReminders({ now });
312
+ expect(result).toEqual({ reminded: 0, expired: 0, failed: 0 });
313
+ expect((await findEscrowHoldById(stale._id))?.status).toBe('pending');
314
+ } finally {
315
+ process.env.ESCROW_ENCLAVE_MODE = 'software';
316
+ __setEscrowEnclaveForTests(undefined);
317
+ }
318
+ });
319
+ });
@@ -0,0 +1,58 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { createKeycloakAdmin } from '../scripts/keycloak-admin';
3
+
4
+ const token = (value: string): Response => Response.json({ access_token: value, expires_in: 100 });
5
+
6
+ afterEach((): void => vi.unstubAllEnvs());
7
+
8
+ describe('Keycloak admin token lifetime', (): void => {
9
+ it('refreshes once after 401 and retries with the new token', async (): Promise<void> => {
10
+ vi.stubEnv('KEYCLOAK_ADMIN_CLIENT_SECRET', 'test');
11
+ const fetchRequest = vi
12
+ .fn<typeof fetch>()
13
+ .mockResolvedValueOnce(token('first'))
14
+ .mockResolvedValueOnce(new Response(null, { status: 401 }))
15
+ .mockResolvedValueOnce(token('second'))
16
+ .mockResolvedValueOnce(Response.json([]));
17
+ const admin = await createKeycloakAdmin(fetchRequest);
18
+ await expect(admin.findUsers('test@example.com')).resolves.toEqual([]);
19
+ expect(fetchRequest).toHaveBeenCalledTimes(4);
20
+ expect(fetchRequest.mock.calls[3]?.[1]?.headers).toMatchObject({
21
+ Authorization: 'Bearer second',
22
+ });
23
+ });
24
+
25
+ it('does not retry a second 401', async (): Promise<void> => {
26
+ vi.stubEnv('KEYCLOAK_ADMIN_CLIENT_SECRET', 'test');
27
+ const fetchRequest = vi
28
+ .fn<typeof fetch>()
29
+ .mockResolvedValueOnce(token('first'))
30
+ .mockResolvedValueOnce(new Response(null, { status: 401 }))
31
+ .mockResolvedValueOnce(token('second'))
32
+ .mockResolvedValueOnce(new Response(null, { status: 401 }));
33
+ const admin = await createKeycloakAdmin(fetchRequest);
34
+ await expect(admin.request('/users')).rejects.toThrow('Admin operation failed (401)');
35
+ expect(fetchRequest).toHaveBeenCalledTimes(4);
36
+ });
37
+
38
+ it('refreshes shortly before expiry but reuses a valid token', async (): Promise<void> => {
39
+ vi.stubEnv('KEYCLOAK_ADMIN_CLIENT_SECRET', 'test');
40
+ let time = 0;
41
+ const fetchRequest = vi
42
+ .fn<typeof fetch>()
43
+ .mockResolvedValueOnce(token('first'))
44
+ .mockResolvedValueOnce(Response.json([]))
45
+ .mockResolvedValueOnce(token('second'))
46
+ .mockResolvedValueOnce(Response.json([]));
47
+ const admin = await createKeycloakAdmin(fetchRequest, (): number => time);
48
+ time = 89_000;
49
+ await admin.request('/users');
50
+ expect(fetchRequest).toHaveBeenCalledTimes(2);
51
+ time = 90_000;
52
+ await admin.request('/users');
53
+ expect(fetchRequest).toHaveBeenCalledTimes(4);
54
+ expect(fetchRequest.mock.calls[3]?.[1]?.headers).toMatchObject({
55
+ Authorization: 'Bearer second',
56
+ });
57
+ });
58
+ });
@@ -0,0 +1,40 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { decodeJwt } from 'jose';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { createKeycloakAdmin } from '../scripts/keycloak-admin';
5
+ import { roundtripEnabled, signInThroughBroker } from './helpers/keycloak-broker';
6
+
7
+ describe.runIf(roundtripEnabled)('full Keycloak broker round-trip', () => {
8
+ it('issues a Keycloak session for a new email without rendering any page', async () => {
9
+ const { verifyKeycloakToken } = await import('../src/helpers/auth.helpers');
10
+ const { getAuthSubjectsCollection } = await import('../src/models/AuthSubject');
11
+ const { client } = await import('./helpers/live-mongo');
12
+ const admin = await createKeycloakAdmin();
13
+ const email = `broker-${randomUUID()}@example.com`;
14
+ try {
15
+ const { idToken, trace } = await signInThroughBroker(email);
16
+ const verified = await verifyKeycloakToken(idToken);
17
+ const users = await admin.findUsers(email);
18
+ expect(users).toHaveLength(1);
19
+ expect(verified).toMatchObject({ providerType: 'keycloak', email, id: users[0]!.id });
20
+ expect(decodeJwt(idToken).sub).toBe(users[0]!.id);
21
+ const subject = await getAuthSubjectsCollection().findOne({
22
+ identityKey: `email:${email}`,
23
+ });
24
+ expect(subject).not.toBeNull();
25
+ expect(await admin.links(users[0]!.id)).toContainEqual(
26
+ expect.objectContaining({
27
+ identityProvider: 'lca-api',
28
+ userId: subject!.subject,
29
+ })
30
+ );
31
+ expect(trace.length).toBeGreaterThanOrEqual(4);
32
+ expect(trace.every(hop => /^3\d\d /.test(hop))).toBe(true);
33
+ } finally {
34
+ for (const user of await admin.findUsers(email))
35
+ await admin.request(`/users/${user.id}`, { method: 'DELETE' });
36
+ await getAuthSubjectsCollection().deleteOne({ identityKey: `email:${email}` });
37
+ await client.close();
38
+ }
39
+ }, 60_000);
40
+ });