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