@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,1073 @@
1
+ import { timingSafeEqual, randomUUID } from 'crypto';
2
+ import { z } from 'zod';
3
+ import { TRPCError } from '@trpc/server';
4
+ import { ESCROW_PIN_MAX_ATTEMPTS } from '@learncard/sss-key-manager';
5
+ import {
6
+ ESCROW_PIN_LOCKED_MESSAGE,
7
+ ESCROW_PIN_UNAVAILABLE_MESSAGE,
8
+ escrowPinMismatchMessage,
9
+ } from '@learncard/types';
10
+ import cache from '@cache';
11
+ import { environment, isEscrowReleaseKillSwitchEnabled } from '@environment';
12
+ import { t, openRoute, didAndChallengeRoute, type Context } from '@routes';
13
+ import { createRecoverySession } from '@cache/recoverySessions';
14
+ import { decryptAuthShare } from '@helpers/shareEncryption.helpers';
15
+ import {
16
+ generateEscrowCancelToken,
17
+ hashEscrowCancelToken,
18
+ escrowCancelTokenMatches,
19
+ } from '@helpers/escrowCancelToken';
20
+ import {
21
+ EscrowEnvelopeValidator,
22
+ EscrowBlobValidator,
23
+ EscrowPinSaltValidator,
24
+ reserveEscrowPinAttempt,
25
+ refundEscrowPinAttempt,
26
+ recordEscrowPinFailure,
27
+ disableEscrowPin,
28
+ markClaimedEscrowHoldFailed,
29
+ retryClaimedEscrowHold,
30
+ ServerEncryptedShareValidator,
31
+ findUserKeyByAuthProvider,
32
+ findAuthShareByVersion,
33
+ isRecoveryMethodConfirmed,
34
+ setEscrowBlobByAuthProvider,
35
+ clearEscrowByAuthProvider,
36
+ setEscrowOptInByAuthProvider,
37
+ createEscrowHold,
38
+ createEscrowHoldsIndexes,
39
+ findPendingEscrowHoldByAuthProvider,
40
+ findEscrowHoldById,
41
+ cancelEscrowHold,
42
+ hasUnconfirmedEscrowCancellation,
43
+ cancelEscrowHoldByCancelToken,
44
+ completeEscrowHold,
45
+ hasCompletedEscrowHoldForVersion,
46
+ expireStaleEscrowHolds,
47
+ hashEscrowResumeToken,
48
+ generateEscrowResumeToken,
49
+ type EscrowHold,
50
+ type MongoUserKeyType,
51
+ type AuthProviderMapping,
52
+ } from '@models';
53
+ import {
54
+ AuthInputValidator,
55
+ AuthProviderTypeValidator,
56
+ RecoverySessionTokenValidator,
57
+ verifyAndGetContactMethod,
58
+ requireUserKey,
59
+ assertDidOwner,
60
+ requireRecoverySession,
61
+ } from './keys';
62
+ import {
63
+ getEscrowEnclave,
64
+ getEscrowHoldDurationMs,
65
+ getEscrowHoldRestartMinAgeMs,
66
+ isEscrowRemoteMode,
67
+ activeEscrowClientMode,
68
+ getEnclaveAttestationIdentity,
69
+ notifyEscrowHoldEvent,
70
+ EscrowPolicyError,
71
+ EscrowBlobError,
72
+ EscrowUnavailableError,
73
+ EscrowPinMismatchError,
74
+ } from '../services/escrow-enclave';
75
+
76
+ import { cancelHoldInEnclave } from '../services/escrow-enclave/cancellation';
77
+ const unavailableMessage = 'Automatic recovery is not available for this account.';
78
+ const killSwitchMessage = 'Automatic recovery is temporarily unavailable. Please try again later.';
79
+ const invalidMessage = 'This recovery request is no longer valid.';
80
+ const staleEscrowMessage = 'Automatic recovery needs to be set up again on a signed-in device.';
81
+ // Roughtime's release lower bound can lag its creation upper bound. Advertise
82
+ // an additional minute, and still allow retries when trusted time is unavailable.
83
+ const ESCROW_RELEASE_TIME_MARGIN_MS = 60_000;
84
+ // Keep throttling distinct: clients reserve the locked message for lifetime PIN exhaustion.
85
+ const pinThrottledMessage = 'Please wait before trying again.';
86
+
87
+ // Follow qr-login's Redis INCR/EXPIRE per-IP limiter. keys.ts has only OTP-specific limits.
88
+ const limitPinCompletion = async (
89
+ clientIp: string | undefined,
90
+ operation: 'start' | 'complete' = 'complete'
91
+ ): Promise<void> => {
92
+ const redis = cache.redis ?? cache.node;
93
+ const key = `escrow:pin-${operation}:${clientIp ?? 'unknown'}`;
94
+ const count = await redis.incr(key);
95
+ if (count === 1) await redis.expire(key, 60);
96
+ if (count > 20)
97
+ throw new TRPCError({
98
+ code: 'TOO_MANY_REQUESTS',
99
+ message: pinThrottledMessage,
100
+ });
101
+ };
102
+
103
+ // Same Redis INCR/EXPIRE per-IP shape as limitPinCompletion, scoped under its
104
+ // own key so cancel-link attempts never share (or exhaust) the PIN budget.
105
+ const limitCancelLinkAttempts = async (clientIp: string | undefined): Promise<void> => {
106
+ const redis = cache.redis ?? cache.node;
107
+ const key = `escrow:cancel-link:${clientIp ?? 'unknown'}`;
108
+ const count = await redis.incr(key);
109
+ if (count === 1) await redis.expire(key, 60);
110
+ if (count > 20)
111
+ throw new TRPCError({
112
+ code: 'TOO_MANY_REQUESTS',
113
+ message: pinThrottledMessage,
114
+ });
115
+ };
116
+
117
+ const lockPin = async (
118
+ hold: EscrowHold,
119
+ userKey: MongoUserKeyType,
120
+ tenant: Context['tenant']
121
+ ): Promise<void> => {
122
+ const disabled = await disableEscrowPin(
123
+ hold.authProvider,
124
+ hold.shareVersion,
125
+ userKey.escrowBlob?.envelope.ciphertext
126
+ );
127
+ // Always burn this request, but never disable/cancel a replacement enrollment.
128
+ const cancelled = await cancelEscrowHold(hold._id, 'system', 'pin-locked');
129
+ if (cancelled) {
130
+ await cancelHoldInEnclave(cancelled, userKey);
131
+ await notifyEscrowHoldEvent({ kind: 'cancelled', hold: cancelled, userKey, tenant });
132
+ }
133
+ if (!disabled) return;
134
+ // The pin-locked notice's cancel link must point at the account's still-open
135
+ // waiting-period hold (if any) — `hold` itself is already terminal by this
136
+ // point, so a link rooted there could never authorize a cancel.
137
+ let activeHold: EscrowHold | undefined;
138
+ for (const provider of userKey.authProviders) {
139
+ activeHold = (await findPendingEscrowHoldByAuthProvider(provider, 'hold')) ?? undefined;
140
+ if (activeHold) break;
141
+ }
142
+ // One lockout notice per lock event, not per cancelled hold below — the PIN
143
+ // capability is account-wide, not per-hold.
144
+ await notifyEscrowHoldEvent({ kind: 'pin-locked', hold, userKey, tenant, activeHold });
145
+ for (const provider of userKey.authProviders) {
146
+ const pending = await findPendingEscrowHoldByAuthProvider(provider, 'pin');
147
+ if (pending?.releasePolicy === 'pin' && pending.shareVersion === hold.shareVersion) {
148
+ const cancelled = await cancelEscrowHold(pending._id, 'system', 'pin-locked');
149
+ if (cancelled) {
150
+ await cancelHoldInEnclave(cancelled, userKey);
151
+ await notifyEscrowHoldEvent({
152
+ kind: 'cancelled',
153
+ hold: cancelled,
154
+ userKey,
155
+ tenant,
156
+ });
157
+ }
158
+ }
159
+ }
160
+ };
161
+ let holdIndexes: Promise<void> | undefined;
162
+
163
+ // Lambda and in-process callers do not run docker-entry's index initialization.
164
+ const ensureHoldIndexes = async (): Promise<void> => {
165
+ holdIndexes ??= createEscrowHoldsIndexes().catch(() => {
166
+ holdIndexes = undefined;
167
+ throw new TRPCError({
168
+ code: 'INTERNAL_SERVER_ERROR',
169
+ message: 'Recovery could not be started.',
170
+ });
171
+ });
172
+ await holdIndexes;
173
+ };
174
+ const successValidator = z.object({ success: z.literal(true) });
175
+ const cancelByLinkInput = z
176
+ .object({
177
+ holdId: z.string().uuid(),
178
+ token: z.string().regex(/^[0-9a-f]{64}$/),
179
+ })
180
+ .strict();
181
+ const resumeInput = z
182
+ .object({
183
+ holdId: z.string().uuid(),
184
+ resumeToken: z.string().min(1).max(512),
185
+ })
186
+ .strict();
187
+ const holdStatusValidator = z.object({
188
+ holdId: z.string(),
189
+ status: z.enum(['pending', 'cancelled', 'completed', 'expired']),
190
+ requestedAt: z.string(),
191
+ releaseAfter: z.string(),
192
+ releasePolicy: z.enum(['hold', 'pin']),
193
+ cancelledAt: z.string().optional(),
194
+ completedAt: z.string().optional(),
195
+ });
196
+
197
+ const serializeHold = (hold: EscrowHold): z.infer<typeof holdStatusValidator> => ({
198
+ holdId: hold._id,
199
+ status: hold.status,
200
+ requestedAt: hold.requestedAt.toISOString(),
201
+ releaseAfter: hold.releaseAfter.toISOString(),
202
+ releasePolicy: hold.releasePolicy ?? 'hold',
203
+ ...(hold.cancelledAt ? { cancelledAt: hold.cancelledAt.toISOString() } : {}),
204
+ ...(hold.completedAt ? { completedAt: hold.completedAt.toISOString() } : {}),
205
+ });
206
+
207
+ const resumeTokenMatches = (hold: EscrowHold, token: string): boolean => {
208
+ const actual = Buffer.from(hashEscrowResumeToken(token), 'hex');
209
+ const expected = Buffer.from(hold.resumeTokenHash, 'hex');
210
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
211
+ };
212
+
213
+ const hasConfirmedEscrow = (userKey: MongoUserKeyType, version: number): boolean =>
214
+ userKey.recoveryMethods.some(
215
+ method =>
216
+ method.type === 'escrow' &&
217
+ method.shareVersion === version &&
218
+ isRecoveryMethodConfirmed(userKey, method)
219
+ );
220
+
221
+ /** Never propagate enclave internals (or cryptographic payloads) into tRPC errors. */
222
+ const enclaveOperation = async <T>(
223
+ operation: () => Promise<T>,
224
+ enrolling = false,
225
+ policyMessage = invalidMessage
226
+ ): Promise<T> => {
227
+ try {
228
+ return await operation();
229
+ } catch (error) {
230
+ if (error instanceof EscrowUnavailableError) {
231
+ throw new TRPCError({
232
+ code: 'PRECONDITION_FAILED',
233
+ message: 'Escrow recovery is not available.',
234
+ });
235
+ }
236
+ if (error instanceof EscrowPolicyError) {
237
+ throw new TRPCError({ code: 'FORBIDDEN', message: policyMessage });
238
+ }
239
+ if (enrolling && error instanceof EscrowBlobError) {
240
+ throw new TRPCError({
241
+ code: 'BAD_REQUEST',
242
+ message: 'Invalid automatic recovery material.',
243
+ });
244
+ }
245
+ throw new TRPCError({
246
+ code: 'INTERNAL_SERVER_ERROR',
247
+ message: 'Recovery material could not be released.',
248
+ });
249
+ }
250
+ };
251
+
252
+ // A stored blob is stale when it was sealed for a different enclave backend
253
+ // (P6 software<->nitro migration/rollback) or a since-rotated enclave key
254
+ // (measurement rotation). Never forward a stale blob to createHold/releaseEscrow
255
+ // UNLESS the blob's keyId is a recognised previous key (P9.1/P9.2): the enclave
256
+ // can still decrypt/release it, so only an UNKNOWN keyId blocks the operation.
257
+ const assertFreshEscrowBlob = (blob: {
258
+ enclaveMode: 'software' | 'nitro';
259
+ enclaveKeyId: string;
260
+ }): Promise<void> =>
261
+ enclaveOperation(
262
+ async () => {
263
+ if (blob.enclaveMode !== activeEscrowClientMode()) throw new EscrowPolicyError();
264
+ // Fail-closed identity lookup: uses a warm cache when available,
265
+ // otherwise makes a real (normally-timed-out) enclave call and lets
266
+ // failure propagate — never treats an unreachable enclave as fresh.
267
+ const identity = await getEnclaveAttestationIdentity();
268
+ if (
269
+ blob.enclaveKeyId !== identity.keyId &&
270
+ !identity.previousKeyIds.includes(blob.enclaveKeyId)
271
+ ) {
272
+ throw new EscrowPolicyError();
273
+ }
274
+ },
275
+ false,
276
+ staleEscrowMessage
277
+ );
278
+
279
+ // Strict objects with exclusive proof fields also remain compatible with OpenAPI's
280
+ // object-only parameter generation (unlike a top-level Zod union).
281
+ const startInput = z
282
+ .object({
283
+ clientEphemeralPublicKey: z.string().min(1).max(512),
284
+ releasePolicy: z.enum(['hold', 'pin']).default('hold'),
285
+ restart: z.boolean().optional(),
286
+ authToken: z.string().optional(),
287
+ providerType: AuthProviderTypeValidator.optional(),
288
+ recoverySessionToken: RecoverySessionTokenValidator.optional(),
289
+ })
290
+ .strict()
291
+ .refine(
292
+ input =>
293
+ input.recoverySessionToken !== undefined
294
+ ? input.authToken === undefined && input.providerType === undefined
295
+ : input.authToken !== undefined && input.providerType !== undefined,
296
+ { message: 'Provide exactly one identity proof.' }
297
+ );
298
+
299
+ // P0-4: this is a GET route, so neither the resume token nor the provider
300
+ // token may travel in the query string (proxy/ALB access logs). Raw-fetch
301
+ // callers send whichever secret applies as the X-Auth-Token header
302
+ // (ctx.providerToken); the optional input fields exist only for native tRPC
303
+ // callers whose batch link always POSTs.
304
+ const statusInput = z
305
+ .object({
306
+ holdId: z.string().uuid().optional(),
307
+ resumeToken: z.string().min(1).max(512).optional(),
308
+ authToken: z.string().optional(),
309
+ providerType: AuthProviderTypeValidator.optional(),
310
+ })
311
+ .strict()
312
+ .refine(
313
+ input =>
314
+ input.holdId !== undefined
315
+ ? input.authToken === undefined && input.providerType === undefined
316
+ : input.resumeToken === undefined && input.providerType !== undefined,
317
+ { message: 'Provide exactly one identity proof.' }
318
+ );
319
+
320
+ export const escrowRouter = t.router({
321
+ getAttestation: openRoute
322
+ .meta({ openapi: { method: 'GET', path: '/keys/escrow/attestation', tags: ['Keys'] } })
323
+ .input(
324
+ z
325
+ .object({
326
+ nonce: z
327
+ .string()
328
+ .regex(/^[0-9a-f]{64}$/i)
329
+ .optional(),
330
+ })
331
+ .strict()
332
+ )
333
+ .output(
334
+ z.object({
335
+ attestation: z.object({
336
+ mode: z.enum(['software', 'nitro']),
337
+ keyId: z.string(),
338
+ previousKeyIds: z.array(z.string()).optional(),
339
+ publicKey: z.string(),
340
+ measurements: EscrowBlobValidator.shape.measurements,
341
+ document: z.string(),
342
+ issuedAt: z.string(),
343
+ }),
344
+ holdDurationMs: z.number(),
345
+ })
346
+ )
347
+ .query(async ({ input }) => {
348
+ // Nitro attestation is only meaningful bound to a fresh client nonce
349
+ // (sss-key-manager always sends one: GET .../attestation?nonce=<64 hex>);
350
+ // the software backend has no freshness story and never required one.
351
+ if (isEscrowRemoteMode() && !input.nonce) {
352
+ throw new TRPCError({
353
+ code: 'BAD_REQUEST',
354
+ message: 'A nonce is required for attestation.',
355
+ });
356
+ }
357
+ const nonce = input.nonce
358
+ ? Uint8Array.from(Buffer.from(input.nonce, 'hex'))
359
+ : undefined;
360
+ return {
361
+ attestation: await enclaveOperation(() => getEscrowEnclave().getAttestation(nonce)),
362
+ holdDurationMs: getEscrowHoldDurationMs(),
363
+ };
364
+ }),
365
+
366
+ enroll: didAndChallengeRoute
367
+ .meta({ openapi: { method: 'POST', path: '/keys/escrow', tags: ['Keys'] } })
368
+ .input(
369
+ AuthInputValidator.extend({
370
+ envelope: EscrowEnvelopeValidator,
371
+ shareVersion: z.number().int().positive(),
372
+ enclaveKeyId: EscrowBlobValidator.shape.enclaveKeyId,
373
+ pinSalt: EscrowPinSaltValidator.optional(),
374
+ clearPin: z.boolean().optional(),
375
+ }).strict()
376
+ )
377
+ .output(successValidator.extend({ shareVersion: z.number() }))
378
+ .mutation(async ({ input, ctx }) => {
379
+ const { authProvider } = await verifyAndGetContactMethod(input);
380
+ const userKey = await requireUserKey(authProvider);
381
+ assertDidOwner(userKey, ctx.user.did);
382
+ if (userKey.escrowOptedOutAt) {
383
+ throw new TRPCError({
384
+ code: 'CONFLICT',
385
+ message: 'Automatic recovery is turned off for this account.',
386
+ });
387
+ }
388
+ if (input.shareVersion !== userKey.shareVersion) {
389
+ throw new TRPCError({
390
+ code: 'CONFLICT',
391
+ message: 'Key material changed; please retry.',
392
+ });
393
+ }
394
+ const attestation = await enclaveOperation(() => getEscrowEnclave().getAttestation());
395
+ if (
396
+ input.enclaveKeyId !== attestation.keyId ||
397
+ input.envelope.keyId !== attestation.keyId
398
+ ) {
399
+ throw new TRPCError({
400
+ code: 'BAD_REQUEST',
401
+ message: 'Invalid automatic recovery key.',
402
+ });
403
+ }
404
+ const verification = await enclaveOperation(
405
+ () =>
406
+ getEscrowEnclave().verifyEscrowBlob({
407
+ envelope: input.envelope,
408
+ expectedDid: ctx.user.did,
409
+ expectedShareVersion: input.shareVersion,
410
+ }),
411
+ true
412
+ );
413
+ if (
414
+ !verification.ok ||
415
+ verification.hasPin !== !!input.pinSalt ||
416
+ (input.clearPin && input.pinSalt)
417
+ ) {
418
+ throw new TRPCError({
419
+ code: 'BAD_REQUEST',
420
+ message: 'Invalid automatic recovery material.',
421
+ });
422
+ }
423
+ const blob = {
424
+ envelope: input.envelope,
425
+ enclaveKeyId: attestation.keyId,
426
+ enclaveMode: attestation.mode,
427
+ measurements: attestation.measurements,
428
+ shareVersion: input.shareVersion,
429
+ createdAt: new Date(),
430
+ };
431
+ let stored;
432
+ const oldBlob = userKey.escrowBlob;
433
+ const oldPin = userKey.escrowPin;
434
+ if (!input.pinSalt && !input.clearPin && oldPin) {
435
+ if (
436
+ oldBlob &&
437
+ !oldPin.disabledAt &&
438
+ oldPin.shareVersion === oldBlob.shareVersion &&
439
+ // P9.2: carry from the current key OR a still-accepted previous
440
+ // key (deliberate rotation / lost-sealed-key recovery) — only a
441
+ // fully unrecognised keyId drops the PIN (see assertFreshEscrowBlob).
442
+ (oldBlob.enclaveKeyId === attestation.keyId ||
443
+ (attestation.previousKeyIds ?? []).includes(oldBlob.enclaveKeyId)) &&
444
+ input.shareVersion > oldBlob.shareVersion &&
445
+ // A released blob's PIN is retired: it was just used, or the user forgot it.
446
+ !(await hasCompletedEscrowHoldForVersion(authProvider, oldBlob.shareVersion))
447
+ ) {
448
+ try {
449
+ const carried = await getEscrowEnclave().carryPinVerifier({
450
+ sourceEnvelope: oldBlob.envelope,
451
+ targetEnvelope: input.envelope,
452
+ expectedDid: ctx.user.did,
453
+ sourceShareVersion: oldBlob.shareVersion,
454
+ targetShareVersion: input.shareVersion,
455
+ sourceEnrollmentEpoch: oldBlob.enrollmentEpoch,
456
+ targetEnrollmentEpoch: oldBlob.enrollmentEpoch + 1,
457
+ });
458
+ stored = await setEscrowBlobByAuthProvider(
459
+ authProvider,
460
+ { ...blob, envelope: carried.envelope },
461
+ input.shareVersion,
462
+ undefined,
463
+ { blob: oldBlob, pin: oldPin }
464
+ );
465
+ } catch {
466
+ // Never log enclave errors: a backend could include decrypted material.
467
+ console.warn('[LCA escrow/enroll] PIN carry failed; enrolling without PIN');
468
+ }
469
+ }
470
+ if (!stored) {
471
+ console.warn(
472
+ '[LCA escrow/enroll] PIN carry unavailable; enrolling without PIN'
473
+ );
474
+ }
475
+ }
476
+ stored ??= await setEscrowBlobByAuthProvider(
477
+ authProvider,
478
+ blob,
479
+ input.shareVersion,
480
+ input.pinSalt ? { salt: input.pinSalt } : undefined
481
+ );
482
+ if (!stored)
483
+ throw new TRPCError({
484
+ code: 'CONFLICT',
485
+ message: 'Key material changed; please retry.',
486
+ });
487
+ return { success: true as const, shareVersion: input.shareVersion };
488
+ }),
489
+
490
+ remove: didAndChallengeRoute
491
+ .meta({ openapi: { method: 'DELETE', path: '/keys/escrow', tags: ['Keys'] } })
492
+ .input(AuthInputValidator.extend({ optOut: z.boolean() }).strict())
493
+ .output(successValidator)
494
+ .mutation(async ({ input, ctx }) => {
495
+ const { authProvider } = await verifyAndGetContactMethod(input);
496
+ const userKey = await requireUserKey(authProvider);
497
+ assertDidOwner(userKey, ctx.user.did);
498
+ if (
499
+ input.optOut &&
500
+ !userKey.recoveryMethods.some(
501
+ method =>
502
+ method.type !== 'escrow' &&
503
+ method.shareVersion === userKey.shareVersion &&
504
+ isRecoveryMethodConfirmed(userKey, method)
505
+ )
506
+ ) {
507
+ throw new TRPCError({
508
+ code: 'PRECONDITION_FAILED',
509
+ message:
510
+ 'Set up another recovery method before turning off automatic recovery.',
511
+ });
512
+ }
513
+ await clearEscrowByAuthProvider(authProvider, { optOut: input.optOut });
514
+ for (const policy of ['hold', 'pin'] as const) {
515
+ const pending = await findPendingEscrowHoldByAuthProvider(authProvider, policy);
516
+ const cancelled = pending && (await cancelEscrowHold(pending._id, 'did'));
517
+ if (cancelled) {
518
+ await cancelHoldInEnclave(cancelled, userKey);
519
+ await notifyEscrowHoldEvent({
520
+ kind: 'cancelled',
521
+ hold: cancelled,
522
+ userKey,
523
+ tenant: ctx.tenant,
524
+ });
525
+ }
526
+ }
527
+ return { success: true as const };
528
+ }),
529
+
530
+ optIn: didAndChallengeRoute
531
+ .meta({ openapi: { method: 'POST', path: '/keys/escrow/opt-in', tags: ['Keys'] } })
532
+ .input(AuthInputValidator.strict())
533
+ .output(successValidator)
534
+ .mutation(async ({ input, ctx }) => {
535
+ const { authProvider } = await verifyAndGetContactMethod(input);
536
+ const userKey = await requireUserKey(authProvider);
537
+ assertDidOwner(userKey, ctx.user.did);
538
+ await setEscrowOptInByAuthProvider(authProvider);
539
+ return { success: true as const };
540
+ }),
541
+
542
+ startRecovery: openRoute
543
+ .meta({ openapi: { method: 'POST', path: '/keys/escrow/recover', tags: ['Keys'] } })
544
+ .input(startInput)
545
+ .output(
546
+ holdStatusValidator
547
+ .pick({ holdId: true, requestedAt: true, releaseAfter: true, releasePolicy: true })
548
+ .extend({
549
+ status: z.literal('pending'),
550
+ resumeToken: z.string().nullable(),
551
+ pinSalt: EscrowPinSaltValidator.optional(),
552
+ })
553
+ )
554
+ .mutation(async ({ input, ctx }) => {
555
+ if (isEscrowReleaseKillSwitchEnabled()) {
556
+ throw new TRPCError({
557
+ code: 'PRECONDITION_FAILED',
558
+ message: killSwitchMessage,
559
+ });
560
+ }
561
+ let authProvider: AuthProviderMapping;
562
+ if (input.releasePolicy === 'pin') await limitPinCompletion(ctx.clientIp, 'start');
563
+ if (input.recoverySessionToken !== undefined) {
564
+ try {
565
+ authProvider = await requireRecoverySession(
566
+ input.recoverySessionToken,
567
+ 'recover'
568
+ );
569
+ } catch (error) {
570
+ if (!(error instanceof TRPCError) || error.code !== 'UNAUTHORIZED') throw error;
571
+ throw new TRPCError({ code: 'NOT_FOUND', message: unavailableMessage });
572
+ }
573
+ } else {
574
+ ({ authProvider } = await verifyAndGetContactMethod({
575
+ authToken: input.authToken!,
576
+ providerType: input.providerType!,
577
+ }));
578
+ }
579
+ const userKey = await findUserKeyByAuthProvider(authProvider.type, authProvider.id);
580
+ if (
581
+ !userKey?.escrowBlob ||
582
+ userKey.escrowOptedOutAt ||
583
+ !hasConfirmedEscrow(userKey, userKey.escrowBlob.shareVersion)
584
+ ) {
585
+ throw new TRPCError({ code: 'NOT_FOUND', message: unavailableMessage });
586
+ }
587
+ if (await hasUnconfirmedEscrowCancellation(userKey.primaryDid)) {
588
+ throw new TRPCError({
589
+ code: 'PRECONDITION_FAILED',
590
+ message: killSwitchMessage,
591
+ });
592
+ }
593
+ await assertFreshEscrowBlob(userKey.escrowBlob);
594
+ const now = new Date();
595
+ if (
596
+ input.releasePolicy === 'pin' &&
597
+ (!userKey.escrowPin ||
598
+ userKey.escrowPin.disabledAt ||
599
+ userKey.escrowPin.shareVersion !== userKey.shareVersion)
600
+ ) {
601
+ throw new TRPCError({
602
+ code: 'FORBIDDEN',
603
+ message: ESCROW_PIN_UNAVAILABLE_MESSAGE,
604
+ });
605
+ }
606
+ await expireStaleEscrowHolds(now);
607
+ await ensureHoldIndexes();
608
+ // Policies coexist; reuse waiting holds only while their material is current.
609
+ const pending = await findPendingEscrowHoldByAuthProvider(
610
+ authProvider,
611
+ input.releasePolicy
612
+ );
613
+ if (
614
+ pending &&
615
+ input.releasePolicy === 'hold' &&
616
+ (pending.releasePolicy ?? 'hold') === 'hold' &&
617
+ pending.shareVersion === userKey.escrowBlob.shareVersion
618
+ ) {
619
+ if (!input.restart)
620
+ return {
621
+ ...serializeHold(pending),
622
+ status: 'pending' as const,
623
+ resumeToken: null,
624
+ };
625
+ const minAge = getEscrowHoldRestartMinAgeMs();
626
+ if (now.getTime() - pending.requestedAt.getTime() < minAge) {
627
+ const retryAfter = new Date(
628
+ pending.requestedAt.getTime() + minAge
629
+ ).toISOString();
630
+ throw new TRPCError({
631
+ code: 'TOO_MANY_REQUESTS',
632
+ message: `A recovery request was started recently. Try again after ${retryAfter}.`,
633
+ });
634
+ }
635
+ }
636
+ if (pending) {
637
+ const cancelled = await cancelEscrowHold(pending._id, 'system', 'superseded');
638
+ if (cancelled) {
639
+ await cancelHoldInEnclave(cancelled, userKey);
640
+ await notifyEscrowHoldEvent({
641
+ kind: 'cancelled',
642
+ hold: cancelled,
643
+ userKey,
644
+ reason: 'superseded',
645
+ tenant: ctx.tenant,
646
+ });
647
+ }
648
+ }
649
+ if (await hasUnconfirmedEscrowCancellation(userKey.primaryDid)) {
650
+ throw new TRPCError({
651
+ code: 'PRECONDITION_FAILED',
652
+ message: killSwitchMessage,
653
+ });
654
+ }
655
+ const resumeToken = generateEscrowResumeToken();
656
+ const cancelToken = generateEscrowCancelToken();
657
+ const { holdRecord } = await enclaveOperation(() =>
658
+ getEscrowEnclave().createHold({
659
+ envelope: userKey.escrowBlob!.envelope,
660
+ holdId: randomUUID(),
661
+ expectedDid: userKey.primaryDid,
662
+ expectedShareVersion: userKey.escrowBlob!.shareVersion,
663
+ enrollmentEpoch: userKey.escrowBlob!.enrollmentEpoch,
664
+ releasePolicy: input.releasePolicy,
665
+ clientEphemeralPublicKey: input.clientEphemeralPublicKey,
666
+ })
667
+ );
668
+ let hold: EscrowHold;
669
+ try {
670
+ hold = await createEscrowHold({
671
+ holdRecord,
672
+ authProvider,
673
+ enclaveEnvelope: userKey.escrowBlob.envelope,
674
+ primaryDid: userKey.primaryDid,
675
+ shareVersion: userKey.escrowBlob.shareVersion,
676
+ identityProofType:
677
+ input.recoverySessionToken !== undefined
678
+ ? 'recovery-session'
679
+ : 'auth-token',
680
+ requestedAt: now,
681
+ releaseAfter:
682
+ input.releasePolicy === 'pin'
683
+ ? now
684
+ : new Date(
685
+ holdRecord.hold.createdHi +
686
+ holdRecord.holdDurationMs +
687
+ ESCROW_RELEASE_TIME_MARGIN_MS
688
+ ),
689
+ releasePolicy: input.releasePolicy,
690
+ clientEphemeralPublicKey: input.clientEphemeralPublicKey,
691
+ resumeTokenHash: hashEscrowResumeToken(resumeToken),
692
+ cancelTokenHash: hashEscrowCancelToken(cancelToken),
693
+ startedNotificationPending: true,
694
+ requestIp: ctx.clientIp,
695
+ tenantId: ctx.tenant?.id,
696
+ });
697
+ } catch (error) {
698
+ if (
699
+ typeof error === 'object' &&
700
+ error !== null &&
701
+ 'code' in error &&
702
+ error.code === 11000
703
+ ) {
704
+ const raced = await findPendingEscrowHoldByAuthProvider(
705
+ authProvider,
706
+ input.releasePolicy
707
+ );
708
+ if (
709
+ raced &&
710
+ !input.restart &&
711
+ input.releasePolicy === 'hold' &&
712
+ (raced.releasePolicy ?? 'hold') === 'hold' &&
713
+ raced.shareVersion === userKey.escrowBlob.shareVersion
714
+ )
715
+ return {
716
+ ...serializeHold(raced),
717
+ status: 'pending' as const,
718
+ resumeToken: null,
719
+ };
720
+ throw new TRPCError({
721
+ code: 'CONFLICT',
722
+ message: 'Recovery state changed; please retry.',
723
+ });
724
+ }
725
+ throw new TRPCError({
726
+ code: 'INTERNAL_SERVER_ERROR',
727
+ message: 'Recovery could not be started.',
728
+ });
729
+ }
730
+ await notifyEscrowHoldEvent({
731
+ kind: 'started',
732
+ hold,
733
+ userKey,
734
+ cancelToken,
735
+ tenant: ctx.tenant,
736
+ });
737
+ return {
738
+ ...serializeHold(hold),
739
+ status: 'pending' as const,
740
+ resumeToken,
741
+ ...(input.releasePolicy === 'pin' ? { pinSalt: userKey.escrowPin!.salt } : {}),
742
+ };
743
+ }),
744
+
745
+ getStatus: openRoute
746
+ .meta({ openapi: { method: 'GET', path: '/keys/escrow/status', tags: ['Keys'] } })
747
+ .input(statusInput)
748
+ .output(z.object({ hold: holdStatusValidator.nullable() }))
749
+ .query(async ({ input, ctx }) => {
750
+ await expireStaleEscrowHolds(new Date());
751
+ let hold: EscrowHold | null;
752
+ if (input.holdId !== undefined) {
753
+ const resumeToken = input.resumeToken || ctx.providerToken || '';
754
+ hold = resumeToken ? await findEscrowHoldById(input.holdId) : null;
755
+ if (hold && !resumeTokenMatches(hold, resumeToken)) hold = null;
756
+ } else {
757
+ const { authProvider } = await verifyAndGetContactMethod({
758
+ authToken: input.authToken || ctx.providerToken || '',
759
+ providerType: input.providerType!,
760
+ });
761
+ hold = await findPendingEscrowHoldByAuthProvider(authProvider, 'hold');
762
+ }
763
+ return { hold: hold ? serializeHold(hold) : null };
764
+ }),
765
+
766
+ cancelRecovery: didAndChallengeRoute
767
+ .meta({ openapi: { method: 'POST', path: '/keys/escrow/cancel', tags: ['Keys'] } })
768
+ .input(AuthInputValidator.strict())
769
+ .output(successValidator.extend({ cancelled: z.boolean() }))
770
+ .mutation(async ({ input, ctx }) => {
771
+ const { authProvider } = await verifyAndGetContactMethod(input);
772
+ const userKey = await requireUserKey(authProvider);
773
+ assertDidOwner(userKey, ctx.user.did);
774
+ const pending = await findPendingEscrowHoldByAuthProvider(authProvider, 'hold');
775
+ const hold = pending && (await cancelEscrowHold(pending._id, 'did'));
776
+ if (hold) {
777
+ await cancelHoldInEnclave(hold, userKey);
778
+ await notifyEscrowHoldEvent({
779
+ kind: 'cancelled',
780
+ hold,
781
+ userKey,
782
+ tenant: ctx.tenant,
783
+ });
784
+ }
785
+ return { success: true as const, cancelled: Boolean(hold) };
786
+ }),
787
+
788
+ // No DID auth is possible from an email link, so this is deliberately an
789
+ // openRoute guarded only by the single-use cancelToken. Every failure
790
+ // path — missing hold, wrong token, already used, not pending — returns
791
+ // the identical { cancelled: false } shape so a link can't be used to
792
+ // probe hold state (mirrors resumeToken's uniform-failure behaviour in
793
+ // getStatus/completeRecovery above).
794
+ cancelRecoveryByLink: openRoute
795
+ .meta({ openapi: { method: 'POST', path: '/keys/escrow/cancel-link', tags: ['Keys'] } })
796
+ .input(cancelByLinkInput)
797
+ .output(z.object({ cancelled: z.boolean() }))
798
+ .mutation(async ({ input, ctx }) => {
799
+ await limitCancelLinkAttempts(ctx.clientIp);
800
+ const hold = await findEscrowHoldById(input.holdId);
801
+ if (!hold || !escrowCancelTokenMatches(hold, input.token)) return { cancelled: false };
802
+ const cancelled = await cancelEscrowHoldByCancelToken(
803
+ hold._id,
804
+ hashEscrowCancelToken(input.token)
805
+ );
806
+ if (!cancelled) return { cancelled: false };
807
+ const userKey = await findUserKeyByAuthProvider(
808
+ cancelled.authProvider.type,
809
+ cancelled.authProvider.id
810
+ );
811
+ await cancelHoldInEnclave(cancelled, userKey);
812
+ if (userKey) {
813
+ await notifyEscrowHoldEvent({
814
+ kind: 'cancelled',
815
+ hold: cancelled,
816
+ userKey,
817
+ tenant: ctx.tenant,
818
+ });
819
+ }
820
+ return { cancelled: true };
821
+ }),
822
+
823
+ completeRecovery: openRoute
824
+ .meta({ openapi: { method: 'POST', path: '/keys/escrow/complete', tags: ['Keys'] } })
825
+ .input(
826
+ resumeInput.extend({
827
+ pinProof: z
828
+ .string()
829
+ .regex(/^[0-9a-f]{64}$/i)
830
+ .optional(),
831
+ })
832
+ )
833
+ .output(
834
+ z.object({
835
+ sealedShare: EscrowEnvelopeValidator,
836
+ authShare: ServerEncryptedShareValidator,
837
+ primaryDid: z.string(),
838
+ shareVersion: z.number(),
839
+ rebindSessionToken: RecoverySessionTokenValidator,
840
+ })
841
+ )
842
+ .mutation(async ({ input, ctx }) => {
843
+ if (isEscrowReleaseKillSwitchEnabled()) {
844
+ throw new TRPCError({
845
+ code: 'PRECONDITION_FAILED',
846
+ message: killSwitchMessage,
847
+ });
848
+ }
849
+ if (input.pinProof !== undefined) await limitPinCompletion(ctx.clientIp);
850
+ const hold = await findEscrowHoldById(input.holdId);
851
+ if (!hold || !resumeTokenMatches(hold, input.resumeToken)) {
852
+ throw new TRPCError({
853
+ code: 'FORBIDDEN',
854
+ message: 'This recovery request is invalid.',
855
+ });
856
+ }
857
+ if (hold.status === 'cancelled') {
858
+ throw new TRPCError({
859
+ code: 'FORBIDDEN',
860
+ message: 'This recovery request was cancelled.',
861
+ });
862
+ }
863
+ // Legacy Mongo rows can predate the required creation-time record.
864
+ if (hold.status !== 'pending' || !hold.holdRecord)
865
+ throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
866
+ const now = new Date();
867
+ if (now < hold.releaseAfter) {
868
+ throw new TRPCError({
869
+ code: 'FORBIDDEN',
870
+ message: 'This recovery request is still in its waiting period.',
871
+ });
872
+ }
873
+ const userKey = await findUserKeyByAuthProvider(
874
+ hold.authProvider.type,
875
+ hold.authProvider.id
876
+ );
877
+ if (
878
+ !userKey?.escrowBlob ||
879
+ userKey.escrowOptedOutAt ||
880
+ userKey.escrowBlob.shareVersion !== hold.shareVersion ||
881
+ !hasConfirmedEscrow(userKey, hold.shareVersion)
882
+ ) {
883
+ throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
884
+ }
885
+ await assertFreshEscrowBlob(userKey.escrowBlob);
886
+ const encryptedAuthShare = findAuthShareByVersion(userKey, hold.shareVersion);
887
+ if (!encryptedAuthShare || !environment.SEED) {
888
+ throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
889
+ }
890
+ const expectedCiphertext = userKey.escrowBlob.envelope.ciphertext;
891
+ const reserved =
892
+ hold.releasePolicy === 'pin'
893
+ ? await reserveEscrowPinAttempt(
894
+ hold.authProvider,
895
+ hold.shareVersion,
896
+ expectedCiphertext
897
+ )
898
+ : undefined;
899
+ if (reserved === null) {
900
+ const current = await findUserKeyByAuthProvider(
901
+ hold.authProvider.type,
902
+ hold.authProvider.id
903
+ );
904
+ if (
905
+ !current?.escrowPin ||
906
+ current.escrowPin.disabledAt ||
907
+ current.escrowPin.shareVersion !== hold.shareVersion ||
908
+ current.shareVersion !== hold.shareVersion ||
909
+ current.escrowBlob?.envelope.ciphertext !== expectedCiphertext
910
+ ) {
911
+ throw new TRPCError({
912
+ code: 'FORBIDDEN',
913
+ message: ESCROW_PIN_UNAVAILABLE_MESSAGE,
914
+ });
915
+ }
916
+ if (current.escrowPin.failedAttempts >= ESCROW_PIN_MAX_ATTEMPTS) {
917
+ throw new TRPCError({
918
+ code: 'TOO_MANY_REQUESTS',
919
+ message: pinThrottledMessage,
920
+ });
921
+ }
922
+ throw new TRPCError({
923
+ code: 'CONFLICT',
924
+ message: 'Recovery state changed; please retry.',
925
+ });
926
+ }
927
+ // Claim the pending hold BEFORE releasing secrets. The enclave receives the
928
+ // pending snapshot whose CAS we won; transient refusals reopen only our claim.
929
+ let completed: Awaited<ReturnType<typeof completeEscrowHold>> = null;
930
+ try {
931
+ completed = await completeEscrowHold(hold._id);
932
+ if (!completed) {
933
+ throw new TRPCError({
934
+ code: 'CONFLICT',
935
+ message: 'Recovery state changed; please retry.',
936
+ });
937
+ }
938
+ // Revalidate after the claim so a rotation/removal cannot release the old snapshot.
939
+ const current = await findUserKeyByAuthProvider(
940
+ hold.authProvider.type,
941
+ hold.authProvider.id
942
+ );
943
+ if (
944
+ !current?.escrowBlob ||
945
+ current.escrowOptedOutAt ||
946
+ current.shareVersion !== userKey.shareVersion ||
947
+ current.escrowBlob.shareVersion !== hold.shareVersion ||
948
+ current.escrowBlob.envelope.ciphertext !== expectedCiphertext ||
949
+ !hasConfirmedEscrow(current, hold.shareVersion)
950
+ ) {
951
+ throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
952
+ }
953
+ } catch (error) {
954
+ // No enclave evaluation occurred, including when Mongo throws rather
955
+ // than returning a lost CAS. Refund before any fallible hold cleanup.
956
+ if (reserved)
957
+ await refundEscrowPinAttempt(
958
+ hold.authProvider,
959
+ hold.shareVersion,
960
+ expectedCiphertext
961
+ );
962
+ if (completed) {
963
+ await markClaimedEscrowHoldFailed(
964
+ hold._id,
965
+ 'release-failed',
966
+ completed.completedAt!
967
+ );
968
+ await cancelHoldInEnclave(hold, userKey);
969
+ }
970
+ throw error;
971
+ }
972
+ const release = await enclaveOperation(async () => {
973
+ try {
974
+ return {
975
+ result: await getEscrowEnclave().releaseEscrow({
976
+ envelope: (reserved ?? userKey).escrowBlob!.envelope,
977
+ hold: hold.holdRecord,
978
+ clientEphemeralPublicKey: hold.clientEphemeralPublicKey,
979
+ expectedDid: userKey.primaryDid,
980
+ now,
981
+ pinProof: input.pinProof,
982
+ }),
983
+ mismatch: false as const,
984
+ };
985
+ } catch (error) {
986
+ // Keep the typed mismatch inside the wrapper; never expose enclave internals.
987
+ if (hold.releasePolicy === 'pin' && error instanceof EscrowPinMismatchError)
988
+ return { mismatch: true as const };
989
+ if (reserved)
990
+ await refundEscrowPinAttempt(
991
+ hold.authProvider,
992
+ hold.shareVersion,
993
+ expectedCiphertext
994
+ );
995
+ // Policy includes "not yet releasable"; time/ledger/transport
996
+ // refusals map to unavailable. Only malformed blobs are terminal.
997
+ // Retries still require the enclave's single-use ledger.
998
+ if (error instanceof EscrowBlobError) {
999
+ await markClaimedEscrowHoldFailed(
1000
+ hold._id,
1001
+ 'release-failed',
1002
+ completed.completedAt!
1003
+ );
1004
+ await cancelHoldInEnclave(hold, userKey);
1005
+ } else {
1006
+ await retryClaimedEscrowHold(hold._id, completed.completedAt!);
1007
+ }
1008
+ throw error;
1009
+ }
1010
+ });
1011
+ if (release.mismatch) {
1012
+ const failed = await recordEscrowPinFailure(
1013
+ hold.authProvider,
1014
+ hold.shareVersion,
1015
+ expectedCiphertext
1016
+ );
1017
+ const attemptsRemaining =
1018
+ ESCROW_PIN_MAX_ATTEMPTS -
1019
+ (failed?.escrowPin?.failedAttempts ?? reserved!.escrowPin!.failedAttempts);
1020
+ const locked = !!failed?.escrowPin?.disabledAt;
1021
+ const cancelReason = locked ? 'pin-locked' : 'pin-mismatch';
1022
+ await markClaimedEscrowHoldFailed(hold._id, cancelReason, completed.completedAt!);
1023
+ await cancelHoldInEnclave(hold, userKey);
1024
+ await notifyEscrowHoldEvent({
1025
+ kind: 'cancelled',
1026
+ hold: {
1027
+ ...completed,
1028
+ status: 'cancelled',
1029
+ cancelledBy: 'system',
1030
+ cancelReason,
1031
+ cancelledAt: new Date(),
1032
+ },
1033
+ userKey,
1034
+ tenant: ctx.tenant,
1035
+ });
1036
+ if (locked) {
1037
+ await lockPin(hold, userKey, ctx.tenant);
1038
+ throw new TRPCError({
1039
+ code: 'TOO_MANY_REQUESTS',
1040
+ message: ESCROW_PIN_LOCKED_MESSAGE,
1041
+ });
1042
+ }
1043
+ // No structured error-data convention exists: the client parses this exact text.
1044
+ throw new TRPCError({
1045
+ code: 'FORBIDDEN',
1046
+ message: escrowPinMismatchMessage(attemptsRemaining),
1047
+ });
1048
+ }
1049
+ const { sealed } = release.result;
1050
+ // A successful evaluation also consumes the lifetime budget; do not
1051
+ // erase other requests' reservations or replenish guesses here.
1052
+ const authShare = await enclaveOperation(async () =>
1053
+ decryptAuthShare(encryptedAuthShare, environment.SEED)
1054
+ );
1055
+ const rebindSessionToken = await createRecoverySession({
1056
+ scope: 'rebind',
1057
+ authProvider: hold.authProvider,
1058
+ });
1059
+ await notifyEscrowHoldEvent({
1060
+ kind: 'completed',
1061
+ hold: completed,
1062
+ userKey,
1063
+ tenant: ctx.tenant,
1064
+ });
1065
+ return {
1066
+ sealedShare: sealed,
1067
+ authShare,
1068
+ primaryDid: userKey.primaryDid,
1069
+ shareVersion: hold.shareVersion,
1070
+ rebindSessionToken,
1071
+ };
1072
+ }),
1073
+ });