@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
@@ -9,7 +9,11 @@
9
9
  import { ServerClient } from 'postmark';
10
10
 
11
11
  import { renderEmail, resolveBranding } from '@learncard/email-templates';
12
- import type { TemplateId, TemplateDataMap } from '@learncard/email-templates';
12
+ import type {
13
+ TemplateId,
14
+ TemplateDataMap,
15
+ EscrowHoldCancelledReason,
16
+ } from '@learncard/email-templates';
13
17
 
14
18
  import { DeliveryService, Notification, isTemplateNotification } from '../delivery.service';
15
19
 
@@ -35,6 +39,11 @@ const LOCAL_TEMPLATE_ALIASES: Record<string, TemplateId> = {
35
39
  'account-approved': 'account-approved',
36
40
  'contact-method-verification': 'contact-method-verification',
37
41
  'account-sign-in-changed': 'account-sign-in-changed',
42
+ 'escrow-hold-started': 'escrow-hold-started',
43
+ 'escrow-hold-reminder': 'escrow-hold-reminder',
44
+ 'escrow-hold-released': 'escrow-hold-released',
45
+ 'escrow-hold-cancelled': 'escrow-hold-cancelled',
46
+ 'escrow-pin-locked': 'escrow-pin-locked',
38
47
  };
39
48
 
40
49
  /** Whether the given alias is a sentinel we registered (vs. a real Postmark alias). */
@@ -206,6 +215,39 @@ export class PostmarkAdapter implements DeliveryService {
206
215
  user: model.user as { displayName?: string } | undefined,
207
216
  };
208
217
 
218
+ case 'escrow-hold-started':
219
+ return {
220
+ requestedAt: (model.requestedAt as string) ?? '',
221
+ releaseAfter: (model.releaseAfter as string) ?? '',
222
+ cancelUrl: (model.cancelUrl as string) ?? '',
223
+ deviceHint: model.deviceHint as string | undefined,
224
+ };
225
+
226
+ case 'escrow-hold-reminder':
227
+ return {
228
+ releaseAfter: (model.releaseAfter as string) ?? '',
229
+ cancelUrl: (model.cancelUrl as string) ?? '',
230
+ };
231
+
232
+ case 'escrow-hold-released':
233
+ return {
234
+ completedAt: (model.completedAt as string) ?? '',
235
+ supportUrl: model.supportUrl as string | undefined,
236
+ };
237
+
238
+ case 'escrow-hold-cancelled':
239
+ return {
240
+ cancelledAt: (model.cancelledAt as string) ?? '',
241
+ reason: model.reason as EscrowHoldCancelledReason | undefined,
242
+ };
243
+
244
+ case 'escrow-pin-locked':
245
+ return {
246
+ lockedAt: (model.lockedAt as string) ?? '',
247
+ releaseAfter: model.releaseAfter as string | undefined,
248
+ cancelUrl: model.cancelUrl as string | undefined,
249
+ };
250
+
209
251
  default:
210
252
  return model as TemplateDataMap[TemplateId];
211
253
  }
@@ -14,19 +14,45 @@ existing host-trusted model, but does not protect against a malicious host eithe
14
14
  The API atomically reserves one of ten lifetime attempts before claiming the hold
15
15
  and calling the enclave. Failed releases burn the single-use hold. Exhaustion
16
16
  disables PIN recovery and cancels pending PIN holds; delayed recovery remains
17
- available. Successful PIN recovery does not replenish the lifetime budget; authenticated enrollment
18
- with a new PIN starts a fresh counter. Setting or changing a PIN requires client share
19
- rotation. Inbox compromise plus a correct PIN guess can recover the account
20
- (at most 10/1,000,000 for a uniformly random six-digit PIN).
17
+ available. Successful PIN recovery does not replenish the lifetime budget; authenticated
18
+ enrollment with a new PIN starts a fresh counter. Setting or changing a PIN requires
19
+ client share rotation. Inbox compromise plus a correct PIN guess can recover the
20
+ account (at most 10/1,000,000 for a uniformly random six-digit PIN).
21
+
22
+ Software hold records contain a non-cryptographic signature placeholder and no
23
+ ledger. The factory supplies its configured hold duration (seven days by default);
24
+ PIN holds have zero duration. Mongo remains responsible for single-use/cancellation.
21
25
 
22
26
  ## Nitro requirement
23
27
 
24
- The real Nitro enclave application lives on a separate branch and **must implement
25
- `carryPinVerifier`** with the same contract before PIN carry is available there:
28
+ In `remote` mode, the enclave-owned reserve → verify → commit ledger is the
29
+ intended security boundary (`services/escrow-enclave-app/src/policy.rs` `release_pin`
30
+ and `ledger.rs` `verify_pin`), pending full P1.8/P3.3 production wiring. The host-owned
31
+ Mongo counter (`reserveEscrowPinAttempt` and related helpers) remains defense-in-depth
32
+ and UX only, not the boundary against a malicious host.
33
+
34
+ D3 is **rollback detection, not prevention**: see
35
+ `services/escrow-enclave-app/README.md`, "D3: rollback detection, not prevention".
36
+ Do not treat the signed ledger as an independent freshness oracle. Per D13, the
37
+ ledger is enrollment-wide, bounded to 64 records, and shares a ten-lifetime-reservation
38
+ budget across holds for that enrollment.
39
+
40
+ New holds store the full opaque SignedHoldRecord before returning to callers;
41
+ release and cancellation forward that record rather than unsigned host fields.
42
+ Legacy Mongo rows without a record can still be cancelled locally, but cannot
43
+ release. Local cancellation atomically queues durable enclave revocation. The hourly
44
+ reminders job retries failures; only success confirms revocation. Unconfirmed
45
+ cancellations block new holds for the account and alarm within 24 hours of release
46
+ or while overdue. See `services/escrow-enclave-app/SECURITY.md` for the eventual
47
+ consistency, ambiguous terminal-error, and BLOCKER-ENROLLMENT limitations.
48
+
49
+ The real Nitro enclave application **implements `carryPinVerifier`** (P8.1/P8.2)
50
+ with the same contract described here:
26
51
 
27
52
  ```ts
28
53
  carryPinVerifier({ sourceEnvelope, targetEnvelope, expectedDid,
29
- sourceShareVersion, targetShareVersion }): Promise<{ envelope }>
54
+ sourceShareVersion, targetShareVersion,
55
+ sourceEnrollmentEpoch, targetEnrollmentEpoch }): Promise<{ envelope }>
30
56
  ```
31
57
 
32
58
  Decrypt both envelopes inside the enclave. Require both DIDs to equal `expectedDid`,
@@ -46,6 +72,51 @@ lost compare-and-swap falls back to normal PIN-less enrollment with a secret-fre
46
72
  Explicit new PIN enrollment is unchanged; explicit removal sends `clearPin: true`
47
73
  to bypass carry (it cannot be combined with `pinSalt`).
48
74
 
49
- TODO(escrow-nitro): Move the counter into enclave-internal, replicated state with
50
- decrement-before-decrypt semantics and rollback protection. Do not reuse the
51
- host-owned counter as the security boundary for a Nitro implementation.
75
+ `services/escrow-enclave-app/src/wire.rs` implements `carryPinVerifier` as
76
+ `wire::v1::Request::CarryPinVerifier`, dispatched in `src/server.rs` to
77
+ `Policy::carry_pin_verifier` (`src/policy.rs`), and reachable over HTTP at
78
+ `POST /v1/carry-pin-verifier` (emulator: `src/server/http.rs`; production parent:
79
+ `services/escrow-enclave-host`). `RemoteEnclave.carryPinVerifier` (`remoteEnclave.ts`)
80
+ calls that endpoint the same way `verifyEscrowBlob`/`releaseEscrow` do, with zod
81
+ response validation and the same fail-closed error mapping.
82
+
83
+ `carry_pin_verifier` decrypts/validates/reseals: unlike `createHold`/`release`/
84
+ `cancelHold`, it needs no `EnrollmentSource`, but now requires trusted time to
85
+ sign a destination-genesis Carried record. It can still
86
+ works in `remote` mode today even though every other mutating operation fails
87
+ closed with `Unavailable` under the enclave's BLOCKER-ENROLLMENT gate (see
88
+ `services/escrow-enclave-app/README.md`'s intro paragraph and `SECURITY.md`'s
89
+ "Open Items / Launch Blockers").
90
+
91
+ **P8.3 / v2**: it observes the source blob's ledger chain and writes the output
92
+ blob's first Carried record before returning ciphertext, so
93
+ the PIN attempt budget itself carries forward across a rotation instead of
94
+ resetting. Tenant, decrypted source DID hash and actual blob hash locate that chain;
95
+ `sourceEnrollmentEpoch` remains a signed record binding, never a chain selector.
96
+ `targetEnrollmentEpoch` binds carry output; rewrap preserves the source epoch.
97
+ The chain is verified with
98
+ the same signature/sequence/binding checks (`Ledger::verify_chain`), and a
99
+ chain that fails that verification is a fail-closed `Ledger`/`Unavailable`
100
+ error, not silently treated as empty. The target's `pinAttemptsFloor` becomes
101
+ `source.pinAttemptsFloor (default 0) + source_chain.attempts_used`, capped at
102
+ the ten-attempt lifetime maximum; a `release_pin` against a blob carrying that
103
+ floor only permits `10 - floor` further local attempts before locking, so a
104
+ carry — including a carry of a carry — never hands out a fresh budget and
105
+ never lowers whatever floor the source blob already had. `pinAttemptsFloor` is
106
+ stripped (like `pinVerifier`) before any release plaintext reaches the client:
107
+ it is enclave/ledger bookkeeping, not client-facing data. Positive floors must
108
+ match the chain's Carried genesis. The independent monitor checks source-head
109
+ accounting on inserts and sweeps. Choosing an older PIN-bearing copy with fewer
110
+ attempts still requires an authenticated current-enrollment authority to prevent.
111
+ Nitro never ran in production; there is no legacy-chain migration.
112
+
113
+ Software mode (`softwareEnclave.ts`) accepts both epoch fields but
114
+ ignores them: software mode has no enclave ledger to reset in the first place,
115
+ so there is no epoch-scoped budget to carry — its PIN attempt budget is the
116
+ host's `escrowPin.failedAttempts`/`verifiedFailedAttempts` counters in
117
+ MongoDB, which the enroll route already copies forward unchanged on every
118
+ carry, independent of `enrollmentEpoch`.
119
+
120
+ ## Key rotation
121
+
122
+ When the enclave key is rotated, previous keys are retained as read-only. The `assertFreshEscrowBlob` check allows carry eligibility for blobs sealed under a recognized previous key (stale-but-usable). The `escrowBlobRewrap` job runs periodically to migrate these blobs to the current key, skipping accounts with pending holds. Use `scripts/escrow-blob-mode-report.ts` with `--current-key-id` and `--previous-key-ids` to monitor migration progress.
@@ -0,0 +1,11 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIBnTCCAUKgAwIBAgIUX8sxxI8R2Ee+rvQYnO4eAo9Ink0wCgYIKoZIzj0EAwIw
3
+ GTEXMBUGA1UEAwwOcGlubmVkIHRlc3QgQ0EwIBcNMjYxMDAyMTczMjQ3WhgPMjEy
4
+ NjA5MDgxNzMyNDdaMBkxFzAVBgNVBAMMDnBpbm5lZCB0ZXN0IENBMFkwEwYHKoZI
5
+ zj0CAQYIKoZIzj0DAQcDQgAEfZw7amNFFbLuEIEhlMUMw5zy0OJsUy1eK8jAm6Pf
6
+ QgTg+A/vJXuPwQCcyGyLAnMRkKzLGFEuTFn4WbXhbxXXK6NmMGQwHQYDVR0OBBYE
7
+ FCppqmpr4350poKX2C9Zch+OLIqfMB8GA1UdIwQYMBaAFCppqmpr4350poKX2C9Z
8
+ ch+OLIqfMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgIEMAoGCCqG
9
+ SM49BAMCA0kAMEYCIQCFW0tlq57l7AkyRMv0QP1FsrDZp+t8TAhpPfBxThtuKQIh
10
+ ALrjfvsSzWNjYeQnuDYOBqB4AadnDE8lvkPdf6d5KDjn
11
+ -----END CERTIFICATE-----
@@ -0,0 +1,5 @@
1
+ -----BEGIN PRIVATE KEY-----
2
+ MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgdWjP158c+uimIld5
3
+ cyzEEh1k7BU6jfpb7TynI1SLneihRANCAAS1jfWWPshOTRLxqm9JB3u5LjXmC9LC
4
+ tNvWc49A7pYxqlr/cFmVkUkrB8JoFX23r05WmV5ZSvpu62BpGAT4TZQh
5
+ -----END PRIVATE KEY-----
@@ -0,0 +1,12 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIBvjCCAWSgAwIBAgIUHI1pe3GtEABXkmHdADf35RMT+4UwCgYIKoZIzj0EAwIw
3
+ GTEXMBUGA1UEAwwOcGlubmVkIHRlc3QgQ0EwIBcNMjYxMDAyMTczMjQ3WhgPMjEy
4
+ NjA5MDgxNzMyNDdaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDBZMBMGByqGSM49AgEG
5
+ CCqGSM49AwEHA0IABLWN9ZY+yE5NEvGqb0kHe7kuNeYL0sK029Zzj0DuljGqWv9w
6
+ WZWRSSsHwmgVfbevTlaZXllK+m7rYGkYBPhNlCGjgYwwgYkwDAYDVR0TAQH/BAIw
7
+ ADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwEwFAYDVR0RBA0w
8
+ C4IJbG9jYWxob3N0MB0GA1UdDgQWBBQ683SeqkVStM5taUD0Xl2iinptHzAfBgNV
9
+ HSMEGDAWgBQqaapqa+N+dKaCl9gvWXIfjiyKnzAKBggqhkjOPQQDAgNIADBFAiEA
10
+ 2NydSl5d/JAnib147fmSD97SiIOoYeR0U2TtKSCZOnICICXk+JJ7Zfi7e8orhL4M
11
+ qbFHUur9Li9EysqWauUlZ3Uf
12
+ -----END CERTIFICATE-----
@@ -0,0 +1,11 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIBmzCCAUCgAwIBAgIUL3KTPKqBu6PunDVaswtgh4DSsMcwCgYIKoZIzj0EAwIw
3
+ GDEWMBQGA1UEAwwNcm9ndWUgdGVzdCBDQTAgFw0yNjEwMDIxNzMyNDdaGA8yMTI2
4
+ MDkwODE3MzI0N1owGDEWMBQGA1UEAwwNcm9ndWUgdGVzdCBDQTBZMBMGByqGSM49
5
+ AgEGCCqGSM49AwEHA0IABJELPnDkTrlV1JvqGnw49Y1Ieq9X5LtRvk2YAi82u0dP
6
+ HKVAz/VDg6mjDZE0iCksMbySnMi21U15OCR9euVXgeujZjBkMB0GA1UdDgQWBBR4
7
+ j+2A3mYxGVN4wh5A+n7KgYrg/jAfBgNVHSMEGDAWgBR4j+2A3mYxGVN4wh5A+n7K
8
+ gYrg/jASBgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwICBDAKBggqhkjO
9
+ PQQDAgNJADBGAiEA2wS9nA9hRhvnq+bkfpahBQqRucjapHUii6ifKFPRW6kCIQDY
10
+ WBdfGkXMxHO3SAdWn7Os/bCTfzIfGSTf1RirYu0mxw==
11
+ -----END CERTIFICATE-----
@@ -0,0 +1,5 @@
1
+ -----BEGIN PRIVATE KEY-----
2
+ MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg5M9NiEz2zo+9wmbb
3
+ tUhG80uMcB12+M/+hk5lZFEH/UmhRANCAAQKfZQnflrAA6jmVBNSe1WnPxDkQOkv
4
+ RksIG/Iw0afcYucz6V33V1suoyAzWgEdMI7ZvoCMKBPUZxckdZ+nElFx
5
+ -----END PRIVATE KEY-----
@@ -0,0 +1,12 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIBvTCCAWOgAwIBAgIUIEjECAktIlQDeXXVSVMtVFKkd5IwCgYIKoZIzj0EAwIw
3
+ GDEWMBQGA1UEAwwNcm9ndWUgdGVzdCBDQTAgFw0yNjEwMDIxNzMyNDdaGA8yMTI2
4
+ MDkwODE3MzI0N1owFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYI
5
+ KoZIzj0DAQcDQgAECn2UJ35awAOo5lQTUntVpz8Q5EDpL0ZLCBvyMNGn3GLnM+ld
6
+ 91dbLqMgM1oBHTCO2b6AjCgT1GcXJHWfpxJRcaOBjDCBiTAMBgNVHRMBAf8EAjAA
7
+ MA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATAUBgNVHREEDTAL
8
+ gglsb2NhbGhvc3QwHQYDVR0OBBYEFO6h+RmT2lYntPXdm74FuKlCr/WJMB8GA1Ud
9
+ IwQYMBaAFHiP7YDeZjEZU3jCHkD6fsqBiuD+MAoGCCqGSM49BAMCA0gAMEUCIAsT
10
+ +CdriQ451QTzoudLSc5yITnlStq5DH5O5AqYDSijAiEAwiV+2jn2WsWjnoFDcrNm
11
+ zQQxmsZf1EofwSpxmH9t3Ks=
12
+ -----END CERTIFICATE-----
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import vectors from '../../../../../../packages/sss-key-manager/src/__fixtures__/escrow-vectors.json';
3
+ import { escrowBlobIdentity } from './blobIdentity';
4
+
5
+ const P256_P = BigInt('0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff');
6
+
7
+ const negatePoint = (base64: string): string => {
8
+ const point = Buffer.from(base64, 'base64');
9
+ const y = BigInt(`0x${point.subarray(33).toString('hex')}`);
10
+ const negated = Buffer.from((P256_P - y).toString(16).padStart(64, '0'), 'hex');
11
+ return Buffer.concat([point.subarray(0, 33), negated]).toString('base64');
12
+ };
13
+
14
+ describe('escrowBlobIdentity', () => {
15
+ const [first] = vectors.blobs;
16
+ if (!first) throw new Error('escrow-vectors.json has no blobs');
17
+ const { envelope } = first;
18
+
19
+ it('matches the enclave golden vector', () => {
20
+ expect(escrowBlobIdentity(envelope)).toBe(
21
+ '8090217ef11060bfa380c4a4b26d76a64811216176ecb60749652fabf3b09425'
22
+ );
23
+ });
24
+
25
+ it('is unchanged when the ephemeral point is negated', () => {
26
+ const twin = { ...envelope, ephemeralPublicKey: negatePoint(envelope.ephemeralPublicKey) };
27
+ expect(twin.ephemeralPublicKey).not.toBe(envelope.ephemeralPublicKey);
28
+ expect(escrowBlobIdentity(twin)).toBe(escrowBlobIdentity(envelope));
29
+ });
30
+
31
+ it('changes when the ciphertext changes', () => {
32
+ const ciphertext = Buffer.from(envelope.ciphertext, 'base64');
33
+ ciphertext.writeUInt8(ciphertext.readUInt8(0) ^ 1, 0);
34
+ expect(
35
+ escrowBlobIdentity({ ...envelope, ciphertext: ciphertext.toString('base64') })
36
+ ).not.toBe(escrowBlobIdentity(envelope));
37
+ });
38
+
39
+ it('rejects malformed envelopes', () => {
40
+ expect(() => escrowBlobIdentity({ ...envelope, ephemeralPublicKey: 'BA==' })).toThrow();
41
+ });
42
+ });
@@ -0,0 +1,55 @@
1
+ import { createHash } from 'crypto';
2
+
3
+ export interface EscrowEnvelopeFields {
4
+ version: number;
5
+ algorithm: string;
6
+ keyId: string;
7
+ ephemeralPublicKey: string;
8
+ salt: string;
9
+ iv: string;
10
+ ciphertext: string;
11
+ }
12
+
13
+ const DOMAIN = Buffer.from('learncard-escrow-blob-id-v1\0', 'utf8');
14
+
15
+ const lengthPrefixed = (field: Buffer): Buffer => {
16
+ const length = Buffer.alloc(8);
17
+ length.writeBigUInt64BE(BigInt(field.length));
18
+ return Buffer.concat([length, field]);
19
+ };
20
+
21
+ /**
22
+ * Mirrors `escrow_blob_identity` in services/escrow-enclave-app/src/crypto.rs.
23
+ *
24
+ * ECDH uses only the x-coordinate, so an envelope and its negated-point twin
25
+ * decrypt identically. Hashing decoded bytes with only the point's x-coordinate
26
+ * gives both the same identity, and neutralises base64/JSON serialization aliases.
27
+ */
28
+ export const escrowBlobIdentity = (envelope: EscrowEnvelopeFields): string => {
29
+ const point = Buffer.from(envelope.ephemeralPublicKey, 'base64');
30
+ const salt = Buffer.from(envelope.salt, 'base64');
31
+ const iv = Buffer.from(envelope.iv, 'base64');
32
+ const ciphertext = Buffer.from(envelope.ciphertext, 'base64');
33
+ if (
34
+ envelope.version !== 1 ||
35
+ point.length !== 65 ||
36
+ point[0] !== 4 ||
37
+ salt.length !== 32 ||
38
+ iv.length !== 12 ||
39
+ ciphertext.length < 16
40
+ )
41
+ throw new Error('Invalid escrow payload');
42
+
43
+ const hash = createHash('sha256').update(DOMAIN);
44
+ for (const field of [
45
+ Buffer.from([1]),
46
+ Buffer.from(envelope.algorithm, 'utf8'),
47
+ Buffer.from(envelope.keyId, 'utf8'),
48
+ point.subarray(1, 33),
49
+ salt,
50
+ iv,
51
+ ciphertext,
52
+ ])
53
+ hash.update(lengthPrefixed(field));
54
+ return hash.digest('hex');
55
+ };
@@ -0,0 +1,59 @@
1
+ import * as Sentry from '@sentry/serverless';
2
+ import {
3
+ confirmEscrowEnclaveCancellation,
4
+ recordEscrowEnclaveCancelAttempt,
5
+ ESCROW_HOLD_REMINDER_WINDOW_MS,
6
+ type EscrowHold,
7
+ type MongoUserKeyType,
8
+ } from '@models';
9
+ import { getEscrowEnclave } from './index';
10
+ import { escrowBlobIdentity } from './blobIdentity';
11
+
12
+ /** No provider identities, ciphertexts, tokens or transport error details in logs. */
13
+ export const logEscrowCancellationPending = (urgent: boolean): void => {
14
+ const message = urgent
15
+ ? 'Escrow enclave cancellation unconfirmed within 24h of release or overdue'
16
+ : 'Escrow enclave cancellation pending retry';
17
+ if (urgent) console.error('[escrow-cancellation]', message);
18
+ else console.warn('[escrow-cancellation]', message);
19
+ try {
20
+ Sentry.captureMessage(message, urgent ? 'error' : 'warning');
21
+ } catch {
22
+ console.error('[escrow-cancellation] cancellation alert delivery failed');
23
+ }
24
+ };
25
+
26
+ /** Mongo cancellation is authoritative for the API; only a positive enclave reply confirms revocation. */
27
+ export const cancelHoldInEnclave = async (
28
+ hold: EscrowHold,
29
+ userKey?: MongoUserKeyType | null,
30
+ now = new Date()
31
+ ): Promise<boolean> => {
32
+ try {
33
+ if (!(await recordEscrowEnclaveCancelAttempt(hold._id))) return true;
34
+ const envelope = hold.enclaveEnvelope ?? userKey?.escrowBlob?.envelope;
35
+ // Never cancel using a replacement enrollment or fabricate a legacy signed record.
36
+ if (
37
+ !hold.holdRecord ||
38
+ !envelope ||
39
+ escrowBlobIdentity(envelope) !== hold.holdRecord.hold.blobHash
40
+ ) {
41
+ throw new Error('Cancellation material unavailable');
42
+ }
43
+ await getEscrowEnclave().cancelHold({
44
+ envelope,
45
+ hold: hold.holdRecord,
46
+ clientEphemeralPublicKey: hold.clientEphemeralPublicKey,
47
+ expectedDid: hold.primaryDid,
48
+ });
49
+ await confirmEscrowEnclaveCancellation(hold._id);
50
+ return true;
51
+ } catch {
52
+ // Policy/unknown/terminal errors are ambiguous, not proof of cancellation.
53
+ // Includes lost success replies and BLOCKER-ENROLLMENT's Unavailable.
54
+ logEscrowCancellationPending(
55
+ hold.releaseAfter.getTime() <= now.getTime() + ESCROW_HOLD_REMINDER_WINDOW_MS
56
+ );
57
+ return false;
58
+ }
59
+ };
@@ -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];
@@ -4,28 +4,155 @@ import {
4
4
  parseEscrowPrivateKeys,
5
5
  type LcaApiEnvironment,
6
6
  } from '@environment';
7
+ import type { EscrowBlobStaleReason } from '@learncard/types';
7
8
  import { SoftwareEnclave } from './softwareEnclave';
9
+ import { createRemoteEnclave } from './remoteEnclave';
10
+ import { pinnedEnclaveCa } from './enclaveTrustAnchors';
8
11
  import { EscrowUnavailableError, type EscrowEnclave } from './types';
9
12
 
10
13
  export * from './types';
11
14
  export * from './softwareEnclave';
15
+ export * from './remoteEnclave';
12
16
  export * from './notifications';
13
17
 
14
18
  let enclave: EscrowEnclave | undefined;
15
19
  let config: LcaApiEnvironment = environment;
16
20
 
17
- export const isEscrowEnabled = (): boolean => Boolean(config.ESCROW_ENCLAVE_MODE);
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';
18
46
  export const getEscrowHoldDurationMs = (): number => config.ESCROW_HOLD_DURATION_MS;
19
47
  export const getEscrowHoldRestartMinAgeMs = (): number => config.ESCROW_HOLD_RESTART_MIN_AGE_MS;
20
48
  export const getEscrowEnclave = (): EscrowEnclave => {
21
49
  if (!isEscrowEnabled()) throw new EscrowUnavailableError();
22
50
  if (enclave) return enclave;
23
- if (config.ESCROW_ENCLAVE_MODE !== 'software') throw new EscrowUnavailableError();
24
- enclave = new SoftwareEnclave({
25
- privateKeys: parseEscrowPrivateKeys(config.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON ?? ''),
26
- activeKeyId: config.ESCROW_ENCLAVE_ACTIVE_KEY_ID ?? '',
27
- });
28
- 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;
29
156
  };
30
157
 
31
158
  /** Reset both singleton and environment snapshot after test environment changes. */
@@ -33,4 +160,15 @@ export const __setEscrowEnclaveForTests = (replacement: EscrowEnclave | undefine
33
160
  if (environment.NODE_ENV !== 'test') throw new EscrowUnavailableError();
34
161
  config = parseLcaApiEnvironment(process.env);
35
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;
36
174
  };