@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,34 @@
1
+ /**
2
+ * Private CAs for enclave-host TLS, pinned per hostname. When a CA is pinned
3
+ * the remote-enclave transport trusts ONLY that CA (not the system roots), and
4
+ * nothing else in lca-api trusts it. Changing a trust anchor is a reviewed
5
+ * code change, never an environment edit.
6
+ *
7
+ * Each CA is name-constrained to its single hostname; see
8
+ * services/escrow-enclave-app/STAGING.md section 4a.
9
+ */
10
+ const ENCLAVE_TRUST_ANCHORS: Readonly<Record<string, string>> = {
11
+ // Staging CA, valid until 2029-09-30. SHA-256 1BB676B1B969A916497AB7386A81AEBD
12
+ // 903469ED4B124685F43513B5FCB9E010; source: SSM
13
+ // /learncard/escrow-enclave/staging/ca-cert in account 217358003896.
14
+ 'escrow-enclave.staging.internal': `-----BEGIN CERTIFICATE-----
15
+ MIICgTCCAgigAwIBAgIUe/8u9Uy/Y9jD4Aw+cZCIeU/yap0wCgYIKoZIzj0EAwMw
16
+ VDEkMCIGA1UECgwbTGVhcm5pbmcgRWNvbm9teSBGb3VuZGF0aW9uMSwwKgYDVQQD
17
+ DCNMZWFybkNhcmQgRXNjcm93IEVuY2xhdmUgU3RhZ2luZyBDQTAeFw0yNjEwMDEy
18
+ MTI1MDJaFw0yOTA5MzAyMTI1MDJaMFQxJDAiBgNVBAoMG0xlYXJuaW5nIEVjb25v
19
+ bXkgRm91bmRhdGlvbjEsMCoGA1UEAwwjTGVhcm5DYXJkIEVzY3JvdyBFbmNsYXZl
20
+ IFN0YWdpbmcgQ0EwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARSwi8GmnzoqYadKw9n
21
+ gc7kOMMbimXjF5weA8YCOBhiaNHAxRcYJ0iz1nv74wYt/8x9lvwkFP4IK36i+bdH
22
+ fiu6Oo34LXi3AHBbQvL5A2c2IELil6Cea+ClWtJLfWDu7OOjgZowgZcwHwYDVR0j
23
+ BBgwFoAUybGIxOfLHqqbjDFJ1k28v4aKh4IwEgYDVR0TAQH/BAgwBgEB/wIBADAO
24
+ BgNVHQ8BAf8EBAMCAQYwMQYDVR0eAQH/BCcwJaAjMCGCH2VzY3Jvdy1lbmNsYXZl
25
+ LnN0YWdpbmcuaW50ZXJuYWwwHQYDVR0OBBYEFMmxiMTnyx6qm4wxSdZNvL+GioeC
26
+ MAoGCCqGSM49BAMDA2cAMGQCMGp/wTQLmtB6/GK6hMFaFTA27gXZcxrgJ0Uy+8C0
27
+ Ztl5uoRwN99inHNWv1cnKGdNQgIwSoQgKs8uMnBUcfiB9ZR6xb98z/9athp5pDIE
28
+ /KUv6cNmJtuCgPOJxC+4fVKl8xC3
29
+ -----END CERTIFICATE-----
30
+ `,
31
+ };
32
+
33
+ export const pinnedEnclaveCa = (baseUrl: string): string | undefined =>
34
+ ENCLAVE_TRUST_ANCHORS[new URL(baseUrl).hostname];
@@ -0,0 +1,174 @@
1
+ import {
2
+ environment,
3
+ parseLcaApiEnvironment,
4
+ parseEscrowPrivateKeys,
5
+ type LcaApiEnvironment,
6
+ } from '@environment';
7
+ import type { EscrowBlobStaleReason } from '@learncard/types';
8
+ import { SoftwareEnclave } from './softwareEnclave';
9
+ import { createRemoteEnclave } from './remoteEnclave';
10
+ import { pinnedEnclaveCa } from './enclaveTrustAnchors';
11
+ import { EscrowUnavailableError, type EscrowEnclave } from './types';
12
+
13
+ export * from './types';
14
+ export * from './softwareEnclave';
15
+ export * from './remoteEnclave';
16
+ export * from './notifications';
17
+
18
+ let enclave: EscrowEnclave | undefined;
19
+ let config: LcaApiEnvironment = environment;
20
+
21
+ // 'software' config validity is enforced at environment-parse time (see
22
+ // config/environment.ts), so reaching that branch always means it's ready.
23
+ // 'remote' config (URL/token) is deliberately NOT required at parse time, so
24
+ // its readiness is checked here instead; missing it fails this closed rather
25
+ // than crashing the whole service at boot.
26
+ export const isEscrowEnabled = (): boolean => {
27
+ if (config.ESCROW_ENCLAVE_MODE === 'software') {
28
+ // Serverless injects the actual deploy stage; NODE_ENV often remains development
29
+ // in Lambda. Unknown deployed stages fail closed, regardless of offline flags.
30
+ const allowed = config.LAMBDA_STAGE
31
+ ? ['dev', 'development', 'staging', 'test', 'local'].includes(config.LAMBDA_STAGE)
32
+ : config.NODE_ENV !== 'production';
33
+ if (!allowed) console.error('[escrow] Software enclave disabled for this deploy stage');
34
+ return allowed;
35
+ }
36
+ if (config.ESCROW_ENCLAVE_MODE === 'remote') {
37
+ return Boolean(config.ESCROW_ENCLAVE_REMOTE_URL && config.ESCROW_ENCLAVE_REMOTE_TOKEN);
38
+ }
39
+ return false;
40
+ };
41
+ export const isEscrowRemoteMode = (): boolean => config.ESCROW_ENCLAVE_MODE === 'remote';
42
+ // The client-facing attestation mode implied by the server's active backend
43
+ // ('remote' mode always attests as 'nitro' — see remoteEnclave.ts).
44
+ export const activeEscrowClientMode = (): 'software' | 'nitro' =>
45
+ isEscrowRemoteMode() ? 'nitro' : 'software';
46
+ export const getEscrowHoldDurationMs = (): number => config.ESCROW_HOLD_DURATION_MS;
47
+ export const getEscrowHoldRestartMinAgeMs = (): number => config.ESCROW_HOLD_RESTART_MIN_AGE_MS;
48
+ export const getEscrowEnclave = (): EscrowEnclave => {
49
+ if (!isEscrowEnabled()) throw new EscrowUnavailableError();
50
+ if (enclave) return enclave;
51
+ if (config.ESCROW_ENCLAVE_MODE === 'software') {
52
+ enclave = new SoftwareEnclave({
53
+ privateKeys: parseEscrowPrivateKeys(
54
+ config.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON ?? ''
55
+ ),
56
+ activeKeyId: config.ESCROW_ENCLAVE_ACTIVE_KEY_ID ?? '',
57
+ holdDurationMs: getEscrowHoldDurationMs(),
58
+ });
59
+ return enclave;
60
+ }
61
+ if (config.ESCROW_ENCLAVE_MODE === 'remote') {
62
+ const { ESCROW_ENCLAVE_REMOTE_URL: baseUrl, ESCROW_ENCLAVE_REMOTE_TOKEN: token } = config;
63
+ if (!baseUrl || !token) throw new EscrowUnavailableError();
64
+ enclave = createRemoteEnclave({
65
+ baseUrl,
66
+ token,
67
+ timeoutMs: config.ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS,
68
+ caPem: pinnedEnclaveCa(baseUrl),
69
+ });
70
+ return enclave;
71
+ }
72
+ throw new EscrowUnavailableError();
73
+ };
74
+
75
+ export type { EscrowBlobStaleReason };
76
+
77
+ interface EscrowAttestationIdentity {
78
+ keyId: string;
79
+ /** P9.1: retired keyIds the enclave still accepts for decrypt only. */
80
+ previousKeyIds: string[];
81
+ mode: 'software' | 'nitro';
82
+ }
83
+ interface EscrowAttestationCacheEntry extends EscrowAttestationIdentity {
84
+ fetchedAt: number;
85
+ }
86
+
87
+ // Keeps every login's status check off the enclave's critical path (a remote
88
+ // nitro backend can be slow or briefly unreachable; a normal sign-in must
89
+ // never wait on it). TTL and budget are process-local constants, not env
90
+ // config, since neither is an operator-facing tuning knob.
91
+ const ESCROW_ATTESTATION_CACHE_TTL_MS = 5 * 60 * 1000;
92
+ const ESCROW_ATTESTATION_STATUS_BUDGET_MS = 250;
93
+
94
+ let attestationCache: EscrowAttestationCacheEntry | undefined;
95
+ let attestationRefreshInFlight: Promise<EscrowAttestationIdentity> | undefined;
96
+
97
+ const isAttestationCacheFresh = (): boolean =>
98
+ !!attestationCache && Date.now() - attestationCache.fetchedAt < ESCROW_ATTESTATION_CACHE_TTL_MS;
99
+
100
+ // Single-flight: any number of concurrent cold-cache callers (status checks
101
+ // and release-enforcement checks alike) share one underlying getAttestation()
102
+ // call and its outcome, so a cache-expiry moment never causes a call fan-out
103
+ // to the enclave. Clears the in-flight marker on settle either way so the
104
+ // next cold call starts a fresh fetch; only a successful fetch warms the cache.
105
+ const refreshAttestationCache = (): Promise<EscrowAttestationIdentity> => {
106
+ attestationRefreshInFlight ??= (async () => {
107
+ try {
108
+ const attestation = await getEscrowEnclave().getAttestation();
109
+ const identity: EscrowAttestationIdentity = {
110
+ keyId: attestation.keyId,
111
+ previousKeyIds: attestation.previousKeyIds ?? [],
112
+ mode: attestation.mode,
113
+ };
114
+ attestationCache = { ...identity, fetchedAt: Date.now() };
115
+ return identity;
116
+ } finally {
117
+ attestationRefreshInFlight = undefined;
118
+ }
119
+ })();
120
+ return attestationRefreshInFlight;
121
+ };
122
+
123
+ /**
124
+ * Fail-closed identity lookup for release enforcement (startRecovery /
125
+ * completeRecovery): resolves instantly from a warm cache, otherwise awaits a
126
+ * real getAttestation() call (subject to the enclave's own normal timeout,
127
+ * e.g. `ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS`) and lets a failure propagate — a
128
+ * caller here must never treat an unreachable enclave as "fresh".
129
+ */
130
+ export const getEnclaveAttestationIdentity = (): Promise<EscrowAttestationIdentity> =>
131
+ isAttestationCacheFresh() ? Promise.resolve(attestationCache!) : refreshAttestationCache();
132
+
133
+ // Best-effort status check (used for the client's read-only enrollment state
134
+ // on every sign-in — see routes/keys.ts's getAuthShare — not for release
135
+ // enforcement; see getEnclaveAttestationIdentity above for the fail-closed
136
+ // version). Never awaits the enclave directly: a cold/failed cache still
137
+ // returns "not stale" within ESCROW_ATTESTATION_STATUS_BUDGET_MS, while a
138
+ // background refresh (already in flight, or started here) continues so a
139
+ // later call can see a warm cache.
140
+ export const getEscrowBlobStaleReason = async (blob: {
141
+ enclaveMode: 'software' | 'nitro';
142
+ enclaveKeyId: string;
143
+ }): Promise<EscrowBlobStaleReason | undefined> => {
144
+ if (!isEscrowEnabled()) return undefined;
145
+ if (blob.enclaveMode !== activeEscrowClientMode()) return 'mode-mismatch';
146
+ if (isAttestationCacheFresh()) {
147
+ return blob.enclaveKeyId !== attestationCache!.keyId ? 'key-rotated' : undefined;
148
+ }
149
+ const identity = await Promise.race([
150
+ refreshAttestationCache().catch(() => undefined),
151
+ new Promise<undefined>(resolve => {
152
+ setTimeout(() => resolve(undefined), ESCROW_ATTESTATION_STATUS_BUDGET_MS);
153
+ }),
154
+ ]);
155
+ return identity && blob.enclaveKeyId !== identity.keyId ? 'key-rotated' : undefined;
156
+ };
157
+
158
+ /** Reset both singleton and environment snapshot after test environment changes. */
159
+ export const __setEscrowEnclaveForTests = (replacement: EscrowEnclave | undefined): void => {
160
+ if (environment.NODE_ENV !== 'test') throw new EscrowUnavailableError();
161
+ config = parseLcaApiEnvironment(process.env);
162
+ enclave = replacement;
163
+ attestationCache = undefined;
164
+ attestationRefreshInFlight = undefined;
165
+ };
166
+
167
+ /** Invalidate the attestation cache without replacing the enclave singleton
168
+ * (e.g. to force the next freshness check to re-fetch against the same
169
+ * enclave, or to isolate tests from each other's cached state). */
170
+ export const __resetEscrowAttestationCacheForTests = (): void => {
171
+ if (environment.NODE_ENV !== 'test') throw new EscrowUnavailableError();
172
+ attestationCache = undefined;
173
+ attestationRefreshInFlight = undefined;
174
+ };
@@ -0,0 +1,377 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import * as Sentry from '@sentry/serverless';
3
+
4
+ import type { EscrowHold, MongoUserKeyType, RecoveryMethod } from '@models';
5
+ import type { TemplateNotification } from '../delivery';
6
+ import {
7
+ notifyEscrowHoldEvent,
8
+ ESCROW_NOTIFICATION_TIMEOUT_MS,
9
+ type EscrowHoldNotifierDeps,
10
+ type EscrowHoldEventKind,
11
+ } from './notifications';
12
+
13
+ const baseUserKey = (overrides: Partial<MongoUserKeyType> = {}): MongoUserKeyType => {
14
+ const now = new Date();
15
+ return {
16
+ contactMethod: { type: 'email', value: 'owner@example.com' },
17
+ authProviders: [{ type: 'firebase', id: 'user-1' }],
18
+ primaryDid: 'did:key:owner',
19
+ linkedDids: [],
20
+ keyProvider: 'sss',
21
+ shareVersion: 1,
22
+ previousAuthShares: [],
23
+ securityLevel: 'basic',
24
+ recoveryMethods: [],
25
+ migratedFromWeb3Auth: false,
26
+ createdAt: now,
27
+ updatedAt: now,
28
+ ...overrides,
29
+ };
30
+ };
31
+
32
+ const baseHold = (overrides: Partial<EscrowHold> = {}): EscrowHold => {
33
+ const now = new Date();
34
+ return {
35
+ holdRecord: {
36
+ hold: {
37
+ holdId: 'hold-1',
38
+ did: 'did:key:owner',
39
+ shareVersion: 1,
40
+ blobHash: 'ab'.repeat(32),
41
+ enrollmentEpoch: 1,
42
+ releasePolicy: 'hold',
43
+ clientEphemeralPublicKey: 'public-key',
44
+ createdLo: 0,
45
+ createdHi: 0,
46
+ policyVersion: 1,
47
+ signature: 'test-signature',
48
+ },
49
+ holdDurationMs: 0,
50
+ ledgerSeq: 0,
51
+ },
52
+ _id: 'hold-1',
53
+ authProvider: { type: 'firebase', id: 'user-1' },
54
+ primaryDid: 'did:key:owner',
55
+ shareVersion: 1,
56
+ status: 'pending',
57
+ identityProofType: 'auth-token',
58
+ requestedAt: now,
59
+ releaseAfter: now,
60
+ releasePolicy: 'hold',
61
+ clientEphemeralPublicKey: 'client-pub',
62
+ resumeTokenHash: 'a'.repeat(64),
63
+ notifications: [],
64
+ createdAt: now,
65
+ updatedAt: now,
66
+ ...overrides,
67
+ };
68
+ };
69
+
70
+ const confirmedRecoveryMethod = (confirmationEmail: string): RecoveryMethod => ({
71
+ type: 'email',
72
+ createdAt: new Date(),
73
+ confirmationStatus: 'confirmed',
74
+ confirmedAt: new Date(),
75
+ confirmationEmail,
76
+ });
77
+
78
+ const pendingRecoveryMethod = (confirmationEmail: string): RecoveryMethod => ({
79
+ type: 'email',
80
+ createdAt: new Date(),
81
+ confirmationStatus: 'pending',
82
+ confirmationEmail,
83
+ });
84
+
85
+ const makeDeps = (overrides: Partial<EscrowHoldNotifierDeps> = {}) => {
86
+ const sent: TemplateNotification[] = [];
87
+ const pushCalls: unknown[] = [];
88
+ const recorded: { holdId: string; kind: EscrowHoldEventKind }[] = [];
89
+ const deps: EscrowHoldNotifierDeps = {
90
+ send: async notification => {
91
+ sent.push(notification as TemplateNotification);
92
+ },
93
+ sendPush: async (notification): ReturnType<EscrowHoldNotifierDeps['sendPush']> => {
94
+ pushCalls.push(notification);
95
+ return { successCount: 1, failureCount: 0, failedTokens: [] };
96
+ },
97
+ now: () => new Date('2026-01-01T00:00:00.000Z'),
98
+ rotateCancelToken: async () => 'b'.repeat(64),
99
+ recordNotification: async (holdId, kind) => {
100
+ recorded.push({ holdId, kind });
101
+ },
102
+ ...overrides,
103
+ };
104
+ return { deps, sent, pushCalls, recorded };
105
+ };
106
+
107
+ describe('notifyEscrowHoldEvent', () => {
108
+ it('emails every verified address deduped case-insensitively, excluding unconfirmed methods', async () => {
109
+ const userKey = baseUserKey({
110
+ contactMethod: { type: 'email', value: 'Owner@Example.com' },
111
+ recoveryEmail: 'owner@example.com',
112
+ recoveryEmailVerifiedAt: new Date(),
113
+ recoveryMethods: [
114
+ confirmedRecoveryMethod('secondary@example.com'),
115
+ pendingRecoveryMethod('attacker@example.com'),
116
+ ],
117
+ });
118
+ const hold = baseHold({ completedAt: new Date() });
119
+ const { deps, sent } = makeDeps();
120
+
121
+ await notifyEscrowHoldEvent({ kind: 'completed', hold, userKey }, deps);
122
+
123
+ const recipients = sent.map(n => n.to).sort();
124
+ expect(recipients).toEqual(['owner@example.com', 'secondary@example.com']);
125
+ });
126
+
127
+ it('excludes a recoveryEmail that lacks recoveryEmailVerifiedAt', async () => {
128
+ const userKey = baseUserKey({
129
+ contactMethod: { type: 'phone', value: '+15555550100' },
130
+ recoveryEmail: 'unverified@example.com',
131
+ });
132
+ const hold = baseHold({ completedAt: new Date() });
133
+ const { deps, sent } = makeDeps();
134
+
135
+ await notifyEscrowHoldEvent({ kind: 'completed', hold, userKey }, deps);
136
+
137
+ expect(sent).toHaveLength(0);
138
+ });
139
+
140
+ it('sends the started email with a cancel link built from the plaintext cancelToken, never rotating', async () => {
141
+ const userKey = baseUserKey();
142
+ const hold = baseHold();
143
+ const rotateCancelToken = vi.fn(async () => {
144
+ throw new Error('must not rotate for started');
145
+ });
146
+ const { deps, sent } = makeDeps({ rotateCancelToken });
147
+ const cancelToken = 'a'.repeat(64);
148
+
149
+ await notifyEscrowHoldEvent({ kind: 'started', hold, userKey, cancelToken }, deps);
150
+
151
+ expect(rotateCancelToken).not.toHaveBeenCalled();
152
+ expect(sent).toHaveLength(1);
153
+ expect(sent[0]!.templateAlias).toBe('escrow-hold-started');
154
+ expect(sent[0]!.templateModel.cancelUrl).toContain(cancelToken);
155
+ expect(sent[0]!.templateModel.cancelUrl).toContain(hold._id);
156
+ });
157
+
158
+ it('rotates a fresh cancel token for reminder emails', async () => {
159
+ const userKey = baseUserKey();
160
+ const hold = baseHold();
161
+ const rotateCancelToken = vi.fn(async () => 'c'.repeat(64));
162
+ const { deps, sent } = makeDeps({ rotateCancelToken });
163
+
164
+ await notifyEscrowHoldEvent({ kind: 'reminder', hold, userKey }, deps);
165
+
166
+ expect(rotateCancelToken).toHaveBeenCalledWith(hold._id);
167
+ expect(sent[0]!.templateAlias).toBe('escrow-hold-reminder');
168
+ expect(sent[0]!.templateModel.cancelUrl).toContain('c'.repeat(64));
169
+ });
170
+
171
+ it('pin-locked with an active waiting-period hold rotates that hold (not the PIN hold) and links to it', async () => {
172
+ const userKey = baseUserKey();
173
+ // The PIN hold itself is already terminal by the time 'pin-locked' fires.
174
+ const hold = baseHold({
175
+ _id: 'pin-hold',
176
+ releasePolicy: 'pin',
177
+ status: 'cancelled',
178
+ cancelledBy: 'system',
179
+ cancelReason: 'pin-locked',
180
+ });
181
+ const activeHold = baseHold({
182
+ _id: 'active-hold',
183
+ releasePolicy: 'hold',
184
+ status: 'pending',
185
+ });
186
+ const rotateCancelToken = vi.fn(async () => 'd'.repeat(64));
187
+ const { deps, sent } = makeDeps({ rotateCancelToken });
188
+
189
+ await notifyEscrowHoldEvent({ kind: 'pin-locked', hold, userKey, activeHold }, deps);
190
+
191
+ expect(rotateCancelToken).toHaveBeenCalledExactlyOnceWith(activeHold._id);
192
+ expect(sent[0]!.templateAlias).toBe('escrow-pin-locked');
193
+ expect(sent[0]!.templateModel.cancelUrl).toContain('d'.repeat(64));
194
+ expect(sent[0]!.templateModel.cancelUrl).toContain(activeHold._id);
195
+ expect(sent[0]!.templateModel.releaseAfter).toBe(activeHold.releaseAfter.toISOString());
196
+ });
197
+
198
+ it('pin-locked without an active hold sends no cancelUrl/releaseAfter and never rotates the PIN hold', async () => {
199
+ const userKey = baseUserKey();
200
+ const hold = baseHold({
201
+ _id: 'pin-hold',
202
+ releasePolicy: 'pin',
203
+ status: 'cancelled',
204
+ cancelledBy: 'system',
205
+ cancelReason: 'pin-locked',
206
+ });
207
+ const rotateCancelToken = vi.fn(async () => 'e'.repeat(64));
208
+ const { deps, sent } = makeDeps({ rotateCancelToken });
209
+
210
+ await notifyEscrowHoldEvent({ kind: 'pin-locked', hold, userKey }, deps);
211
+
212
+ expect(rotateCancelToken).not.toHaveBeenCalled();
213
+ expect(sent[0]!.templateAlias).toBe('escrow-pin-locked');
214
+ expect(sent[0]!.templateModel).not.toHaveProperty('cancelUrl');
215
+ expect(sent[0]!.templateModel).not.toHaveProperty('releaseAfter');
216
+ });
217
+
218
+ it('pin-locked treats a non-pending activeHold the same as no active hold', async () => {
219
+ const userKey = baseUserKey();
220
+ const hold = baseHold({ _id: 'pin-hold', releasePolicy: 'pin', status: 'cancelled' });
221
+ const activeHold = baseHold({ _id: 'stale-hold', status: 'completed' });
222
+ const rotateCancelToken = vi.fn(async () => 'f'.repeat(64));
223
+ const { deps, sent } = makeDeps({ rotateCancelToken });
224
+
225
+ await notifyEscrowHoldEvent({ kind: 'pin-locked', hold, userKey, activeHold }, deps);
226
+
227
+ expect(rotateCancelToken).not.toHaveBeenCalled();
228
+ expect(sent[0]!.templateModel).not.toHaveProperty('cancelUrl');
229
+ });
230
+
231
+ it('skips the reminder email entirely when a fresh token cannot be issued', async () => {
232
+ const userKey = baseUserKey();
233
+ const hold = baseHold();
234
+ const { deps, sent } = makeDeps({ rotateCancelToken: async () => null });
235
+
236
+ await notifyEscrowHoldEvent({ kind: 'reminder', hold, userKey }, deps);
237
+
238
+ expect(sent).toHaveLength(0);
239
+ });
240
+
241
+ it('sends a push notification to the account DID for every kind', async () => {
242
+ const userKey = baseUserKey({ contactMethod: { type: 'phone', value: '+15555550100' } });
243
+ const hold = baseHold();
244
+ const { deps, sent, pushCalls } = makeDeps();
245
+
246
+ await notifyEscrowHoldEvent(
247
+ { kind: 'started', hold, userKey, cancelToken: 'a'.repeat(64) },
248
+ deps
249
+ );
250
+
251
+ expect(sent).toHaveLength(0);
252
+ expect(pushCalls).toEqual([
253
+ expect.objectContaining({ type: 'APP_NOTIFICATION', to: { did: userKey.primaryDid } }),
254
+ ]);
255
+ });
256
+
257
+ it('keeps the security email pending even when push succeeds and logs only safe metadata', async () => {
258
+ const userKey = baseUserKey();
259
+ const hold = baseHold();
260
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
261
+ const sentrySpy = vi.spyOn(Sentry, 'captureException').mockImplementation(() => 'event-id');
262
+ const { deps, recorded } = makeDeps({
263
+ send: async () => {
264
+ throw new Error('Postmark rejected owner@example.com');
265
+ },
266
+ });
267
+
268
+ await notifyEscrowHoldEvent(
269
+ { kind: 'started', hold, userKey, cancelToken: 'a'.repeat(64) },
270
+ deps
271
+ );
272
+
273
+ expect(recorded).toEqual([]);
274
+ expect(errorSpy).toHaveBeenCalled();
275
+ expect(sentrySpy).toHaveBeenCalled();
276
+
277
+ const loggedText = JSON.stringify([...errorSpy.mock.calls, ...sentrySpy.mock.calls]);
278
+ expect(loggedText).not.toContain('owner@example.com');
279
+ expect(loggedText).not.toContain(userKey.primaryDid);
280
+ expect(loggedText).not.toContain('a'.repeat(64));
281
+
282
+ errorSpy.mockRestore();
283
+ sentrySpy.mockRestore();
284
+ });
285
+
286
+ it('never throws and records nothing when every channel fails', async () => {
287
+ const userKey = baseUserKey();
288
+ const hold = baseHold();
289
+ vi.spyOn(console, 'error').mockImplementation(() => {});
290
+ vi.spyOn(Sentry, 'captureException').mockImplementation(() => 'event-id');
291
+ const { deps, recorded } = makeDeps({
292
+ send: async () => {
293
+ throw new Error('email down');
294
+ },
295
+ sendPush: async () => {
296
+ throw new Error('push down');
297
+ },
298
+ });
299
+
300
+ await expect(
301
+ notifyEscrowHoldEvent(
302
+ { kind: 'started', hold, userKey, cancelToken: 'a'.repeat(64) },
303
+ deps
304
+ )
305
+ ).resolves.toBe(false);
306
+ expect(recorded).toHaveLength(0);
307
+
308
+ vi.restoreAllMocks();
309
+ });
310
+
311
+ it('contains template token and persistence rejections', async () => {
312
+ for (const overrides of [
313
+ {
314
+ rotateCancelToken: async () => {
315
+ throw new Error('token DB failed');
316
+ },
317
+ },
318
+ {
319
+ recordNotification: async () => {
320
+ throw new Error('record DB failed');
321
+ },
322
+ },
323
+ ]) {
324
+ const { deps } = makeDeps(overrides);
325
+ await expect(
326
+ notifyEscrowHoldEvent(
327
+ { kind: 'reminder', hold: baseHold(), userKey: baseUserKey() },
328
+ deps
329
+ )
330
+ ).resolves.toBe(false);
331
+ }
332
+ });
333
+
334
+ it.each(['started', 'reminder'] as const)(
335
+ 'retries %s when only one verified inbox receives its email',
336
+ async kind => {
337
+ const { deps, recorded } = makeDeps({
338
+ send: async notification => {
339
+ if (notification.to === 'recovery@example.com')
340
+ throw new Error('delivery failed');
341
+ },
342
+ });
343
+ const userKey = baseUserKey({
344
+ recoveryEmail: 'recovery@example.com',
345
+ recoveryEmailVerifiedAt: new Date(),
346
+ });
347
+ expect(await notifyEscrowHoldEvent({ kind, hold: baseHold(), userKey }, deps)).toBe(
348
+ false
349
+ );
350
+ expect(recorded).toEqual([]);
351
+ }
352
+ );
353
+
354
+ it('bounds hanging delivery and consumes its late rejection', async () => {
355
+ vi.useFakeTimers();
356
+ try {
357
+ let rejectSend!: (error: Error) => void;
358
+ const { deps, recorded } = makeDeps({
359
+ send: () =>
360
+ new Promise((_, reject) => {
361
+ rejectSend = reject;
362
+ }),
363
+ });
364
+ const pending = notifyEscrowHoldEvent(
365
+ { kind: 'reminder', hold: baseHold(), userKey: baseUserKey() },
366
+ deps
367
+ );
368
+ await vi.advanceTimersByTimeAsync(ESCROW_NOTIFICATION_TIMEOUT_MS);
369
+ expect(await pending).toBe(false);
370
+ rejectSend(new Error('late failure'));
371
+ await vi.runAllTimersAsync();
372
+ expect(recorded).toEqual([]);
373
+ } finally {
374
+ vi.useRealTimers();
375
+ }
376
+ });
377
+ });