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