@learncard/lca-api-service 1.3.1 → 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.
- package/.env.example +26 -2
- package/AGENTS.md +59 -44
- package/CHANGELOG.md +57 -0
- package/compose.env +5 -0
- package/dist/accesslayer/signing-authority/index.d.ts +8 -2
- package/dist/app.d.ts +297 -8
- package/dist/cache/challenges.d.ts +2 -0
- package/dist/cache/recoverySessions.d.ts +42 -0
- package/dist/config/environment.d.ts +152 -2
- package/dist/config/environment.test.d.ts +1 -0
- package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
- package/dist/helpers/rateLimit.helpers.d.ts +4 -0
- package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
- package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
- package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
- package/dist/models/EscrowHold.d.ts +75 -0
- package/dist/models/SigningAuthority.d.ts +7 -1
- package/dist/models/UserKey.d.ts +316 -3
- package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
- package/dist/models/index.d.ts +1 -0
- package/dist/routes/escrow.d.ts +150 -0
- package/dist/routes/firebase.test.d.ts +1 -0
- package/dist/routes/index.d.ts +6 -0
- package/dist/routes/keys.d.ts +181 -7
- package/dist/routes/qr-login.d.ts +1 -1
- package/dist/services/escrow-enclave/index.d.ts +9 -0
- package/dist/services/escrow-enclave/notifications.d.ts +9 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +17 -0
- package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +67 -0
- package/dist/types/seed-encryption.d.ts +31 -0
- package/dist/types/seed-migration.d.ts +49 -0
- package/lambda.ts +8 -3
- package/package.json +17 -11
- package/scripts/check-seed-encryption-infrastructure.mjs +123 -0
- package/scripts/check-signing-authority-redis.mjs +93 -0
- package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
- package/scripts/migrate-signing-authority-seeds.mjs +105 -0
- package/seedMigrationLambda.test.ts +145 -0
- package/seedMigrationLambda.ts +50 -0
- package/serverless-local.yml +3 -0
- package/serverless.escrow-env.cjs +20 -0
- package/serverless.yml +133 -0
- package/src/accesslayer/signing-authority/create.ts +11 -7
- package/src/app.ts +2 -0
- package/src/cache/challenges.ts +12 -2
- package/src/cache/in-memory-lru.ts +1 -0
- package/src/cache/recoverySessions.ts +252 -0
- package/src/config/environment.test.ts +116 -0
- package/src/config/environment.ts +97 -0
- package/src/docker-entry.ts +4 -0
- package/src/helpers/learnCard.helpers.test.ts +11 -1
- package/src/helpers/learnCard.helpers.ts +57 -45
- package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
- package/src/helpers/rateLimit.helpers.ts +112 -0
- package/src/helpers/seedEncryption.helpers.test.ts +427 -0
- package/src/helpers/seedEncryption.helpers.ts +372 -0
- package/src/helpers/signingAuthority.helpers.ts +4 -1
- package/src/migrations/signingAuthoritySeeds.ts +430 -0
- package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
- package/src/models/EscrowHold.ts +225 -0
- package/src/models/SigningAuthority.ts +14 -3
- package/src/models/UserKey.escrowPin.test.ts +49 -0
- package/src/models/UserKey.ts +1024 -40
- package/src/models/index.ts +1 -0
- package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
- package/src/routes/credentials.ts +8 -0
- package/src/routes/escrow.ts +826 -0
- package/src/routes/firebase.test.ts +94 -0
- package/src/routes/firebase.ts +99 -56
- package/src/routes/index.ts +98 -5
- package/src/routes/keys.ts +1017 -174
- package/src/routes/qr-login.ts +51 -53
- package/src/services/delivery/adapters/postmark.adapter.ts +5 -0
- package/src/services/escrow-enclave/README.md +51 -0
- package/src/services/escrow-enclave/index.ts +36 -0
- package/src/services/escrow-enclave/notifications.ts +14 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +249 -0
- package/src/services/escrow-enclave/softwareEnclave.ts +168 -0
- package/src/services/escrow-enclave/types.ts +76 -0
- package/src/types/seed-encryption.ts +51 -0
- package/src/types/seed-migration.ts +61 -0
- package/test/credentials.spec.ts +2 -0
- package/test/keys-escrow.spec.ts +1389 -0
- package/test/keys-identity-rebind.spec.ts +456 -0
- package/test/keys-lifecycle-invariants.spec.ts +785 -0
- package/test/keys-recovery-confirmation.spec.ts +586 -0
- package/test/keys-recovery-rate-limit.spec.ts +123 -0
- package/test/keys-secret-leakage.spec.ts +265 -0
- package/test/models/escrow.spec.ts +210 -0
- package/test/signing-authority-http.spec.ts +161 -0
- package/test/signing-authority-seed-migration.spec.ts +414 -0
- package/test/signing-authority.spec.ts +40 -0
- package/vitest.config.ts +1 -1
- package/vitest.integration.config.ts +5 -0
- package/dist/cache/signing-authority.d.ts +0 -6
- package/src/cache/signing-authority.ts +0 -37
package/src/routes/keys.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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 {
|
|
644
|
+
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
645
|
+
await purgeExpiredProvisionalMigrationByAuthProvider(authProvider);
|
|
110
646
|
|
|
111
|
-
const userKey = await
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 {
|
|
842
|
+
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
843
|
+
const userKey = await requireUserKey(authProvider);
|
|
247
844
|
|
|
248
|
-
|
|
249
|
-
|
|
845
|
+
assertDidOwner(userKey, ctx.user.did);
|
|
846
|
+
|
|
847
|
+
if (input.type === 'escrow') {
|
|
250
848
|
throw new TRPCError({
|
|
251
|
-
code: '
|
|
252
|
-
message: '
|
|
849
|
+
code: 'BAD_REQUEST',
|
|
850
|
+
message: 'Use the automatic recovery enrollment endpoint.',
|
|
253
851
|
});
|
|
254
852
|
}
|
|
255
853
|
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
287
|
-
|
|
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 {
|
|
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
|
|
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:
|
|
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 {
|
|
1106
|
+
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
1107
|
+
const userKey = await requireUserKey(authProvider);
|
|
336
1108
|
|
|
337
|
-
|
|
1109
|
+
assertDidOwner(userKey, ctx.user.did);
|
|
338
1110
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
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:
|
|
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 {
|
|
1198
|
+
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
1199
|
+
const userKey = await requireUserKey(authProvider);
|
|
369
1200
|
|
|
370
|
-
|
|
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}${
|
|
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:
|
|
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 {
|
|
1270
|
+
.mutation(async ({ input, ctx }) => {
|
|
1271
|
+
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
1272
|
+
const userKey = await requireUserKey(authProvider, 'User key not found.');
|
|
442
1273
|
|
|
443
|
-
|
|
444
|
-
|
|
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: '
|
|
447
|
-
message: '
|
|
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
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
523
|
-
const
|
|
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
|
-
|
|
1367
|
+
assertDidOwner(userKey, ctx.user.did);
|
|
532
1368
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
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: '
|
|
1385
|
+
message: 'Use the verified recovery email for this account.',
|
|
545
1386
|
});
|
|
546
1387
|
}
|
|
547
1388
|
|
|
548
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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:
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
1554
|
+
assertDidOwner(userKey, ctx.user.did);
|
|
1555
|
+
await deleteUserKeyByAuthProvider(authProvider);
|
|
713
1556
|
|
|
714
1557
|
return { success: true };
|
|
715
1558
|
}),
|