@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
@@ -7,55 +7,179 @@ import { environment } from '@environment';
7
7
  import { z } from 'zod';
8
8
  import { TRPCError } from '@trpc/server';
9
9
  import admin from 'firebase-admin';
10
+ import { createHmac, randomBytes, randomInt, timingSafeEqual } from 'crypto';
11
+ import type { TenantBranding } from '@learncard/email-templates';
10
12
 
11
- import { t, openRoute, didRoute } from '@routes';
13
+ import { t, openRoute, didRoute, didAndChallengeRoute } from '@routes';
12
14
  import { getDeliveryService, getFrom } from '../services/delivery';
15
+ import { getEscrowBlobStaleReason } from '../services/escrow-enclave';
13
16
  import { verifyAuthToken, getContactMethodFromUser, AuthProviderType } from '@helpers/auth.helpers';
14
17
  import { encryptAuthShare, decryptAuthShare } from '@helpers/shareEncryption.helpers';
15
18
  import { maskEmail } from '@helpers/maskEmail';
16
19
  import cache from '@cache';
20
+ import { setValidChallengeForDid } from '@cache/challenges';
21
+ import { checkRateLimit, clearRateLimit } from '@helpers/rateLimit.helpers';
17
22
  import {
18
- findUserKeyByContactMethod,
23
+ MAX_RECOVERY_OTP_ATTEMPTS,
24
+ claimRecoveryOtpSendWindow,
25
+ consumeRecoverySession,
26
+ createRecoverySession,
27
+ storeRecoveryOtp,
28
+ verifyRecoveryOtp,
29
+ } from '@cache/recoverySessions';
30
+ import {
31
+ findUserKeyByAuthProvider,
32
+ findUniqueUserKeyByVerifiedRecoveryEmail,
19
33
  findAuthShareByVersion,
20
- upsertUserKey,
21
- addAuthProviderToUserKey,
22
- addRecoveryMethodToUserKey,
23
- setRecoveryEmail,
24
- markUserKeyMigrated,
25
- upgradeContactMethod,
26
- deleteUserKey,
34
+ upsertUserKeyByAuthProvider,
35
+ addRecoveryMethodToUserKeyByAuthProvider,
36
+ confirmRecoveryMethodByAuthProvider,
37
+ incrementRecoveryConfirmationAttemptsByAuthProvider,
38
+ removeRecoveryMethodFromUserKeyByAuthProvider,
39
+ setRecoveryEmailByAuthProvider,
40
+ markUserKeyMigrationProvisionalByAuthProvider,
41
+ activateUserKeyByAuthProvider,
42
+ purgeExpiredProvisionalMigrationByAuthProvider,
43
+ getSssActivationState,
44
+ hasActivatableRecoveryMethod,
45
+ isRecoveryMethodConfirmed,
46
+ upgradeContactMethodByAuthProvider,
47
+ deleteUserKeyByAuthProvider,
48
+ completeIdentityRebind,
27
49
  ServerEncryptedShareValidator,
50
+ EscrowPinStatusValidator,
51
+ getEscrowPinStatus,
28
52
  EncryptedShareValidator,
53
+ UserKeyVersionConflictError,
29
54
  type ContactMethod,
55
+ type AuthProviderMapping,
56
+ type MongoUserKeyType,
30
57
  } from '@models';
31
58
 
32
59
  const RECOVERY_EMAIL_CODE_TEMPLATE_ALIAS =
33
60
  environment.POSTMARK_RECOVERY_EMAIL_CODE_TEMPLATE_ALIAS ?? '';
34
- const RECOVERY_KEY_TEMPLATE_ALIAS = environment.POSTMARK_RECOVERY_KEY_TEMPLATE_ALIAS ?? '';
35
61
 
36
62
  const RECOVERY_EMAIL_CODE_PREFIX = 'recovery_email_code:';
37
63
  const RECOVERY_EMAIL_CODE_TTL_SECS = 15 * 60; // 15 minutes
64
+ const RECOVERY_METHOD_CONFIRMATION_TTL_MS = 15 * 60 * 1000;
65
+ const MAX_RECOVERY_METHOD_CONFIRMATION_ATTEMPTS = 5;
66
+ const EMAIL_RELAY_ALGORITHM = 'P-256-HKDF-SHA256-AES-256-GCM' as const;
67
+ const ESCROW_RELAY_TIMEOUT_MS = 15_000;
68
+
69
+ // Rate limiting for recovery email verification (brute-force protection)
70
+ const RECOVERY_VERIFY_MAX_ATTEMPTS = 5; // max failed attempts per auth provider identity
71
+
72
+ const generate6DigitCode = (): string => randomInt(100000, 1000000).toString();
73
+
74
+ const hashRecoveryConfirmationCode = (code: string): string => {
75
+ const seed = environment.SEED;
76
+
77
+ if (!seed) {
78
+ throw new TRPCError({
79
+ code: 'INTERNAL_SERVER_ERROR',
80
+ message: 'Server misconfiguration: SEED is required for recovery confirmation',
81
+ });
82
+ }
38
83
 
39
- const generate6DigitCode = (): string => {
40
- const array = new Uint32Array(1);
41
- crypto.getRandomValues(array);
42
- if (!array[0]) throw new Error('Failed to generate random number');
43
- return (100000 + (array[0] % 900000)).toString();
84
+ return createHmac('sha256', seed).update(code).digest('hex');
44
85
  };
45
86
 
46
- const AuthProviderTypeValidator = z.enum(['firebase', 'supertokens', 'keycloak', 'oidc']);
87
+ const confirmationCodeMatches = (code: string, expectedHash: string): boolean => {
88
+ const actual = Buffer.from(hashRecoveryConfirmationCode(code), 'hex');
89
+ const expected = Buffer.from(expectedHash, 'hex');
47
90
 
48
- const AuthInputValidator = z.object({
91
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
92
+ };
93
+
94
+ export const AuthProviderTypeValidator = z.enum(['firebase', 'supertokens', 'keycloak', 'oidc']);
95
+
96
+ export const AuthInputValidator = z.object({
49
97
  authToken: z.string(),
50
98
  providerType: AuthProviderTypeValidator,
51
99
  });
52
100
 
53
- async function verifyAndGetContactMethod(input: {
101
+ const RecoveryMethodTypeValidator = z.enum(['passkey', 'backup', 'phrase', 'email', 'escrow']);
102
+ export const RecoverySessionTokenValidator = z.string().regex(/^[0-9a-f]{64}$/);
103
+
104
+ const RecoveryMethodResponseValidator = z.object({
105
+ type: RecoveryMethodTypeValidator,
106
+ createdAt: z.string(),
107
+ credentialId: z.string().optional(),
108
+ shareVersion: z.number().optional(),
109
+ });
110
+
111
+ const EmailRelayEnvelopeValidator = z
112
+ .object({
113
+ version: z.literal(1),
114
+ algorithm: z.literal(EMAIL_RELAY_ALGORITHM),
115
+ keyId: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/),
116
+ ephemeralPublicKey: z.string().min(1).max(256),
117
+ salt: z.string().min(1).max(128),
118
+ iv: z.string().min(1).max(64),
119
+ ciphertext: z.string().min(1).max(16_384),
120
+ })
121
+ .strict();
122
+
123
+ type EmailRelayEnvelope = z.infer<typeof EmailRelayEnvelopeValidator>;
124
+
125
+ const sendRecoveryKeyToEscrowRelay = async (
126
+ payload: EmailRelayEnvelope,
127
+ expectedRecipient: string
128
+ ): Promise<void> => {
129
+ const relayUrl = environment.ESCROW_RELAY_URL?.replace(/\/$/, '');
130
+ const relayAuthToken = environment.ESCROW_RELAY_AUTH_TOKEN;
131
+
132
+ if (!relayUrl || !relayAuthToken) {
133
+ throw new TRPCError({
134
+ code: 'INTERNAL_SERVER_ERROR',
135
+ message: 'Recovery email delivery is not configured.',
136
+ });
137
+ }
138
+
139
+ let response: Response;
140
+
141
+ try {
142
+ response = await fetch(`${relayUrl}/email-backup`, {
143
+ method: 'POST',
144
+ headers: {
145
+ 'Content-Type': 'application/json',
146
+ Authorization: `Bearer ${relayAuthToken}`,
147
+ },
148
+ body: JSON.stringify({ payload, expectedRecipient }),
149
+ signal: AbortSignal.timeout(ESCROW_RELAY_TIMEOUT_MS),
150
+ });
151
+ } catch {
152
+ throw new TRPCError({
153
+ code: 'INTERNAL_SERVER_ERROR',
154
+ message: 'Failed to send the recovery key. Please try again.',
155
+ });
156
+ }
157
+
158
+ const result: unknown = await response.json().catch(() => null);
159
+ const accepted =
160
+ typeof result === 'object' &&
161
+ result !== null &&
162
+ 'accepted' in result &&
163
+ result.accepted === true &&
164
+ 'messageId' in result &&
165
+ typeof result.messageId === 'string' &&
166
+ result.messageId.length > 0;
167
+
168
+ if (!response.ok || !accepted) {
169
+ throw new TRPCError({
170
+ code: 'INTERNAL_SERVER_ERROR',
171
+ message: 'Failed to send the recovery key. Please try again.',
172
+ });
173
+ }
174
+ };
175
+
176
+ export async function verifyAndGetContactMethod(input: {
54
177
  authToken: string;
55
178
  providerType: AuthProviderType;
56
179
  }): Promise<{
57
180
  user: { id: string; email?: string; phone?: string; providerType: string };
58
181
  contactMethod: ContactMethod;
182
+ authProvider: AuthProviderMapping;
59
183
  }> {
60
184
  const user = await verifyAuthToken(input.authToken, input.providerType);
61
185
 
@@ -67,10 +191,418 @@ async function verifyAndGetContactMethod(input: {
67
191
  });
68
192
  }
69
193
 
70
- return { user, contactMethod };
194
+ return {
195
+ user,
196
+ contactMethod,
197
+ authProvider: { type: input.providerType, id: user.id },
198
+ };
71
199
  }
72
200
 
201
+ export const requireUserKey = async (
202
+ authProvider: AuthProviderMapping,
203
+ message = 'User key not found. Set up SSS first.'
204
+ ): Promise<MongoUserKeyType> => {
205
+ const userKey = await findUserKeyByAuthProvider(authProvider.type, authProvider.id);
206
+
207
+ if (!userKey) throw new TRPCError({ code: 'NOT_FOUND', message });
208
+
209
+ return userKey;
210
+ };
211
+
212
+ export const assertDidOwner = (userKey: MongoUserKeyType, authenticatedDid: string): void => {
213
+ if (!userKey.primaryDid || userKey.primaryDid !== authenticatedDid) {
214
+ throw new TRPCError({
215
+ code: 'FORBIDDEN',
216
+ message: 'The authenticated DID does not own this key record.',
217
+ });
218
+ }
219
+ };
220
+
221
+ const serializeConfirmedRecoveryMethods = (userKey: MongoUserKeyType) =>
222
+ (userKey.recoveryMethods ?? [])
223
+ .filter(method => isRecoveryMethodConfirmed(userKey, method))
224
+ .map(method => ({
225
+ type: method.type,
226
+ createdAt:
227
+ method.createdAt instanceof Date
228
+ ? method.createdAt.toISOString()
229
+ : String(method.createdAt),
230
+ ...(method.credentialId ? { credentialId: method.credentialId } : {}),
231
+ ...(method.shareVersion != null ? { shareVersion: method.shareVersion } : {}),
232
+ }));
233
+
234
+ export const requireRecoverySession = async (
235
+ token: string,
236
+ scope: 'recover' | 'rebind'
237
+ ): Promise<AuthProviderMapping> => {
238
+ const session = await consumeRecoverySession(token, scope);
239
+
240
+ if (!session || !AuthProviderTypeValidator.safeParse(session.authProvider.type).success) {
241
+ throw new TRPCError({
242
+ code: 'UNAUTHORIZED',
243
+ message: 'This recovery session is invalid or expired. Start again.',
244
+ });
245
+ }
246
+
247
+ return session.authProvider as AuthProviderMapping;
248
+ };
249
+
250
+ const notifyIdentityRebind = async (
251
+ userKey: MongoUserKeyType,
252
+ newContactMethod: ContactMethod,
253
+ branding: Partial<TenantBranding> | undefined
254
+ ): Promise<void> => {
255
+ const recipients = new Set<string>();
256
+
257
+ if (userKey.contactMethod.type === 'email') recipients.add(userKey.contactMethod.value);
258
+ if (userKey.recoveryEmail) recipients.add(userKey.recoveryEmail);
259
+ if (newContactMethod.type === 'email') recipients.add(newContactMethod.value);
260
+
261
+ const results = await Promise.allSettled(
262
+ [...recipients].map(to =>
263
+ getDeliveryService().send({
264
+ to,
265
+ templateAlias: 'account-sign-in-changed',
266
+ templateModel: {},
267
+ branding,
268
+ from: getFrom({ mailbox: 'recovery', branding }),
269
+ })
270
+ )
271
+ );
272
+
273
+ results.forEach(result => {
274
+ if (result.status === 'rejected') {
275
+ console.error(
276
+ '[notifyIdentityRebind] Failed to send security notification:',
277
+ result.reason
278
+ );
279
+ }
280
+ });
281
+ };
282
+
73
283
  export const keysRouter = t.router({
284
+ startRecoverySession: openRoute
285
+ .meta({
286
+ openapi: {
287
+ method: 'POST',
288
+ path: '/keys/recovery-session/start',
289
+ tags: ['Keys'],
290
+ summary: 'Send a recovery-session verification code',
291
+ },
292
+ })
293
+ .input(z.object({ email: z.string().email() }))
294
+ .output(z.object({ success: z.literal(true) }))
295
+ .mutation(async ({ input, ctx }) => {
296
+ const email = input.email.trim().toLowerCase();
297
+ const canSend = await claimRecoveryOtpSendWindow(email);
298
+
299
+ if (!canSend) {
300
+ throw new TRPCError({
301
+ code: 'TOO_MANY_REQUESTS',
302
+ message: 'Please wait before requesting another code.',
303
+ });
304
+ }
305
+
306
+ const userKey = await findUniqueUserKeyByVerifiedRecoveryEmail(email);
307
+ const hasConfirmedMethod = userKey
308
+ ? serializeConfirmedRecoveryMethods(userKey).length > 0
309
+ : false;
310
+
311
+ // Deliberately return the same response when no account matches.
312
+ if (!userKey || !hasConfirmedMethod || !userKey.authProviders[0]) {
313
+ return { success: true as const };
314
+ }
315
+
316
+ const code = generate6DigitCode();
317
+ await storeRecoveryOtp(email, {
318
+ codeHash: hashRecoveryConfirmationCode(code),
319
+ authProvider: userKey.authProviders[0],
320
+ });
321
+
322
+ try {
323
+ await getDeliveryService().send({
324
+ to: email,
325
+ templateAlias: RECOVERY_EMAIL_CODE_TEMPLATE_ALIAS || 'recovery-email-code',
326
+ templateModel: { verificationCode: code, verificationEmail: email },
327
+ branding: ctx.tenant?.emailBranding,
328
+ from: getFrom({ mailbox: 'recovery', branding: ctx.tenant?.emailBranding }),
329
+ });
330
+ } catch (error) {
331
+ console.error('[startRecoverySession] Failed to send verification email:', error);
332
+ }
333
+
334
+ return { success: true as const };
335
+ }),
336
+
337
+ verifyRecoverySession: openRoute
338
+ .meta({
339
+ openapi: {
340
+ method: 'POST',
341
+ path: '/keys/recovery-session/verify',
342
+ tags: ['Keys'],
343
+ summary: 'Verify recovery email and issue a one-use recovery token',
344
+ },
345
+ })
346
+ .input(z.object({ email: z.string().email(), code: z.string().regex(/^\d{6}$/) }))
347
+ .output(
348
+ z.object({
349
+ recoverySessionToken: RecoverySessionTokenValidator,
350
+ recoveryMethods: z.array(RecoveryMethodResponseValidator),
351
+ escrowPin: EscrowPinStatusValidator,
352
+ })
353
+ )
354
+ .mutation(async ({ input }) => {
355
+ const email = input.email.trim().toLowerCase();
356
+ const verification = await verifyRecoveryOtp(
357
+ email,
358
+ hashRecoveryConfirmationCode(input.code)
359
+ );
360
+
361
+ if (verification.status === 'locked') {
362
+ throw new TRPCError({
363
+ code: 'TOO_MANY_REQUESTS',
364
+ message: 'Too many incorrect attempts. Request a new code later.',
365
+ });
366
+ }
367
+
368
+ if (verification.status === 'mismatch') {
369
+ throw new TRPCError({
370
+ code:
371
+ verification.attempts >= MAX_RECOVERY_OTP_ATTEMPTS
372
+ ? 'TOO_MANY_REQUESTS'
373
+ : 'BAD_REQUEST',
374
+ message:
375
+ verification.attempts >= MAX_RECOVERY_OTP_ATTEMPTS
376
+ ? 'Too many incorrect attempts. Request a new code later.'
377
+ : 'Incorrect or expired code. Please try again.',
378
+ });
379
+ }
380
+
381
+ if (verification.status === 'none') {
382
+ throw new TRPCError({
383
+ code: 'BAD_REQUEST',
384
+ message: 'Incorrect or expired code. Please try again.',
385
+ });
386
+ }
387
+
388
+ const userKey = await requireUserKey(
389
+ verification.record.authProvider,
390
+ 'Recovery account not found.'
391
+ );
392
+ const recoveryMethods = serializeConfirmedRecoveryMethods(userKey);
393
+
394
+ if (recoveryMethods.length === 0) {
395
+ throw new TRPCError({
396
+ code: 'BAD_REQUEST',
397
+ message: 'No recovery method is available.',
398
+ });
399
+ }
400
+
401
+ const recoverySessionToken = await createRecoverySession({
402
+ scope: 'recover',
403
+ authProvider: verification.record.authProvider,
404
+ });
405
+
406
+ return {
407
+ recoverySessionToken,
408
+ recoveryMethods,
409
+ escrowPin: getEscrowPinStatus(userKey),
410
+ };
411
+ }),
412
+
413
+ useRecoverySession: openRoute
414
+ .meta({
415
+ openapi: {
416
+ method: 'POST',
417
+ path: '/keys/recovery-session/recover',
418
+ tags: ['Keys'],
419
+ summary: 'Release version-matched recovery material under a recovery session',
420
+ },
421
+ })
422
+ .input(
423
+ z.object({
424
+ recoverySessionToken: RecoverySessionTokenValidator,
425
+ type: RecoveryMethodTypeValidator,
426
+ credentialId: z.string().optional(),
427
+ })
428
+ )
429
+ .output(
430
+ z.object({
431
+ authShare: ServerEncryptedShareValidator,
432
+ encryptedShare: EncryptedShareValidator.optional(),
433
+ primaryDid: z.string(),
434
+ shareVersion: z.number(),
435
+ rebindSessionToken: RecoverySessionTokenValidator,
436
+ })
437
+ )
438
+ .mutation(async ({ input }) => {
439
+ const authProvider = await requireRecoverySession(
440
+ input.recoverySessionToken,
441
+ 'recover'
442
+ );
443
+ if (input.type === 'escrow') {
444
+ throw new TRPCError({
445
+ code: 'BAD_REQUEST',
446
+ message: 'Use /keys/escrow/recover for automatic recovery.',
447
+ });
448
+ }
449
+ const userKey = await requireUserKey(authProvider, 'Recovery account not found.');
450
+ const recoveryMethod = (userKey.recoveryMethods ?? []).find(method => {
451
+ if (method.type !== input.type || !isRecoveryMethodConfirmed(userKey, method)) {
452
+ return false;
453
+ }
454
+
455
+ return input.type !== 'passkey' || method.credentialId === input.credentialId;
456
+ });
457
+
458
+ if (!recoveryMethod?.shareVersion) {
459
+ throw new TRPCError({
460
+ code: 'BAD_REQUEST',
461
+ message: 'Recovery method is unavailable.',
462
+ });
463
+ }
464
+
465
+ const encryptedAuthShare = findAuthShareByVersion(userKey, recoveryMethod.shareVersion);
466
+ const seed = environment.SEED;
467
+
468
+ if (!encryptedAuthShare || !seed) {
469
+ throw new TRPCError({
470
+ code: 'BAD_REQUEST',
471
+ message: 'Recovery method is outdated.',
472
+ });
473
+ }
474
+
475
+ const authShare = decryptAuthShare(encryptedAuthShare, seed);
476
+ const rebindSessionToken = await createRecoverySession({
477
+ scope: 'rebind',
478
+ authProvider,
479
+ });
480
+
481
+ return {
482
+ authShare,
483
+ encryptedShare: recoveryMethod.encryptedShare,
484
+ primaryDid: userKey.primaryDid,
485
+ shareVersion: recoveryMethod.shareVersion,
486
+ rebindSessionToken,
487
+ };
488
+ }),
489
+
490
+ completeRecoveryRebind: didAndChallengeRoute
491
+ .meta({
492
+ openapi: {
493
+ method: 'POST',
494
+ path: '/keys/recovery-session/rebind',
495
+ tags: ['Keys'],
496
+ summary: 'Bind a replacement login and commit recovery rotation',
497
+ },
498
+ })
499
+ .input(
500
+ z.object({
501
+ recoverySessionToken: RecoverySessionTokenValidator,
502
+ newAuthToken: z.string().min(1).optional(),
503
+ providerType: AuthProviderTypeValidator,
504
+ primaryDid: z.string(),
505
+ authShare: ServerEncryptedShareValidator,
506
+ })
507
+ )
508
+ .output(
509
+ z.object({
510
+ success: z.literal(true),
511
+ shareVersion: z.number(),
512
+ recoveryMethodsRequireConfirmation: z.array(RecoveryMethodTypeValidator),
513
+ })
514
+ )
515
+ .mutation(async ({ input, ctx }) => {
516
+ const oldAuthProvider = await requireRecoverySession(
517
+ input.recoverySessionToken,
518
+ 'rebind'
519
+ );
520
+ const oldUserKey = await requireUserKey(oldAuthProvider, 'Recovery account not found.');
521
+
522
+ assertDidOwner(oldUserKey, ctx.user.did);
523
+ if (input.primaryDid !== ctx.user.did) {
524
+ throw new TRPCError({
525
+ code: 'FORBIDDEN',
526
+ message: 'Recovered DID does not match.',
527
+ });
528
+ }
529
+
530
+ const newUser = await verifyAuthToken(
531
+ input.newAuthToken || ctx.providerToken || '',
532
+ input.providerType
533
+ );
534
+ const newContactMethod = getContactMethodFromUser(newUser);
535
+
536
+ if (!newContactMethod) {
537
+ throw new TRPCError({
538
+ code: 'BAD_REQUEST',
539
+ message: 'New sign-in needs an email or phone.',
540
+ });
541
+ }
542
+
543
+ const seed = environment.SEED;
544
+ if (!seed) {
545
+ throw new TRPCError({
546
+ code: 'INTERNAL_SERVER_ERROR',
547
+ message: 'Server configuration error.',
548
+ });
549
+ }
550
+
551
+ const result = await completeIdentityRebind({
552
+ oldAuthProvider,
553
+ newAuthProvider: { type: input.providerType, id: newUser.id },
554
+ newContactMethod,
555
+ primaryDid: input.primaryDid,
556
+ authShare: encryptAuthShare(input.authShare, seed),
557
+ });
558
+
559
+ if (!result) {
560
+ throw new TRPCError({
561
+ code: 'CONFLICT',
562
+ message: 'The new sign-in is already linked or recovery state changed.',
563
+ });
564
+ }
565
+
566
+ // Notification is intentionally fail-open after the security update commits.
567
+ void notifyIdentityRebind(
568
+ oldUserKey,
569
+ newContactMethod,
570
+ ctx.tenant?.emailBranding
571
+ ).catch(error => console.error('[completeRecoveryRebind] Notification failed:', error));
572
+
573
+ return {
574
+ success: true as const,
575
+ shareVersion: result.shareVersion,
576
+ recoveryMethodsRequireConfirmation: result.methodsRequiringConfirmation,
577
+ };
578
+ }),
579
+
580
+ issueDidChallenge: didRoute
581
+ .meta({
582
+ openapi: {
583
+ method: 'POST',
584
+ path: '/keys/challenge',
585
+ tags: ['Keys'],
586
+ summary: 'Issue a short-lived DID challenge for a sensitive key write',
587
+ },
588
+ })
589
+ .input(z.object({ did: z.string() }))
590
+ .output(z.object({ challenge: z.string() }))
591
+ .mutation(async ({ ctx, input }) => {
592
+ if (ctx.user.did !== input.did) {
593
+ throw new TRPCError({
594
+ code: 'FORBIDDEN',
595
+ message: 'The authenticated DID does not match the requested challenge.',
596
+ });
597
+ }
598
+
599
+ const challenge = randomBytes(32).toString('hex');
600
+
601
+ await setValidChallengeForDid(ctx.user.did, challenge);
602
+
603
+ return { challenge };
604
+ }),
605
+
74
606
  getAuthShare: openRoute
75
607
  .meta({
76
608
  openapi: {
@@ -93,32 +625,33 @@ export const keysRouter = t.router({
93
625
  securityLevel: z.enum(['basic', 'enhanced', 'advanced']),
94
626
  recoveryMethods: z.array(
95
627
  z.object({
96
- type: z.enum(['passkey', 'backup', 'phrase', 'email']),
628
+ type: RecoveryMethodTypeValidator,
97
629
  createdAt: z.string(),
98
630
  credentialId: z.string().optional(),
99
631
  shareVersion: z.number().optional(),
632
+ confirmedAt: z.string().optional(),
100
633
  })
101
634
  ),
102
635
  keyProvider: z.enum(['web3auth', 'sss']),
103
636
  shareVersion: z.number(),
104
637
  maskedRecoveryEmail: z.string().nullable(),
638
+ escrowOptedOut: z.boolean(),
639
+ escrowPin: EscrowPinStatusValidator,
640
+ escrowStale: z.enum(['mode-mismatch', 'key-rotated']).optional(),
641
+ sssActivationState: z.enum(['provisional', 'active']),
105
642
  })
106
643
  .nullable()
107
644
  )
108
645
  .mutation(async ({ input }) => {
109
- const { user, contactMethod } = await verifyAndGetContactMethod(input);
646
+ const { authProvider } = await verifyAndGetContactMethod(input);
647
+ await purgeExpiredProvisionalMigrationByAuthProvider(authProvider);
110
648
 
111
- const userKey = await findUserKeyByContactMethod(contactMethod);
649
+ const userKey = await findUserKeyByAuthProvider(authProvider.type, authProvider.id);
112
650
 
113
651
  if (!userKey) {
114
652
  return null;
115
653
  }
116
654
 
117
- await addAuthProviderToUserKey(contactMethod, {
118
- type: input.providerType,
119
- id: user.id,
120
- });
121
-
122
655
  // If a specific shareVersion is requested, look it up from history
123
656
  const requestedVersion = input.shareVersion;
124
657
  const rawAuthShare =
@@ -144,7 +677,9 @@ export const keysRouter = t.router({
144
677
  }
145
678
 
146
679
  const recoveryMethods = (userKey.recoveryMethods ?? [])
147
- .filter(rm => rm && rm.type && rm.createdAt)
680
+ .filter(
681
+ rm => rm && rm.type && rm.createdAt && isRecoveryMethodConfirmed(userKey, rm)
682
+ )
148
683
  .map(rm => ({
149
684
  type: rm.type,
150
685
  createdAt:
@@ -153,6 +688,14 @@ export const keysRouter = t.router({
153
688
  : String(rm.createdAt),
154
689
  ...(rm.credentialId ? { credentialId: rm.credentialId } : {}),
155
690
  ...(rm.shareVersion != null ? { shareVersion: rm.shareVersion } : {}),
691
+ ...(rm.confirmedAt
692
+ ? {
693
+ confirmedAt:
694
+ rm.confirmedAt instanceof Date
695
+ ? rm.confirmedAt.toISOString()
696
+ : String(rm.confirmedAt),
697
+ }
698
+ : {}),
156
699
  }));
157
700
 
158
701
  return {
@@ -161,14 +704,21 @@ export const keysRouter = t.router({
161
704
  securityLevel: userKey.securityLevel ?? 'basic',
162
705
  recoveryMethods,
163
706
  keyProvider: userKey.keyProvider ?? 'sss',
164
- shareVersion: userKey.shareVersion ?? 1,
707
+ // No auth material has observable version 0, just like a missing record.
708
+ shareVersion: userKey.authShare ? (userKey.shareVersion ?? 1) : 0,
165
709
  maskedRecoveryEmail: userKey.recoveryEmail
166
710
  ? maskEmail(userKey.recoveryEmail)
167
711
  : null,
712
+ sssActivationState: getSssActivationState(userKey),
713
+ escrowOptedOut: Boolean(userKey.escrowOptedOutAt),
714
+ escrowPin: getEscrowPinStatus(userKey),
715
+ escrowStale: userKey.escrowBlob
716
+ ? await getEscrowBlobStaleReason(userKey.escrowBlob)
717
+ : undefined,
168
718
  };
169
719
  }),
170
720
 
171
- storeAuthShare: didRoute
721
+ storeAuthShare: didAndChallengeRoute
172
722
  .meta({
173
723
  openapi: {
174
724
  method: 'PUT',
@@ -183,23 +733,50 @@ export const keysRouter = t.router({
183
733
  primaryDid: z.string(),
184
734
  securityLevel: z.enum(['basic', 'enhanced', 'advanced']).optional(),
185
735
  keyProvider: z.enum(['web3auth', 'sss']).optional(),
736
+ sssActivationState: z.literal('provisional').optional(),
737
+ expectedShareVersion: z.number().int().min(0).optional(),
738
+ })
739
+ )
740
+ .output(
741
+ z.object({
742
+ success: z.boolean(),
743
+ shareVersion: z.number(),
744
+ expectedShareVersionChecked: z.boolean(),
186
745
  })
187
746
  )
188
- .output(z.object({ success: z.boolean(), shareVersion: z.number() }))
189
747
  .mutation(async ({ ctx, input }) => {
190
- // DID-Auth: use the VP-authenticated DID as the authoritative primaryDid.
191
- // The VP is already cryptographically verified by didRoute, so ctx.user.did
192
- // is the proven owner of the signing key. We prefer it over input.primaryDid
193
- // to avoid DID-method format mismatches (e.g. did:key vs did:web).
194
748
  const authenticatedDid = ctx.user.did;
195
749
 
196
- if (input.primaryDid && input.primaryDid !== authenticatedDid) {
197
- console.warn(
198
- `[storeAuthShare] DID format mismatch — VP DID: ${authenticatedDid}, body primaryDid: ${input.primaryDid}. Using VP DID.`
199
- );
750
+ if (input.primaryDid !== authenticatedDid) {
751
+ throw new TRPCError({
752
+ code: 'FORBIDDEN',
753
+ message: 'The authenticated DID does not match primaryDid.',
754
+ });
755
+ }
756
+
757
+ const { contactMethod, authProvider } = await verifyAndGetContactMethod(input);
758
+ const existing = await findUserKeyByAuthProvider(authProvider.type, authProvider.id);
759
+
760
+ if (existing) assertDidOwner(existing, authenticatedDid);
761
+
762
+ // Match getAuthShare: null (no record) and authShare-less records mean 0.
763
+ const currentVersion = existing?.authShare ? (existing.shareVersion ?? 1) : 0;
764
+ if (
765
+ input.expectedShareVersion !== undefined &&
766
+ input.expectedShareVersion !== currentVersion
767
+ ) {
768
+ throw new TRPCError({
769
+ code: 'CONFLICT',
770
+ message: 'Key material changed; please retry',
771
+ });
200
772
  }
201
773
 
202
- const { user, contactMethod } = await verifyAndGetContactMethod(input);
774
+ const isMigration = existing?.keyProvider === 'web3auth';
775
+ const shouldRemainProvisional =
776
+ !existing || isMigration || existing.sssActivationState === 'provisional';
777
+ const provisionalCreatedAt = shouldRemainProvisional
778
+ ? (existing?.provisionalCreatedAt ?? new Date())
779
+ : undefined;
203
780
 
204
781
  // Encrypt the auth share at rest using the server SEED
205
782
  const seed = environment.SEED;
@@ -213,18 +790,42 @@ export const keysRouter = t.router({
213
790
 
214
791
  const authShareToStore = encryptAuthShare(input.authShare, seed);
215
792
 
216
- const updatedDoc = await upsertUserKey(contactMethod, {
217
- authShare: authShareToStore,
218
- primaryDid: authenticatedDid,
219
- securityLevel: input.securityLevel ?? 'basic',
220
- keyProvider: input.keyProvider ?? 'sss',
221
- authProviders: [{ type: input.providerType, id: user.id }],
222
- });
793
+ let updatedDoc: MongoUserKeyType;
223
794
 
224
- return { success: true, shareVersion: updatedDoc.shareVersion ?? 1 };
795
+ try {
796
+ updatedDoc = await upsertUserKeyByAuthProvider(
797
+ contactMethod,
798
+ authProvider,
799
+ {
800
+ authShare: authShareToStore,
801
+ primaryDid: authenticatedDid,
802
+ securityLevel: input.securityLevel ?? 'basic',
803
+ keyProvider: isMigration ? 'web3auth' : (existing?.keyProvider ?? 'sss'),
804
+ sssActivationState: shouldRemainProvisional ? 'provisional' : 'active',
805
+ ...(provisionalCreatedAt ? { provisionalCreatedAt } : {}),
806
+ },
807
+ // Legacy clients keep server-observed CAS; first inserts stay unconditional.
808
+ input.expectedShareVersion ?? (existing ? currentVersion : undefined)
809
+ );
810
+ } catch (error) {
811
+ if (error instanceof UserKeyVersionConflictError) {
812
+ throw new TRPCError({
813
+ code: 'CONFLICT',
814
+ message: 'Key material changed; please retry',
815
+ });
816
+ }
817
+
818
+ throw error;
819
+ }
820
+
821
+ return {
822
+ success: true,
823
+ shareVersion: updatedDoc.shareVersion ?? 1,
824
+ expectedShareVersionChecked: input.expectedShareVersion !== undefined,
825
+ };
225
826
  }),
226
827
 
227
- addRecoveryMethod: didRoute
828
+ addRecoveryMethod: didAndChallengeRoute
228
829
  .meta({
229
830
  openapi: {
230
831
  method: 'POST',
@@ -235,7 +836,7 @@ export const keysRouter = t.router({
235
836
  })
236
837
  .input(
237
838
  AuthInputValidator.extend({
238
- type: z.enum(['passkey', 'backup', 'phrase', 'email']),
839
+ type: RecoveryMethodTypeValidator,
239
840
  encryptedShare: EncryptedShareValidator.optional(),
240
841
  credentialId: z.string().optional(),
241
842
  shareVersion: z.number().optional(),
@@ -243,36 +844,198 @@ export const keysRouter = t.router({
243
844
  )
244
845
  .output(z.object({ success: z.boolean() }))
245
846
  .mutation(async ({ ctx, input }) => {
246
- const { contactMethod } = await verifyAndGetContactMethod(input);
847
+ const { authProvider } = await verifyAndGetContactMethod(input);
848
+ const userKey = await requireUserKey(authProvider);
247
849
 
248
- const userKey = await findUserKeyByContactMethod(contactMethod);
249
- if (!userKey) {
850
+ assertDidOwner(userKey, ctx.user.did);
851
+
852
+ if (input.type === 'escrow') {
250
853
  throw new TRPCError({
251
- code: 'NOT_FOUND',
252
- message: 'User key not found. Set up SSS first.',
854
+ code: 'BAD_REQUEST',
855
+ message: 'Use the automatic recovery enrollment endpoint.',
253
856
  });
254
857
  }
255
858
 
256
- // DID-Auth: warn on DID format mismatch but proceed — the VP is
257
- // already cryptographically verified, and format differences (did:key
258
- // vs did:web for the same underlying key) are expected.
259
- if (userKey.primaryDid && ctx.user.did !== userKey.primaryDid) {
260
- console.warn(
261
- `[addRecoveryMethod] DID format mismatch — VP DID: ${ctx.user.did}, stored primaryDid: ${userKey.primaryDid}. Proceeding (VP is verified).`
262
- );
859
+ // Confirmation only accepts pending methods at the current version.
860
+ const shareVersion = userKey.shareVersion ?? 1;
861
+ if (input.shareVersion !== undefined && input.shareVersion !== shareVersion) {
862
+ throw new TRPCError({
863
+ code: 'CONFLICT',
864
+ message: 'Key material changed; please retry',
865
+ });
263
866
  }
264
867
 
265
- await addRecoveryMethodToUserKey(contactMethod, {
868
+ await addRecoveryMethodToUserKeyByAuthProvider(authProvider, {
266
869
  type: input.type,
267
870
  createdAt: new Date(),
871
+ confirmationStatus: 'pending',
268
872
  credentialId: input.credentialId,
269
873
  encryptedShare: input.encryptedShare,
270
- shareVersion: input.shareVersion,
874
+ shareVersion,
271
875
  });
272
876
 
273
877
  return { success: true };
274
878
  }),
275
879
 
880
+ confirmRecoveryMethod: didAndChallengeRoute
881
+ .meta({
882
+ openapi: {
883
+ method: 'POST',
884
+ path: '/keys/recovery/confirm',
885
+ tags: ['Keys'],
886
+ summary: 'Confirm receipt of a pending recovery method',
887
+ },
888
+ })
889
+ .input(
890
+ AuthInputValidator.extend({
891
+ type: RecoveryMethodTypeValidator,
892
+ credentialId: z.string().optional(),
893
+ code: z.string().length(6).optional(),
894
+ })
895
+ )
896
+ .output(z.object({ success: z.boolean() }))
897
+ .mutation(async ({ ctx, input }) => {
898
+ const { authProvider } = await verifyAndGetContactMethod(input);
899
+ const userKey = await requireUserKey(authProvider);
900
+
901
+ assertDidOwner(userKey, ctx.user.did);
902
+
903
+ if (input.type === 'escrow') {
904
+ throw new TRPCError({
905
+ code: 'BAD_REQUEST',
906
+ message: 'Use /keys/escrow to manage automatic recovery.',
907
+ });
908
+ }
909
+ const shareVersion = userKey.shareVersion ?? 1;
910
+ const pendingMethod = (userKey.recoveryMethods ?? []).find(method => {
911
+ if (
912
+ method.type !== input.type ||
913
+ method.shareVersion !== shareVersion ||
914
+ method.confirmationStatus !== 'pending'
915
+ ) {
916
+ return false;
917
+ }
918
+
919
+ return input.type !== 'passkey' || method.credentialId === input.credentialId;
920
+ });
921
+
922
+ if (!pendingMethod) {
923
+ throw new TRPCError({
924
+ code: 'BAD_REQUEST',
925
+ message: 'No pending recovery method was found. Please set it up again.',
926
+ });
927
+ }
928
+
929
+ let confirmedEmail: string | undefined;
930
+
931
+ if (input.type === 'email') {
932
+ if (!input.code || !pendingMethod.confirmationCodeHash) {
933
+ throw new TRPCError({
934
+ code: 'BAD_REQUEST',
935
+ message: 'Enter the confirmation code from your recovery email.',
936
+ });
937
+ }
938
+
939
+ const attempts = pendingMethod.confirmationAttempts ?? 0;
940
+
941
+ if (attempts >= MAX_RECOVERY_METHOD_CONFIRMATION_ATTEMPTS) {
942
+ throw new TRPCError({
943
+ code: 'TOO_MANY_REQUESTS',
944
+ message:
945
+ 'Too many incorrect attempts. Send a new recovery key to try again.',
946
+ });
947
+ }
948
+
949
+ if (
950
+ !pendingMethod.confirmationCodeExpiresAt ||
951
+ pendingMethod.confirmationCodeExpiresAt.getTime() <= Date.now()
952
+ ) {
953
+ throw new TRPCError({
954
+ code: 'BAD_REQUEST',
955
+ message: 'This confirmation code has expired. Send a new recovery key.',
956
+ });
957
+ }
958
+
959
+ if (!confirmationCodeMatches(input.code, pendingMethod.confirmationCodeHash)) {
960
+ await incrementRecoveryConfirmationAttemptsByAuthProvider(
961
+ authProvider,
962
+ shareVersion
963
+ );
964
+
965
+ if (attempts + 1 >= MAX_RECOVERY_METHOD_CONFIRMATION_ATTEMPTS) {
966
+ throw new TRPCError({
967
+ code: 'TOO_MANY_REQUESTS',
968
+ message:
969
+ 'Too many incorrect attempts. Send a new recovery key to try again.',
970
+ });
971
+ }
972
+
973
+ throw new TRPCError({
974
+ code: 'BAD_REQUEST',
975
+ message: 'Incorrect confirmation code. Please try again.',
976
+ });
977
+ }
978
+
979
+ confirmedEmail = pendingMethod.confirmationEmail;
980
+ }
981
+
982
+ const confirmed = await confirmRecoveryMethodByAuthProvider(
983
+ authProvider,
984
+ input.type,
985
+ shareVersion,
986
+ input.credentialId
987
+ );
988
+
989
+ if (!confirmed) {
990
+ throw new TRPCError({
991
+ code: 'CONFLICT',
992
+ message: 'Recovery setup changed before it was confirmed. Please try again.',
993
+ });
994
+ }
995
+
996
+ if (confirmedEmail) {
997
+ await setRecoveryEmailByAuthProvider(authProvider, confirmedEmail);
998
+ }
999
+
1000
+ return { success: true };
1001
+ }),
1002
+
1003
+ deleteRecoveryMethod: didAndChallengeRoute
1004
+ .meta({
1005
+ openapi: {
1006
+ method: 'DELETE',
1007
+ path: '/keys/recovery',
1008
+ tags: ['Keys'],
1009
+ summary: 'Delete a recovery method for an authenticated user',
1010
+ },
1011
+ })
1012
+ .input(
1013
+ AuthInputValidator.extend({
1014
+ type: RecoveryMethodTypeValidator,
1015
+ credentialId: z.string().optional(),
1016
+ })
1017
+ )
1018
+ .output(z.object({ success: z.boolean() }))
1019
+ .mutation(async ({ ctx, input }) => {
1020
+ const { authProvider } = await verifyAndGetContactMethod(input);
1021
+ const userKey = await requireUserKey(authProvider);
1022
+
1023
+ assertDidOwner(userKey, ctx.user.did);
1024
+ if (input.type === 'escrow') {
1025
+ throw new TRPCError({
1026
+ code: 'BAD_REQUEST',
1027
+ message: 'Use /keys/escrow to manage automatic recovery.',
1028
+ });
1029
+ }
1030
+ await removeRecoveryMethodFromUserKeyByAuthProvider(
1031
+ authProvider,
1032
+ input.type,
1033
+ input.credentialId
1034
+ );
1035
+
1036
+ return { success: true };
1037
+ }),
1038
+
276
1039
  getRecoveryShare: openRoute
277
1040
  .meta({
278
1041
  openapi: {
@@ -283,8 +1046,16 @@ export const keysRouter = t.router({
283
1046
  },
284
1047
  })
285
1048
  .input(
286
- AuthInputValidator.extend({
287
- type: z.enum(['passkey', 'backup', 'phrase', 'email']),
1049
+ // P0-4: authToken is optional here — this GET route's query
1050
+ // string must not carry it (proxy/ALB access logs). Raw-fetch
1051
+ // callers (sss-key-manager) send it as an X-Auth-Token header,
1052
+ // read from ctx.providerToken below; native tRPC callers that
1053
+ // still pass it as input (e.g. lca-api-plugin, whose batch link
1054
+ // always POSTs — never a URL leak) keep working unchanged.
1055
+ z.object({
1056
+ authToken: z.string().optional(),
1057
+ providerType: AuthProviderTypeValidator,
1058
+ type: RecoveryMethodTypeValidator,
288
1059
  credentialId: z.string().optional(),
289
1060
  })
290
1061
  )
@@ -296,16 +1067,20 @@ export const keysRouter = t.router({
296
1067
  })
297
1068
  .nullable()
298
1069
  )
299
- .query(async ({ input }) => {
300
- const { contactMethod } = await verifyAndGetContactMethod(input);
1070
+ .query(async ({ input, ctx }) => {
1071
+ const { authProvider } = await verifyAndGetContactMethod({
1072
+ authToken: input.authToken || ctx.providerToken || '',
1073
+ providerType: input.providerType,
1074
+ });
301
1075
 
302
- const userKey = await findUserKeyByContactMethod(contactMethod);
1076
+ const userKey = await findUserKeyByAuthProvider(authProvider.type, authProvider.id);
303
1077
  if (!userKey) {
304
1078
  return null;
305
1079
  }
306
1080
 
307
1081
  const recoveryMethod = (userKey.recoveryMethods ?? []).find(rm => {
308
1082
  if (rm.type !== input.type) return false;
1083
+ if (!isRecoveryMethodConfirmed(userKey, rm)) return false;
309
1084
  if (input.type === 'passkey' && input.credentialId) {
310
1085
  return rm.credentialId === input.credentialId;
311
1086
  }
@@ -313,6 +1088,7 @@ export const keysRouter = t.router({
313
1088
  });
314
1089
 
315
1090
  if (!recoveryMethod) return null;
1091
+ if (input.type === 'escrow') return { shareVersion: recoveryMethod.shareVersion };
316
1092
 
317
1093
  return {
318
1094
  encryptedShare: recoveryMethod.encryptedShare ?? undefined,
@@ -320,7 +1096,7 @@ export const keysRouter = t.router({
320
1096
  };
321
1097
  }),
322
1098
 
323
- markMigrated: didRoute
1099
+ markMigrated: didAndChallengeRoute
324
1100
  .meta({
325
1101
  openapi: {
326
1102
  method: 'POST',
@@ -332,24 +1108,83 @@ export const keysRouter = t.router({
332
1108
  .input(AuthInputValidator)
333
1109
  .output(z.object({ success: z.boolean() }))
334
1110
  .mutation(async ({ ctx, input }) => {
335
- const { contactMethod } = await verifyAndGetContactMethod(input);
1111
+ const { authProvider } = await verifyAndGetContactMethod(input);
1112
+ const userKey = await requireUserKey(authProvider);
336
1113
 
337
- const userKey = await findUserKeyByContactMethod(contactMethod);
1114
+ assertDidOwner(userKey, ctx.user.did);
338
1115
 
339
- if (userKey?.primaryDid && ctx.user.did !== userKey.primaryDid) {
340
- console.warn(
341
- `[markMigrated] DID format mismatch — VP DID: ${ctx.user.did}, stored primaryDid: ${userKey.primaryDid}. Proceeding (VP is verified).`
342
- );
1116
+ // storeAuthShare creates a provisional SSS record when a legacy user
1117
+ // has no prior UserKey. Record migration provenance for that case too.
1118
+ if (
1119
+ userKey.keyProvider !== 'web3auth' &&
1120
+ userKey.sssActivationState !== 'provisional'
1121
+ ) {
1122
+ throw new TRPCError({
1123
+ code: 'BAD_REQUEST',
1124
+ message: 'This key record is not eligible for migration.',
1125
+ });
343
1126
  }
344
1127
 
345
- await markUserKeyMigrated(contactMethod);
1128
+ const marked = await markUserKeyMigrationProvisionalByAuthProvider(
1129
+ authProvider,
1130
+ userKey.provisionalCreatedAt ?? new Date()
1131
+ );
1132
+ if (!marked) {
1133
+ throw new TRPCError({
1134
+ code: 'CONFLICT',
1135
+ message: 'The key record changed before migration. Please try again.',
1136
+ });
1137
+ }
1138
+
1139
+ return { success: true };
1140
+ }),
1141
+
1142
+ activate: didAndChallengeRoute
1143
+ .meta({
1144
+ openapi: {
1145
+ method: 'POST',
1146
+ path: '/keys/activate',
1147
+ tags: ['Keys'],
1148
+ summary: 'Activate provisioned SSS shares after recovery enrollment',
1149
+ },
1150
+ })
1151
+ .input(AuthInputValidator)
1152
+ .output(z.object({ success: z.boolean() }))
1153
+ .mutation(async ({ ctx, input }) => {
1154
+ const { authProvider } = await verifyAndGetContactMethod(input);
1155
+ const userKey = await requireUserKey(authProvider);
1156
+
1157
+ assertDidOwner(userKey, ctx.user.did);
1158
+
1159
+ if (
1160
+ userKey.sssActivationState !== 'provisional' ||
1161
+ !hasActivatableRecoveryMethod(userKey)
1162
+ ) {
1163
+ throw new TRPCError({
1164
+ code: 'BAD_REQUEST',
1165
+ message: 'A recovery method for the current key version is required.',
1166
+ });
1167
+ }
1168
+
1169
+ const activated = await activateUserKeyByAuthProvider(
1170
+ authProvider,
1171
+ userKey.shareVersion ?? 1,
1172
+ userKey.keyProvider === 'web3auth'
1173
+ );
1174
+
1175
+ if (!activated) {
1176
+ throw new TRPCError({
1177
+ code: 'CONFLICT',
1178
+ message: 'The key record changed before activation. Please try again.',
1179
+ });
1180
+ }
346
1181
 
347
1182
  return { success: true };
348
1183
  }),
349
1184
 
350
1185
  // ── Recovery email verification ─────────────────────────────
351
1186
 
352
- addRecoveryEmail: didRoute
1187
+ addRecoveryEmail: didAndChallengeRoute
353
1188
  .meta({
354
1189
  openapi: {
355
1190
  method: 'POST',
@@ -365,15 +1200,10 @@ export const keysRouter = t.router({
365
1200
  )
366
1201
  .output(z.object({ success: z.boolean() }))
367
1202
  .mutation(async ({ input, ctx }) => {
368
- const { contactMethod } = await verifyAndGetContactMethod(input);
1203
+ const { authProvider } = await verifyAndGetContactMethod(input);
1204
+ const userKey = await requireUserKey(authProvider);
369
1205
 
370
- const userKey = await findUserKeyByContactMethod(contactMethod);
371
- if (!userKey) {
372
- throw new TRPCError({
373
- code: 'NOT_FOUND',
374
- message: 'User key not found. Set up SSS first.',
375
- });
376
- }
1206
+ assertDidOwner(userKey, ctx.user.did);
377
1207
 
378
1208
  // Prevent using the primary login email as the recovery email
379
1209
  if (
@@ -387,7 +1217,7 @@ export const keysRouter = t.router({
387
1217
  }
388
1218
 
389
1219
  const code = generate6DigitCode();
390
- const cacheKey = `${RECOVERY_EMAIL_CODE_PREFIX}${contactMethod.type}:${contactMethod.value}`;
1220
+ const cacheKey = `${RECOVERY_EMAIL_CODE_PREFIX}${authProvider.type}:${authProvider.id}`;
391
1221
 
392
1222
  // Store: code + target email so verify can confirm both
393
1223
  await cache.set(
@@ -396,6 +1226,11 @@ export const keysRouter = t.router({
396
1226
  RECOVERY_EMAIL_CODE_TTL_SECS
397
1227
  );
398
1228
 
1229
+ // Clear the verification attempt counter so user can try the new code
1230
+ await clearRateLimit(
1231
+ `recovery-verify-attempts:${authProvider.type}:${authProvider.id}`
1232
+ );
1233
+
399
1234
  try {
400
1235
  // Always render locally via @learncard/email-templates for
401
1236
  // tenant-branded output. Falls back to the 'recovery-email-code'
@@ -422,7 +1257,7 @@ export const keysRouter = t.router({
422
1257
  return { success: true };
423
1258
  }),
424
1259
 
425
- verifyRecoveryEmail: didRoute
1260
+ verifyRecoveryEmail: didAndChallengeRoute
426
1261
  .meta({
427
1262
  openapi: {
428
1263
  method: 'POST',
@@ -437,18 +1272,31 @@ export const keysRouter = t.router({
437
1272
  })
438
1273
  )
439
1274
  .output(z.object({ success: z.boolean(), maskedEmail: z.string() }))
440
- .mutation(async ({ input }) => {
441
- const { contactMethod } = await verifyAndGetContactMethod(input);
1275
+ .mutation(async ({ input, ctx }) => {
1276
+ const { authProvider } = await verifyAndGetContactMethod(input);
1277
+ const userKey = await requireUserKey(authProvider, 'User key not found.');
442
1278
 
443
- const userKey = await findUserKeyByContactMethod(contactMethod);
444
- if (!userKey) {
1279
+ assertDidOwner(userKey, ctx.user.did);
1280
+
1281
+ const cacheKey = `${RECOVERY_EMAIL_CODE_PREFIX}${authProvider.type}:${authProvider.id}`;
1282
+ const attemptKey = `recovery-verify-attempts:${authProvider.type}:${authProvider.id}`;
1283
+
1284
+ // Check rate limit (5 failed attempts per auth provider identity within code TTL)
1285
+ const allowed = await checkRateLimit(
1286
+ attemptKey,
1287
+ RECOVERY_VERIFY_MAX_ATTEMPTS,
1288
+ RECOVERY_EMAIL_CODE_TTL_SECS
1289
+ );
1290
+
1291
+ if (!allowed) {
1292
+ // On exceeding limit, delete the pending recovery code
1293
+ await cache.delete([cacheKey]);
445
1294
  throw new TRPCError({
446
- code: 'NOT_FOUND',
447
- message: 'User key not found.',
1295
+ code: 'TOO_MANY_REQUESTS',
1296
+ message: 'Too many attempts. Please resend code.',
448
1297
  });
449
1298
  }
450
1299
 
451
- const cacheKey = `${RECOVERY_EMAIL_CODE_PREFIX}${contactMethod.type}:${contactMethod.value}`;
452
1300
  const raw = await cache.get(cacheKey);
453
1301
 
454
1302
  if (!raw) {
@@ -461,6 +1309,7 @@ export const keysRouter = t.router({
461
1309
  const { code: storedCode, email } = JSON.parse(raw) as { code: string; email: string };
462
1310
 
463
1311
  if (input.code !== storedCode) {
1312
+ // Failed attempt — rate limit counter already incremented above
464
1313
  throw new TRPCError({
465
1314
  code: 'BAD_REQUEST',
466
1315
  message: 'Incorrect code. Please try again.',
@@ -469,7 +1318,8 @@ export const keysRouter = t.router({
469
1318
 
470
1319
  // Code is valid — consume it and store the verified recovery email
471
1320
  await cache.delete([cacheKey]);
472
- await setRecoveryEmail(contactMethod, email);
1321
+ await clearRateLimit(attemptKey);
1322
+ await setRecoveryEmailByAuthProvider(authProvider, email);
473
1323
 
474
1324
  return { success: true, maskedEmail: maskEmail(email) };
475
1325
  }),
@@ -486,9 +1336,8 @@ export const keysRouter = t.router({
486
1336
  .input(AuthInputValidator)
487
1337
  .output(z.object({ recoveryEmail: z.string().nullable() }))
488
1338
  .query(async ({ input }) => {
489
- const { contactMethod } = await verifyAndGetContactMethod(input);
490
-
491
- const userKey = await findUserKeyByContactMethod(contactMethod);
1339
+ const { authProvider } = await verifyAndGetContactMethod(input);
1340
+ const userKey = await findUserKeyByAuthProvider(authProvider.type, authProvider.id);
492
1341
  if (!userKey) {
493
1342
  return { recoveryEmail: null };
494
1343
  }
@@ -498,79 +1347,80 @@ export const keysRouter = t.router({
498
1347
  };
499
1348
  }),
500
1349
 
501
- sendEmailBackup: openRoute
1350
+ sendEmailBackup: didAndChallengeRoute
502
1351
  .meta({
503
1352
  openapi: {
504
1353
  method: 'POST',
505
1354
  path: '/keys/email-backup',
506
1355
  tags: ['Keys'],
507
- summary:
508
- 'Send email backup share to the authenticated user (fire-and-forget relay, share is NEVER persisted)',
1356
+ summary: 'Send a recovery key and confirmation code to a recovery email',
509
1357
  },
510
1358
  })
511
1359
  .input(
512
1360
  AuthInputValidator.extend({
513
- emailShare: z.string().min(1),
514
- // Provide either an explicit email OR set useRecoveryEmail to
515
- // use the verified recovery email stored on the UserKey.
516
- email: z.string().email().optional(),
517
- useRecoveryEmail: z.boolean().optional(),
518
- })
1361
+ relayPayload: EmailRelayEnvelopeValidator,
1362
+ confirmationCode: z.string().regex(/^\d{6}$/),
1363
+ email: z.string().email(),
1364
+ shareVersion: z.number().int().positive().optional(),
1365
+ }).strict()
519
1366
  )
520
1367
  .output(z.object({ success: z.boolean() }))
521
1368
  .mutation(async ({ input, ctx }) => {
522
- // Verify the caller is authenticated
523
- const { contactMethod } = await verifyAndGetContactMethod(input);
524
-
525
- let targetEmail = input.email;
526
-
527
- // When useRecoveryEmail is set, look up the stored recovery email
528
- if (input.useRecoveryEmail && !targetEmail) {
529
- const userKey = await findUserKeyByContactMethod(contactMethod);
1369
+ const { authProvider } = await verifyAndGetContactMethod(input);
1370
+ const userKey = await requireUserKey(authProvider);
530
1371
 
531
- targetEmail = userKey?.recoveryEmail;
1372
+ assertDidOwner(userKey, ctx.user.did);
532
1373
 
533
- if (!targetEmail) {
534
- throw new TRPCError({
535
- code: 'BAD_REQUEST',
536
- message: 'No verified recovery email on file.',
537
- });
538
- }
1374
+ if (!userKey.recoveryEmail || !userKey.recoveryEmailVerifiedAt) {
1375
+ throw new TRPCError({
1376
+ code: 'BAD_REQUEST',
1377
+ message: 'No verified recovery email on file.',
1378
+ });
539
1379
  }
540
1380
 
541
- if (!targetEmail) {
1381
+ const targetEmail = input.email.trim().toLowerCase();
1382
+
1383
+ if (
1384
+ userKey.recoveryEmail.toLowerCase() !== targetEmail ||
1385
+ (userKey.contactMethod.type === 'email' &&
1386
+ userKey.contactMethod.value.toLowerCase() === targetEmail)
1387
+ ) {
542
1388
  throw new TRPCError({
543
1389
  code: 'BAD_REQUEST',
544
- message: 'An email address or useRecoveryEmail flag is required.',
1390
+ message: 'Use the verified recovery email for this account.',
545
1391
  });
546
1392
  }
547
1393
 
548
- // IMPORTANT: The emailShare is NEVER written to the database.
549
- // It is only held in memory for the duration of this request.
550
- const brandName =
551
- ctx.tenant?.emailBranding?.brandName ||
552
- environment.POSTMARK_BRAND_NAME ||
553
- 'LearnCard';
1394
+ const now = new Date();
554
1395
 
555
- try {
556
- // Always render locally via @learncard/email-templates for
557
- // tenant-branded output. Falls back to the 'recovery-key'
558
- // sentinel when no Postmark alias is configured.
559
- await getDeliveryService().send({
560
- to: targetEmail,
561
- templateAlias: RECOVERY_KEY_TEMPLATE_ALIAS || 'recovery-key',
562
- templateModel: {
563
- brandName,
564
- recoveryKey: input.emailShare,
565
- },
566
- branding: ctx.tenant?.emailBranding,
567
- from: getFrom({ mailbox: 'recovery', branding: ctx.tenant?.emailBranding }),
1396
+ // Older clients omit the version; preserve their current-version fallback.
1397
+ // Even retained historical versions cannot be confirmed for new recovery setup.
1398
+ const shareVersion = userKey.shareVersion ?? 1;
1399
+ if (input.shareVersion !== undefined && input.shareVersion !== shareVersion) {
1400
+ throw new TRPCError({
1401
+ code: 'CONFLICT',
1402
+ message: 'Key material changed; please retry',
568
1403
  });
569
- } catch (emailError) {
570
- // Non-fatal: log but still return success so the client doesn't retry
571
- console.error('[email-backup] Failed to send backup share email:', emailError);
572
1404
  }
573
1405
 
1406
+ // lca-api deliberately cannot decrypt this payload. The isolated
1407
+ // relay verifies that its encrypted recipient matches this
1408
+ // server-verified address before sending.
1409
+ await sendRecoveryKeyToEscrowRelay(input.relayPayload, targetEmail);
1410
+
1411
+ await addRecoveryMethodToUserKeyByAuthProvider(authProvider, {
1412
+ type: 'email',
1413
+ createdAt: now,
1414
+ confirmationStatus: 'pending',
1415
+ shareVersion,
1416
+ confirmationCodeHash: hashRecoveryConfirmationCode(input.confirmationCode),
1417
+ confirmationCodeExpiresAt: new Date(
1418
+ now.getTime() + RECOVERY_METHOD_CONFIRMATION_TTL_MS
1419
+ ),
1420
+ confirmationAttempts: 0,
1421
+ confirmationEmail: targetEmail,
1422
+ });
1423
+
574
1424
  return { success: true };
575
1425
  }),
576
1426
 
@@ -618,27 +1468,23 @@ export const keysRouter = t.router({
618
1468
 
619
1469
  // 2. Verify the old UserKey exists
620
1470
  const oldContactMethod: ContactMethod = { type: 'phone', value: input.previousPhone };
621
- const existingKey = await findUserKeyByContactMethod(oldContactMethod);
1471
+ const authProvider: AuthProviderMapping = {
1472
+ type: input.providerType,
1473
+ id: user.id,
1474
+ };
1475
+ const existingKey = await findUserKeyByAuthProvider(authProvider.type, authProvider.id);
622
1476
 
623
- if (!existingKey) {
1477
+ if (
1478
+ !existingKey ||
1479
+ existingKey.contactMethod.type !== oldContactMethod.type ||
1480
+ existingKey.contactMethod.value !== oldContactMethod.value
1481
+ ) {
624
1482
  throw new TRPCError({
625
1483
  code: 'NOT_FOUND',
626
1484
  message: 'No account found for the provided phone number.',
627
1485
  });
628
1486
  }
629
1487
 
630
- // 3. Verify the authenticated user owns this UserKey
631
- const ownsKey = existingKey.authProviders.some(
632
- ap => ap.type === input.providerType && ap.id === user.id
633
- );
634
-
635
- if (!ownsKey) {
636
- throw new TRPCError({
637
- code: 'FORBIDDEN',
638
- message: 'You do not have permission to upgrade this account.',
639
- });
640
- }
641
-
642
1488
  // 4. Link the email to the auth account (passwordless, via Admin SDK)
643
1489
  // This invalidates the client's existing session, so we issue a
644
1490
  // fresh custom token for the client to re-authenticate with.
@@ -675,7 +1521,11 @@ export const keysRouter = t.router({
675
1521
 
676
1522
  // 5. Atomically upgrade the UserKey contact method
677
1523
  const newContactMethod: ContactMethod = { type: 'email', value: email };
678
- const upgraded = await upgradeContactMethod(oldContactMethod, newContactMethod);
1524
+ const upgraded = await upgradeContactMethodByAuthProvider(
1525
+ authProvider,
1526
+ oldContactMethod,
1527
+ newContactMethod
1528
+ );
679
1529
 
680
1530
  if (!upgraded) {
681
1531
  throw new TRPCError({
@@ -687,7 +1537,7 @@ export const keysRouter = t.router({
687
1537
  return { success: true, customToken };
688
1538
  }),
689
1539
 
690
- deleteUserKey: didRoute
1540
+ deleteUserKey: didAndChallengeRoute
691
1541
  .meta({
692
1542
  openapi: {
693
1543
  method: 'POST',
@@ -696,20 +1546,18 @@ export const keysRouter = t.router({
696
1546
  summary: 'Delete user key and all associated data',
697
1547
  },
698
1548
  })
699
- .input(AuthInputValidator)
1549
+ .input(
1550
+ AuthInputValidator.extend({
1551
+ challenge: z.string().min(1),
1552
+ })
1553
+ )
700
1554
  .output(z.object({ success: z.boolean() }))
701
1555
  .mutation(async ({ ctx, input }) => {
702
- const { contactMethod } = await verifyAndGetContactMethod(input);
703
-
704
- const userKey = await findUserKeyByContactMethod(contactMethod);
705
-
706
- if (userKey?.primaryDid && ctx.user.did !== userKey.primaryDid) {
707
- console.warn(
708
- `[deleteUserKey] DID format mismatch — VP DID: ${ctx.user.did}, stored primaryDid: ${userKey.primaryDid}. Proceeding (VP is verified).`
709
- );
710
- }
1556
+ const { authProvider } = await verifyAndGetContactMethod(input);
1557
+ const userKey = await requireUserKey(authProvider);
711
1558
 
712
- await deleteUserKey(contactMethod);
1559
+ assertDidOwner(userKey, ctx.user.did);
1560
+ await deleteUserKeyByAuthProvider(authProvider);
713
1561
 
714
1562
  return { success: true };
715
1563
  }),