@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,177 @@
1
+ import { resolveTenantFromRequest, type ResolvedTenant } from '@learncard/email-templates';
2
+ import {
3
+ expireStaleEscrowHolds,
4
+ findEscrowHoldsPendingEnclaveCancellation,
5
+ countEscrowCancellationsNearRelease,
6
+ findEscrowHoldsDueForReminder,
7
+ claimEscrowHoldForReminder,
8
+ findUserKeyByAuthProvider,
9
+ findEscrowHoldsPendingStartNotification,
10
+ releaseEscrowReminderClaim,
11
+ recordEscrowStartNotificationAttempt,
12
+ } from '@models';
13
+ import { environment } from '@environment';
14
+ import {
15
+ cancelHoldInEnclave,
16
+ logEscrowCancellationPending,
17
+ } from '../services/escrow-enclave/cancellation';
18
+ import { isEscrowEnabled, notifyEscrowHoldEvent } from '../services/escrow-enclave';
19
+
20
+ // Two queues of ten 3-second deliveries leave half the 120-second Lambda
21
+ // budget for database work. Least-recently-attempted ordering prevents starvation.
22
+ const DEFAULT_REMINDER_BATCH_LIMIT = 10;
23
+
24
+ export interface EscrowHoldReminderResult {
25
+ reminded: number;
26
+ expired: number;
27
+ failed: number;
28
+ }
29
+
30
+ export interface RunEscrowHoldRemindersOptions {
31
+ now?: Date;
32
+ limit?: number;
33
+ /**
34
+ * Test-only seam; defaults to the real `notifyEscrowHoldEvent`. Lets
35
+ * tests force a deterministic delivery failure without touching
36
+ * Postmark/FCM. Real callers (the Lambda handler) never pass this, so
37
+ * the effective public signature stays `{ now?, limit? }`.
38
+ */
39
+ notify?: typeof notifyEscrowHoldEvent;
40
+ }
41
+
42
+ /**
43
+ * Given a hold's stored `tenantId`, resolves the same `ResolvedTenant` shape
44
+ * route handlers get from `ctx.tenant`. A scheduled job has no request to
45
+ * read headers from, so this reuses `resolveTenantFromRequest`'s third
46
+ * resolution step directly (deploy-level id, same as `DEFAULT_TENANT_ID`)
47
+ * rather than duplicating the id → branding lookup it already does. Holds
48
+ * created before `tenantId` existed fall back to `DEFAULT_TENANT_ID` / the
49
+ * package's own 'learncard' default, exactly like any other unheadered call.
50
+ */
51
+ const resolveTenantById = (tenantId: string | undefined): ResolvedTenant =>
52
+ resolveTenantFromRequest({}, tenantId);
53
+
54
+ /**
55
+ * Deliberately drops the caught error's message, not just its stack: a real
56
+ * delivery-channel rejection (Postmark/FCM) can embed the recipient address
57
+ * in its own message text, so forwarding it here would leak PII through the
58
+ * one logging path meant to report the failure. Mirrors
59
+ * `notifications.ts`'s `logDeliveryFailure`, which makes the same trade-off
60
+ * for the same reason. The error's name/constructor is still useful for
61
+ * triage (e.g. "MongoServerError" vs "TypeError") without that risk.
62
+ */
63
+ const logJobError = (holdId: string, error: unknown): void => {
64
+ console.error('[escrow-hold-reminders] failed to process hold', {
65
+ holdId,
66
+ errorType: error instanceof Error ? error.name : typeof error,
67
+ });
68
+ };
69
+
70
+ /**
71
+ * Hourly maintenance job: expires stale pending holds, then sends a T-24h
72
+ * `reminder` notification to every pending, hold-policy escrow hold whose
73
+ * waiting period ends within 24h and that has never been reminded.
74
+ *
75
+ * Escrow can be entirely disabled (no `ESCROW_ENCLAVE_MODE`) on a given
76
+ * stage/tenant deploy. Rather than a Serverless-level `schedule.enabled`
77
+ * conditional (its value must be a literal boolean, not an interpolated env
78
+ * string, so gating it from `${env:ESCROW_ENCLAVE_MODE}` is fragile), the
79
+ * schedule stays always-enabled in serverless.yml and this job no-ops
80
+ * instead — the same disabled/enabled signal the escrow router itself uses
81
+ * via `getEscrowEnclave()` throwing when `isEscrowEnabled()` is false.
82
+ */
83
+ export const runEscrowHoldReminders = async (
84
+ options: RunEscrowHoldRemindersOptions = {}
85
+ ): Promise<EscrowHoldReminderResult> => {
86
+ const empty: EscrowHoldReminderResult = { reminded: 0, expired: 0, failed: 0 };
87
+ const now = options.now ?? new Date();
88
+ if (!isEscrowEnabled()) {
89
+ // Disabling recovery must not silence outstanding revocation alarms.
90
+ if (await countEscrowCancellationsNearRelease(now)) logEscrowCancellationPending(true);
91
+ return empty;
92
+ }
93
+ const limit = Math.max(
94
+ 1,
95
+ Math.min(options.limit ?? DEFAULT_REMINDER_BATCH_LIMIT, DEFAULT_REMINDER_BATCH_LIMIT)
96
+ );
97
+ const notify = options.notify ?? notifyEscrowHoldEvent;
98
+
99
+ const expired = await expireStaleEscrowHolds(now);
100
+ const candidates = await findEscrowHoldsDueForReminder(now, limit);
101
+
102
+ let reminded = 0;
103
+ let failed = 0;
104
+
105
+ // Budget at most 30s of enclave calls beside the two 30s delivery queues.
106
+ // Attempts ordering ensures a repeatedly unavailable cancellation cannot starve others.
107
+ const cancelLimit = Math.min(
108
+ limit,
109
+ 3,
110
+ Math.max(1, Math.floor(30_000 / environment.ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS))
111
+ );
112
+ for (const hold of await findEscrowHoldsPendingEnclaveCancellation(cancelLimit)) {
113
+ try {
114
+ const userKey = hold.enclaveEnvelope
115
+ ? undefined
116
+ : await findUserKeyByAuthProvider(hold.authProvider.type, hold.authProvider.id);
117
+ if (!(await cancelHoldInEnclave(hold, userKey, now))) failed += 1;
118
+ } catch (error) {
119
+ failed += 1;
120
+ logJobError(hold._id, error);
121
+ }
122
+ }
123
+ // Check the whole backlog, not only this run's bounded retry batch.
124
+ if (await countEscrowCancellationsNearRelease(now)) logEscrowCancellationPending(true);
125
+
126
+ // A hold is inserted with this marker before the route attempts delivery,
127
+ // so Lambda freezes/timeouts cannot lose the security-critical start email.
128
+ for (const hold of await findEscrowHoldsPendingStartNotification(limit)) {
129
+ try {
130
+ await recordEscrowStartNotificationAttempt(hold._id, now);
131
+ const userKey = await findUserKeyByAuthProvider(
132
+ hold.authProvider.type,
133
+ hold.authProvider.id
134
+ );
135
+ if (
136
+ userKey &&
137
+ !(await notify({
138
+ kind: 'started',
139
+ hold,
140
+ userKey,
141
+ tenant: resolveTenantById(hold.tenantId),
142
+ }))
143
+ )
144
+ failed += 1;
145
+ } catch (error) {
146
+ failed += 1;
147
+ logJobError(hold._id, error);
148
+ }
149
+ }
150
+
151
+ for (const candidate of candidates) {
152
+ let claimed: Awaited<ReturnType<typeof claimEscrowHoldForReminder>> = null;
153
+ try {
154
+ // Lease before send; only successful delivery writes the sent marker.
155
+ claimed = await claimEscrowHoldForReminder(candidate._id, now);
156
+ if (!claimed) continue;
157
+
158
+ const userKey = await findUserKeyByAuthProvider(
159
+ claimed.authProvider.type,
160
+ claimed.authProvider.id
161
+ );
162
+ if (!userKey) continue;
163
+
164
+ const tenant = resolveTenantById(claimed.tenantId);
165
+
166
+ if (await notify({ kind: 'reminder', hold: claimed, userKey, tenant })) reminded += 1;
167
+ else failed += 1;
168
+ } catch (error) {
169
+ failed += 1;
170
+ logJobError(candidate._id, error);
171
+ } finally {
172
+ if (claimed) await releaseEscrowReminderClaim(claimed);
173
+ }
174
+ }
175
+
176
+ return { reminded, expired, failed };
177
+ };
@@ -0,0 +1,430 @@
1
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import type { Db, Document, Filter } from 'mongodb';
3
+
4
+ import { SIGNING_AUTHORITIES_COLLECTION } from '@models';
5
+ import {
6
+ getSeedIdentity,
7
+ logSeedEncryptionFailure,
8
+ parseSeedEnvelope,
9
+ SeedEncryptionError,
10
+ seedEncryption,
11
+ } from '@helpers/seedEncryption.helpers';
12
+ import type { EncryptedSigningAuthoritySeed } from 'types/seed-encryption';
13
+ import type {
14
+ MigrationSigningAuthority,
15
+ SeedMigrationCounts,
16
+ SeedMigrationReceipt,
17
+ SeedMigrationRequest,
18
+ SeedMigrationResult,
19
+ SeedMigrationState,
20
+ } from 'types/seed-migration';
21
+
22
+ export const SEED_MIGRATION_STATE_COLLECTION = 'signingauthorityseedmigration';
23
+ export const SEED_MIGRATION_RECEIPTS_COLLECTION = 'signingauthorityseedverification';
24
+ const STATE_ID = 'sa-seeds-v1';
25
+ const LEASE_MS = 16 * 60 * 1000; // Longer than the Lambda's maximum lifetime.
26
+ const ENVELOPE_FIELDS = ['encryptedSeed', 'encryptedDek', 'keyVersion'] as const;
27
+ const RECEIPT_FIELDS = [...ENVELOPE_FIELDS, 'ownerDid', 'name', 'did'] as const;
28
+
29
+ export class SeedMigrationError extends Error {
30
+ constructor(public readonly category: string) {
31
+ super(`Signing authority seed migration stopped: ${category}`);
32
+ this.name = 'SeedMigrationError';
33
+ }
34
+ }
35
+
36
+ const hasField = (field: string): Document => ({ $ne: [{ $type: `$${field}` }, 'missing'] });
37
+ const isString = (field: string): Document => ({ $eq: [{ $type: `$${field}` }, 'string'] });
38
+
39
+ /** One aggregation reconciles the collection; cryptographic validity is checked in verify/purge. */
40
+ export const countSeedMigrationDocuments = async (db: Db): Promise<SeedMigrationCounts> => {
41
+ const encrypted = {
42
+ $and: [
43
+ { $in: ['$keyVersion', ['kms-v1', 'local-v1']] },
44
+ isString('encryptedSeed'),
45
+ isString('encryptedDek'),
46
+ { $ne: ['$encryptedSeed', ''] },
47
+ { $ne: ['$encryptedDek', ''] },
48
+ ],
49
+ };
50
+ const legacy = {
51
+ $and: [
52
+ ...ENVELOPE_FIELDS.map(field => ({ $eq: [{ $type: `$${field}` }, 'missing'] })),
53
+ isString('seed'),
54
+ { $ne: ['$seed', ''] },
55
+ ],
56
+ };
57
+ const result = await db
58
+ .collection(SIGNING_AUTHORITIES_COLLECTION)
59
+ .aggregate<SeedMigrationCounts>([
60
+ {
61
+ $group: {
62
+ _id: null,
63
+ total: { $sum: 1 },
64
+ encrypted: { $sum: { $cond: [encrypted, 1, 0] } },
65
+ legacyOnly: { $sum: { $cond: [legacy, 1, 0] } },
66
+ malformed: { $sum: { $cond: [{ $or: [encrypted, legacy] }, 0, 1] } },
67
+ plaintextRemaining: { $sum: { $cond: [hasField('seed'), 1, 0] } },
68
+ },
69
+ },
70
+ { $project: { _id: 0 } },
71
+ ])
72
+ .next();
73
+ return result ?? { total: 0, encrypted: 0, legacyOnly: 0, malformed: 0, plaintextRemaining: 0 };
74
+ };
75
+
76
+ const snapshotFilter = (record: MigrationSigningAuthority): Filter<MigrationSigningAuthority> => {
77
+ const filter: Document = { _id: record._id };
78
+ for (const field of [...RECEIPT_FIELDS, 'seed']) {
79
+ filter[field] = Object.hasOwn(record, field) ? { $eq: record[field] } : { $exists: false };
80
+ }
81
+ return filter;
82
+ };
83
+
84
+ const sameSeed = (actual: string, expected: string): boolean => {
85
+ const left = Buffer.from(actual);
86
+ const right = Buffer.from(expected);
87
+ try {
88
+ return left.length === right.length && timingSafeEqual(left, right);
89
+ } finally {
90
+ left.fill(0);
91
+ right.fill(0);
92
+ }
93
+ };
94
+
95
+ const verifyRecord = async (
96
+ record: MigrationSigningAuthority
97
+ ): Promise<EncryptedSigningAuthoritySeed> => {
98
+ const envelope = parseSeedEnvelope(record);
99
+ if (!envelope) throw new SeedMigrationError('legacy_record_remaining');
100
+ const seed = await seedEncryption.decrypt(envelope, getSeedIdentity(record));
101
+ if (
102
+ Object.hasOwn(record, 'seed') &&
103
+ (typeof record.seed !== 'string' || !sameSeed(seed, record.seed))
104
+ ) {
105
+ throw new SeedMigrationError('seed_mismatch');
106
+ }
107
+ return envelope;
108
+ };
109
+
110
+ const receiptFor = (
111
+ record: MigrationSigningAuthority,
112
+ envelope: EncryptedSigningAuthoritySeed,
113
+ epoch: string
114
+ ): SeedMigrationReceipt => ({
115
+ _id: record._id,
116
+ epoch,
117
+ ...envelope,
118
+ ownerDid: record.ownerDid,
119
+ name: record.name,
120
+ did: record.did ?? null,
121
+ });
122
+
123
+ // Compare receipts with CURRENT documents. The final full sweep catches inserts and changes
124
+ // behind the batch cursor before granting permission to purge.
125
+ const pendingVerification = (epoch: string): Document[] => [
126
+ {
127
+ $lookup: {
128
+ from: SEED_MIGRATION_RECEIPTS_COLLECTION,
129
+ localField: '_id',
130
+ foreignField: '_id',
131
+ as: '_verification',
132
+ },
133
+ },
134
+ {
135
+ $match: {
136
+ $expr: {
137
+ $not: [
138
+ {
139
+ $and: [
140
+ { $eq: [{ $arrayElemAt: ['$_verification.epoch', 0] }, epoch] },
141
+ ...RECEIPT_FIELDS.map(field => ({
142
+ $eq: [
143
+ {
144
+ $ifNull: [
145
+ { $arrayElemAt: [`$_verification.${field}`, 0] },
146
+ null,
147
+ ],
148
+ },
149
+ { $ifNull: [`$${field}`, null] },
150
+ ],
151
+ })),
152
+ ],
153
+ },
154
+ ],
155
+ },
156
+ },
157
+ },
158
+ { $project: { _verification: 0 } },
159
+ ];
160
+
161
+ /** Mongo comparisons are type-bracketed; strings sort before historical ObjectId IDs. */
162
+ const afterCursor = (
163
+ cursor?: MigrationSigningAuthority['_id']
164
+ ): Filter<MigrationSigningAuthority> => {
165
+ if (cursor === undefined) return {};
166
+ if (typeof cursor === 'string') {
167
+ return { $or: [{ _id: { $gt: cursor } }, { _id: { $type: 'objectId' } }] };
168
+ }
169
+ return { _id: { $gt: cursor } };
170
+ };
171
+
172
+ /**
173
+ * Process one idempotent batch. The caller repeats until done. No seed is returned or checkpointed.
174
+ * All writes use majority acknowledgement; purge requires a completed verification epoch.
175
+ */
176
+ export const runSeedMigrationBatch = async (
177
+ db: Db,
178
+ request: SeedMigrationRequest,
179
+ options: { encryptedWritesEnabled: boolean; remainingTime?: () => number }
180
+ ): Promise<SeedMigrationResult> => {
181
+ if (
182
+ !['dry-run', 'prepare', 'verify', 'purge'].includes(request.phase) ||
183
+ (request.batchSize !== undefined &&
184
+ (!Number.isInteger(request.batchSize) ||
185
+ request.batchSize < 1 ||
186
+ request.batchSize > 100))
187
+ ) {
188
+ throw new SeedMigrationError('invalid_request');
189
+ }
190
+ const phase = request.phase;
191
+ const batchSize = request.batchSize ?? 50;
192
+ if (phase === 'dry-run') {
193
+ const result = {
194
+ phase,
195
+ done: true,
196
+ processed: 0,
197
+ counts: await countSeedMigrationDocuments(db),
198
+ countsReconciled: true,
199
+ };
200
+ console.info({ event: 'signing_authority_seed_migration', ...result });
201
+ return result;
202
+ }
203
+ if (!options.encryptedWritesEnabled) throw new SeedMigrationError('encrypted_writes_required');
204
+
205
+ const states = db.collection<SeedMigrationState>(SEED_MIGRATION_STATE_COLLECTION);
206
+ const receipts = db.collection<SeedMigrationReceipt>(SEED_MIGRATION_RECEIPTS_COLLECTION);
207
+ const authorities = db.collection<MigrationSigningAuthority>(SIGNING_AUTHORITIES_COLLECTION);
208
+ const writeConcern = { w: 'majority' as const };
209
+ // Initialize separately so a held lease never makes an upsert overwrite/duplicate the singleton.
210
+ try {
211
+ await states.updateOne(
212
+ { _id: STATE_ID },
213
+ { $setOnInsert: { _id: STATE_ID } },
214
+ { upsert: true, writeConcern }
215
+ );
216
+ } catch {
217
+ throw new SeedMigrationError('checkpoint_unavailable');
218
+ }
219
+ const token = randomUUID();
220
+ const state = await states.findOneAndUpdate(
221
+ {
222
+ _id: STATE_ID,
223
+ $or: [{ leaseOwner: { $exists: false } }, { leaseExpiresAt: { $lte: new Date() } }],
224
+ },
225
+ { $set: { leaseOwner: token, leaseExpiresAt: new Date(Date.now() + LEASE_MS) } },
226
+ {
227
+ returnDocument: 'after',
228
+ writeConcern,
229
+ }
230
+ );
231
+ if (!state) throw new SeedMigrationError('worker_busy');
232
+ const lease = { _id: STATE_ID, leaseOwner: token };
233
+ let current: MigrationSigningAuthority | undefined;
234
+ let processed = 0;
235
+ try {
236
+ const resuming = state.phase === phase && state.status === 'running' && !!state.counts;
237
+ const countsBefore = resuming ? state.counts! : await countSeedMigrationDocuments(db);
238
+ if (countsBefore.malformed) throw new SeedMigrationError('malformed_records');
239
+ if (
240
+ phase === 'purge' &&
241
+ (!state.verifiedEpoch || countsBefore.encrypted !== countsBefore.total)
242
+ ) {
243
+ throw new SeedMigrationError('verification_required');
244
+ }
245
+ const epoch =
246
+ phase === 'verify'
247
+ ? ((state.phase === 'verify' && state.status !== 'failed'
248
+ ? state.epoch
249
+ : undefined) ?? randomUUID())
250
+ : (state.verifiedEpoch ?? '');
251
+ const checkpoint: Partial<SeedMigrationState> = {
252
+ phase,
253
+ status: 'running',
254
+ counts: countsBefore,
255
+ updatedAt: new Date(),
256
+ };
257
+ let cursor = resuming ? state.cursor : undefined;
258
+ if (phase === 'verify') checkpoint.epoch = epoch;
259
+ await states.updateOne(
260
+ lease,
261
+ {
262
+ $set: checkpoint,
263
+ // A killed worker or failed checkpoint write must not leave purge authorized.
264
+ // A successful purge batch restores its epoch so the next batch can resume.
265
+ $unset: { verifiedEpoch: '', ...(!resuming ? { cursor: '' } : {}) },
266
+ },
267
+ { writeConcern }
268
+ );
269
+
270
+ const records =
271
+ phase === 'verify'
272
+ ? await authorities
273
+ .aggregate<MigrationSigningAuthority>([
274
+ { $match: afterCursor(cursor) },
275
+ { $sort: { _id: 1 } },
276
+ ...pendingVerification(epoch),
277
+ { $limit: batchSize },
278
+ ])
279
+ .toArray()
280
+ : await authorities
281
+ .find({
282
+ ...afterCursor(cursor),
283
+ ...(phase === 'prepare'
284
+ ? { keyVersion: { $exists: false } }
285
+ : { seed: { $exists: true } }),
286
+ })
287
+ .sort({ _id: 1 })
288
+ .limit(batchSize)
289
+ .toArray();
290
+
291
+ for (const record of records) {
292
+ if (options.remainingTime && options.remainingTime() < 15_000) break;
293
+ current = record;
294
+ if (phase === 'prepare') {
295
+ if (parseSeedEnvelope(record) || typeof record.seed !== 'string') {
296
+ throw new SeedMigrationError('invalid_legacy_record');
297
+ }
298
+ const envelope = await seedEncryption.encrypt(record.seed, getSeedIdentity(record));
299
+ const updated = await authorities.updateOne(
300
+ snapshotFilter(record),
301
+ { $set: envelope },
302
+ { writeConcern }
303
+ );
304
+ if (updated.matchedCount !== 1) throw new SeedMigrationError('document_changed');
305
+ const persisted = await authorities.findOne(
306
+ snapshotFilter({ ...record, ...envelope })
307
+ );
308
+ if (!persisted) throw new SeedMigrationError('document_changed');
309
+ await verifyRecord(persisted);
310
+ } else if (phase === 'verify') {
311
+ const envelope = await verifyRecord(record);
312
+ if (
313
+ !(await authorities.findOne(snapshotFilter(record), { projection: { _id: 1 } }))
314
+ ) {
315
+ throw new SeedMigrationError('document_changed');
316
+ }
317
+ await receipts.replaceOne(
318
+ { _id: record._id },
319
+ receiptFor(record, envelope, epoch),
320
+ {
321
+ upsert: true,
322
+ writeConcern,
323
+ }
324
+ );
325
+ } else {
326
+ const envelope = parseSeedEnvelope(record);
327
+ if (!envelope || !(await receipts.findOne(receiptFor(record, envelope, epoch)))) {
328
+ throw new SeedMigrationError('reverification_required');
329
+ }
330
+ await verifyRecord(record);
331
+ const updated = await authorities.updateOne(
332
+ snapshotFilter(record),
333
+ { $unset: { seed: '' } },
334
+ { writeConcern }
335
+ );
336
+ if (updated.matchedCount !== 1) throw new SeedMigrationError('document_changed');
337
+ }
338
+ processed += 1;
339
+ cursor = record._id;
340
+ }
341
+
342
+ // Avoid collection-wide reconciliation on every batch. Reconcile after exhausting
343
+ // the ordered scan; an interrupted/time-limited batch continues at its last success.
344
+ const countsReconciled = processed === records.length && records.length < batchSize;
345
+ let counts = countsBefore;
346
+ let done = false;
347
+ let rescanRequired = false;
348
+ if (countsReconciled) {
349
+ counts = await countSeedMigrationDocuments(db);
350
+ if (counts.malformed || (phase !== 'prepare' && counts.encrypted !== counts.total)) {
351
+ throw new SeedMigrationError('reconciliation_failed');
352
+ }
353
+ if (phase === 'verify') {
354
+ rescanRequired = Boolean(
355
+ await authorities
356
+ .aggregate([...pendingVerification(epoch), { $limit: 1 }])
357
+ .next()
358
+ );
359
+ } else {
360
+ rescanRequired =
361
+ phase === 'prepare' ? counts.legacyOnly > 0 : counts.plaintextRemaining > 0;
362
+ }
363
+ done = !rescanRequired;
364
+ cursor = undefined;
365
+ }
366
+ await states.updateOne(
367
+ lease,
368
+ {
369
+ $set: {
370
+ counts,
371
+ status: done ? 'complete' : 'running',
372
+ updatedAt: new Date(),
373
+ ...(cursor !== undefined ? { cursor } : {}),
374
+ ...((phase === 'verify' && done) || phase === 'purge'
375
+ ? { verifiedEpoch: epoch }
376
+ : {}),
377
+ },
378
+ $inc: { processed },
379
+ ...(cursor === undefined ? { $unset: { cursor: '' } } : {}),
380
+ },
381
+ { writeConcern }
382
+ );
383
+ const result = { phase, done, processed, counts, countsReconciled, rescanRequired };
384
+ console.info({ event: 'signing_authority_seed_migration', ...result });
385
+ return result;
386
+ } catch (error) {
387
+ // Clear the purge gate even after partial success; rerun verify before continuing purge.
388
+ const failure = new SeedMigrationError(
389
+ error instanceof SeedMigrationError
390
+ ? error.category
391
+ : error instanceof SeedEncryptionError
392
+ ? error.category
393
+ : 'operation_failed'
394
+ );
395
+ try {
396
+ await states.updateOne(
397
+ lease,
398
+ {
399
+ $set: { status: 'failed', updatedAt: new Date() },
400
+ $unset: { verifiedEpoch: '', cursor: '' },
401
+ },
402
+ { writeConcern }
403
+ );
404
+ } catch (checkpointError) {
405
+ logSeedEncryptionFailure(checkpointError, 'migration_failure_checkpoint', current);
406
+ }
407
+ if (error instanceof SeedMigrationError) {
408
+ console.error({
409
+ event: 'signing_authority_seed_migration_failure',
410
+ operation: `migration_${phase}`,
411
+ recordId: current?._id.toString(),
412
+ category: failure.category,
413
+ });
414
+ } else {
415
+ logSeedEncryptionFailure(error, `migration_${phase}`, current);
416
+ }
417
+ throw failure;
418
+ } finally {
419
+ try {
420
+ await states.updateOne(
421
+ lease,
422
+ { $unset: { leaseOwner: '', leaseExpiresAt: '' } },
423
+ { writeConcern }
424
+ );
425
+ } catch (releaseError) {
426
+ // Preserve the operation's result/error. The lease expires after 16 minutes.
427
+ logSeedEncryptionFailure(releaseError, 'migration_lease_release', current);
428
+ }
429
+ }
430
+ };
@@ -0,0 +1,70 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
+
8
+ const script = fileURLToPath(
9
+ new URL('../../scripts/migrate-signing-authority-seeds.mjs', import.meta.url)
10
+ );
11
+ let directory: string;
12
+
13
+ beforeEach(() => {
14
+ directory = mkdtempSync(join(tmpdir(), 'sa-cli-test-'));
15
+ });
16
+ afterEach(() => rmSync(directory, { recursive: true, force: true }));
17
+
18
+ const invoke = () =>
19
+ spawnSync(
20
+ process.execPath,
21
+ [script, '--function-name', 'test-migration', '--region', 'us-east-1'],
22
+ {
23
+ encoding: 'utf8',
24
+ env: {
25
+ NODE_ENV: 'test',
26
+ PATH: `${directory}:${process.env.PATH}`,
27
+ SA_CLI_TEST_DIRECTORY: directory,
28
+ },
29
+ }
30
+ );
31
+ const mockAws = (body: string): void => {
32
+ writeFileSync(join(directory, 'aws'), `#!/usr/bin/env node\n${body}`, { mode: 0o700 });
33
+ };
34
+
35
+ describe('seed migration operator CLI', () => {
36
+ it.each(['ExpiredTokenException', 'TooManyRequestsException', 'Read timeout on endpoint URL'])(
37
+ 'shows AWS CLI stderr for %s without dumping stdout or the exec error',
38
+ diagnostic => {
39
+ mockAws(
40
+ `process.stdout.write('do-not-log-stdout'); process.stderr.write(${JSON.stringify(diagnostic)}); process.exit(1);`
41
+ );
42
+ const result = invoke();
43
+ expect(result.status).toBe(1);
44
+ expect(result.stderr).toContain(diagnostic);
45
+ expect(result.stderr).toContain('rerun to resume');
46
+ expect(result.stderr).not.toContain('do-not-log-stdout');
47
+ expect(result.stdout).toBe('');
48
+ }
49
+ );
50
+
51
+ it('continues a requested rescan even when the boundary batch processed zero records', () => {
52
+ mockAws(`
53
+ const fs = require('node:fs');
54
+ const marker = process.env.SA_CLI_TEST_DIRECTORY + '/invoked';
55
+ const done = fs.existsSync(marker);
56
+ fs.writeFileSync(marker, '1');
57
+ fs.writeFileSync(process.argv.at(-1), JSON.stringify({ done, processed: done ? 1 : 0, rescanRequired: !done }));
58
+ process.stdout.write(JSON.stringify({ StatusCode: 200 }));
59
+ `);
60
+ const result = invoke();
61
+ expect(result.status).toBe(0);
62
+ expect(result.stderr).toBe('');
63
+ const batches = result.stdout
64
+ .trim()
65
+ .split('\n')
66
+ .map(line => JSON.parse(line));
67
+ expect(batches).toHaveLength(2);
68
+ expect(batches[1].done).toBe(true);
69
+ });
70
+ });