@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/models/UserKey.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { escrowBlobIdentity } from '../services/escrow-enclave/blobIdentity';
|
|
3
|
+
import type { Filter } from 'mongodb';
|
|
4
|
+
import { ESCROW_PIN_MAX_ATTEMPTS } from '@learncard/sss-key-manager';
|
|
5
|
+
import { EscrowPinStatusValidator as SharedEscrowPinStatusValidator } from '@learncard/types';
|
|
2
6
|
|
|
3
7
|
import mongodb from '@mongo';
|
|
4
8
|
import { pruneOrphanedRecoveryMethods } from './pruneOrphanedRecoveryMethods';
|
|
@@ -28,11 +32,17 @@ export const EncryptedShareValidator = z.object({
|
|
|
28
32
|
});
|
|
29
33
|
|
|
30
34
|
export const RecoveryMethodValidator = z.object({
|
|
31
|
-
type: z.enum(['passkey', 'backup', 'phrase', 'email']),
|
|
35
|
+
type: z.enum(['passkey', 'backup', 'phrase', 'email', 'escrow']),
|
|
32
36
|
createdAt: z.date(),
|
|
37
|
+
confirmationStatus: z.enum(['pending', 'confirmed']).optional(),
|
|
38
|
+
confirmedAt: z.date().optional(),
|
|
33
39
|
credentialId: z.string().optional(),
|
|
34
40
|
encryptedShare: EncryptedShareValidator.optional(),
|
|
35
41
|
shareVersion: z.number().optional(),
|
|
42
|
+
confirmationCodeHash: z.string().optional(),
|
|
43
|
+
confirmationCodeExpiresAt: z.date().optional(),
|
|
44
|
+
confirmationAttempts: z.number().int().nonnegative().optional(),
|
|
45
|
+
confirmationEmail: z.string().email().optional(),
|
|
36
46
|
});
|
|
37
47
|
|
|
38
48
|
export const PreviousAuthShareValidator = z.object({
|
|
@@ -41,7 +51,80 @@ export const PreviousAuthShareValidator = z.object({
|
|
|
41
51
|
createdAt: z.date(),
|
|
42
52
|
});
|
|
43
53
|
|
|
44
|
-
export const
|
|
54
|
+
export const EscrowEnvelopeValidator = z
|
|
55
|
+
.object({
|
|
56
|
+
version: z.literal(1),
|
|
57
|
+
algorithm: z.literal('P-256-HKDF-SHA256-AES-256-GCM'),
|
|
58
|
+
keyId: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/),
|
|
59
|
+
ephemeralPublicKey: z.string().min(1).max(256),
|
|
60
|
+
salt: z.string().min(1).max(128),
|
|
61
|
+
iv: z.string().min(1).max(64),
|
|
62
|
+
ciphertext: z.string().min(1).max(16_384),
|
|
63
|
+
})
|
|
64
|
+
.strict();
|
|
65
|
+
|
|
66
|
+
export const EscrowBlobValidator = z.object({
|
|
67
|
+
enrollmentEpoch: z.number().int().positive(),
|
|
68
|
+
blobHash: z.string().regex(/^[0-9a-f]{64}$/),
|
|
69
|
+
envelope: EscrowEnvelopeValidator,
|
|
70
|
+
enclaveKeyId: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/),
|
|
71
|
+
enclaveMode: z.enum(['software', 'nitro']),
|
|
72
|
+
measurements: z.object({
|
|
73
|
+
imageSha384: z.string().optional(),
|
|
74
|
+
pcr0: z.string().optional(),
|
|
75
|
+
pcr1: z.string().optional(),
|
|
76
|
+
pcr2: z.string().optional(),
|
|
77
|
+
}),
|
|
78
|
+
shareVersion: z.number().int().positive(),
|
|
79
|
+
createdAt: z.date(),
|
|
80
|
+
});
|
|
81
|
+
export type EscrowBlob = z.infer<typeof EscrowBlobValidator>;
|
|
82
|
+
|
|
83
|
+
export const EscrowPinSaltValidator = z
|
|
84
|
+
.string()
|
|
85
|
+
.length(24)
|
|
86
|
+
.refine(
|
|
87
|
+
salt =>
|
|
88
|
+
Buffer.from(salt, 'base64').length === 16 &&
|
|
89
|
+
Buffer.from(salt, 'base64').toString('base64') === salt,
|
|
90
|
+
'Expected a base64-encoded 16-byte salt.'
|
|
91
|
+
);
|
|
92
|
+
export const EscrowPinValidator = z.object({
|
|
93
|
+
salt: EscrowPinSaltValidator,
|
|
94
|
+
// Legacy name: charged lifetime budget, including unresolved reservations.
|
|
95
|
+
failedAttempts: z.number().int().nonnegative(),
|
|
96
|
+
verifiedFailedAttempts: z.number().int().nonnegative().optional(),
|
|
97
|
+
enabledAt: z.date(),
|
|
98
|
+
disabledAt: z.date().optional(),
|
|
99
|
+
shareVersion: z.number().int().positive(),
|
|
100
|
+
});
|
|
101
|
+
export const EscrowPinStatusValidator = SharedEscrowPinStatusValidator.extend({
|
|
102
|
+
salt: EscrowPinSaltValidator.optional(),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export const getEscrowPinStatus = (
|
|
106
|
+
userKey: MongoUserKeyType
|
|
107
|
+
): z.infer<typeof EscrowPinStatusValidator> => {
|
|
108
|
+
const pin = userKey.escrowPin;
|
|
109
|
+
const enabled = !!pin && !pin.disabledAt && pin.shareVersion === userKey.shareVersion;
|
|
110
|
+
return {
|
|
111
|
+
state: !pin
|
|
112
|
+
? 'none'
|
|
113
|
+
: pin.disabledAt
|
|
114
|
+
? 'locked'
|
|
115
|
+
: pin.shareVersion !== userKey.shareVersion
|
|
116
|
+
? 'stale'
|
|
117
|
+
: 'enabled',
|
|
118
|
+
enabled,
|
|
119
|
+
attemptsRemaining: pin ? Math.max(0, ESCROW_PIN_MAX_ATTEMPTS - pin.failedAttempts) : 0,
|
|
120
|
+
...(enabled ? { salt: pin.salt } : {}),
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const MongoUserKeyBaseValidator = z.object({
|
|
125
|
+
escrowBlob: EscrowBlobValidator.optional(),
|
|
126
|
+
escrowPin: EscrowPinValidator.optional(),
|
|
127
|
+
escrowOptedOutAt: z.date().optional(),
|
|
45
128
|
_id: z.string().optional(),
|
|
46
129
|
|
|
47
130
|
contactMethod: ContactMethodValidator,
|
|
@@ -78,11 +161,16 @@ export const MongoUserKeyValidator = z.object({
|
|
|
78
161
|
migratedFromWeb3Auth: z.boolean().default(false),
|
|
79
162
|
migratedAt: z.date().optional(),
|
|
80
163
|
|
|
164
|
+
sssActivationState: z.enum(['provisional', 'active']).optional(),
|
|
165
|
+
provisionalCreatedAt: z.date().optional(),
|
|
166
|
+
|
|
81
167
|
createdAt: z.date(),
|
|
82
168
|
updatedAt: z.date(),
|
|
83
169
|
});
|
|
84
170
|
|
|
85
|
-
export
|
|
171
|
+
export const MongoUserKeyValidator = MongoUserKeyBaseValidator.passthrough();
|
|
172
|
+
|
|
173
|
+
export type MongoUserKeyType = z.infer<typeof MongoUserKeyBaseValidator>;
|
|
86
174
|
export type ContactMethod = z.infer<typeof ContactMethodValidator>;
|
|
87
175
|
export type AuthProviderMapping = z.infer<typeof AuthProviderMappingValidator>;
|
|
88
176
|
export type ServerEncryptedShare = z.infer<typeof ServerEncryptedShareValidator>;
|
|
@@ -91,6 +179,40 @@ export type RecoveryMethod = z.infer<typeof RecoveryMethodValidator>;
|
|
|
91
179
|
export type PreviousAuthShare = z.infer<typeof PreviousAuthShareValidator>;
|
|
92
180
|
|
|
93
181
|
export const MAX_PREVIOUS_AUTH_SHARES = 5;
|
|
182
|
+
export const PROVISIONAL_MIGRATION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
183
|
+
|
|
184
|
+
export class UserKeyVersionConflictError extends Error {
|
|
185
|
+
constructor() {
|
|
186
|
+
super('User key share version changed during update');
|
|
187
|
+
this.name = 'UserKeyVersionConflictError';
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Records created before activation state tracking are grandfathered as active.
|
|
193
|
+
* This preserves existing SSS users while new writes use the explicit state machine.
|
|
194
|
+
*/
|
|
195
|
+
export const getSssActivationState = (userKey: MongoUserKeyType): 'provisional' | 'active' =>
|
|
196
|
+
userKey.sssActivationState ?? 'active';
|
|
197
|
+
|
|
198
|
+
/** New methods are pending until receipt is proved. Methods without confirmation
|
|
199
|
+
* metadata on already-active records predate this protocol and are grandfathered. */
|
|
200
|
+
export const isRecoveryMethodConfirmed = (
|
|
201
|
+
userKey: MongoUserKeyType,
|
|
202
|
+
method: RecoveryMethod
|
|
203
|
+
): boolean => {
|
|
204
|
+
if (method.confirmedAt) return true;
|
|
205
|
+
if (method.confirmationStatus) return false;
|
|
206
|
+
|
|
207
|
+
return getSssActivationState(userKey) === 'active';
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export const hasActivatableRecoveryMethod = (userKey: MongoUserKeyType): boolean =>
|
|
211
|
+
(userKey.recoveryMethods ?? []).some(
|
|
212
|
+
method =>
|
|
213
|
+
method.shareVersion === (userKey.shareVersion ?? 1) &&
|
|
214
|
+
isRecoveryMethodConfirmed(userKey, method)
|
|
215
|
+
);
|
|
94
216
|
|
|
95
217
|
export { pruneOrphanedRecoveryMethods };
|
|
96
218
|
|
|
@@ -98,19 +220,172 @@ export const getUserKeysCollection = () => {
|
|
|
98
220
|
return mongodb.collection<MongoUserKeyType>(USER_KEYS_COLLECTION);
|
|
99
221
|
};
|
|
100
222
|
|
|
101
|
-
|
|
223
|
+
const getMongoErrorCode = (error: unknown): number | undefined => {
|
|
224
|
+
if (typeof error !== 'object' || error === null || !('code' in error)) return undefined;
|
|
225
|
+
|
|
226
|
+
return typeof error.code === 'number' ? error.code : undefined;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const runIndexMigrationOperation = async (
|
|
230
|
+
operationName: string,
|
|
231
|
+
operation: () => Promise<unknown>,
|
|
232
|
+
options: { duplicateRequiresManualDedupe?: boolean } = {}
|
|
233
|
+
): Promise<void> => {
|
|
234
|
+
try {
|
|
235
|
+
await operation();
|
|
236
|
+
} catch (error) {
|
|
237
|
+
const code = getMongoErrorCode(error);
|
|
238
|
+
|
|
239
|
+
if (code === 11000) {
|
|
240
|
+
if (options.duplicateRequiresManualDedupe) {
|
|
241
|
+
console.error(
|
|
242
|
+
`[UserKey indexes] ${operationName} found duplicate provider identities; ` +
|
|
243
|
+
'manual dedupe is required before the unique index can be created.',
|
|
244
|
+
{ code }
|
|
245
|
+
);
|
|
246
|
+
} else {
|
|
247
|
+
console.error(
|
|
248
|
+
`[UserKey indexes] ${operationName} encountered duplicate data; continuing startup.`,
|
|
249
|
+
{ code }
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// IndexNotFound and index option/spec conflicts are expected when cold starts race.
|
|
257
|
+
if (code === 27 || code === 85 || code === 86) {
|
|
258
|
+
console.warn(
|
|
259
|
+
`[UserKey indexes] ${operationName} raced another index migration; continuing startup.`,
|
|
260
|
+
{ code }
|
|
261
|
+
);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export const createUserKeysIndexes = async (): Promise<void> => {
|
|
102
270
|
const collection = getUserKeysCollection();
|
|
103
271
|
|
|
104
|
-
await collection
|
|
105
|
-
|
|
106
|
-
|
|
272
|
+
const indexes = await collection
|
|
273
|
+
.listIndexes()
|
|
274
|
+
.toArray()
|
|
275
|
+
.catch((error: unknown) => {
|
|
276
|
+
const code = getMongoErrorCode(error);
|
|
277
|
+
|
|
278
|
+
if (code === 26) return [];
|
|
279
|
+
throw error;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const contactIndex = indexes.find(
|
|
283
|
+
index => index.key?.['contactMethod.type'] === 1 && index.key?.['contactMethod.value'] === 1
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// Contact methods are mutable and can be recycled. They remain searchable,
|
|
287
|
+
// but are deliberately non-unique and never serve as account identity.
|
|
288
|
+
if (contactIndex?.unique && contactIndex.name) {
|
|
289
|
+
await runIndexMigrationOperation('drop unique contact-method index', () =>
|
|
290
|
+
collection.dropIndex(contactIndex.name!)
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (!contactIndex || contactIndex.unique) {
|
|
295
|
+
await runIndexMigrationOperation('create contact-method lookup index', () =>
|
|
296
|
+
collection.createIndex(
|
|
297
|
+
{ 'contactMethod.type': 1, 'contactMethod.value': 1 },
|
|
298
|
+
{ name: 'contact_method_lookup' }
|
|
299
|
+
)
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const providerIndex = indexes.find(
|
|
304
|
+
index => index.key?.['authProviders.type'] === 1 && index.key?.['authProviders.id'] === 1
|
|
107
305
|
);
|
|
108
306
|
|
|
109
|
-
|
|
110
|
-
|
|
307
|
+
if (providerIndex && !providerIndex.unique && providerIndex.name) {
|
|
308
|
+
await runIndexMigrationOperation('drop non-unique provider identity index', () =>
|
|
309
|
+
collection.dropIndex(providerIndex.name!)
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!providerIndex || !providerIndex.unique) {
|
|
314
|
+
await runIndexMigrationOperation(
|
|
315
|
+
'create unique provider identity index',
|
|
316
|
+
() =>
|
|
317
|
+
collection.createIndex(
|
|
318
|
+
{ 'authProviders.type': 1, 'authProviders.id': 1 },
|
|
319
|
+
{ name: 'auth_provider_identity_unique', unique: true }
|
|
320
|
+
),
|
|
321
|
+
{ duplicateRequiresManualDedupe: true }
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
await runIndexMigrationOperation('create primary DID index', () =>
|
|
326
|
+
collection.createIndex({ primaryDid: 1 })
|
|
327
|
+
);
|
|
328
|
+
await runIndexMigrationOperation('create verified recovery email index', () =>
|
|
329
|
+
collection.createIndex(
|
|
330
|
+
{ recoveryEmail: 1, recoveryEmailVerifiedAt: 1 },
|
|
331
|
+
{ name: 'verified_recovery_email_lookup' }
|
|
332
|
+
)
|
|
111
333
|
);
|
|
334
|
+
};
|
|
112
335
|
|
|
113
|
-
|
|
336
|
+
/** First writes require database-enforced provider uniqueness, not a process-local check. */
|
|
337
|
+
const ensureProviderIdentityUniqueForInsert = async (): Promise<void> => {
|
|
338
|
+
const hasUniqueIndex = async (): Promise<boolean> => {
|
|
339
|
+
const indexes = await getUserKeysCollection()
|
|
340
|
+
.listIndexes()
|
|
341
|
+
.toArray()
|
|
342
|
+
.catch((error: unknown) => {
|
|
343
|
+
if (getMongoErrorCode(error) === 26) return [];
|
|
344
|
+
throw error;
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
return indexes.some(index => {
|
|
348
|
+
const keys = Object.keys(index.key ?? {});
|
|
349
|
+
return (
|
|
350
|
+
index.unique === true &&
|
|
351
|
+
!index.sparse &&
|
|
352
|
+
!index.partialFilterExpression &&
|
|
353
|
+
keys.length === 2 &&
|
|
354
|
+
index.key?.['authProviders.type'] === 1 &&
|
|
355
|
+
index.key?.['authProviders.id'] === 1
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
if (await hasUniqueIndex()) return;
|
|
362
|
+
|
|
363
|
+
// The migration drops a legacy non-unique index before creating the unique one.
|
|
364
|
+
// It logs and tolerates legacy duplicates, so verify the index actually exists.
|
|
365
|
+
await createUserKeysIndexes();
|
|
366
|
+
if (await hasUniqueIndex()) return;
|
|
367
|
+
} catch (error) {
|
|
368
|
+
console.error('[UserKey indexes] provider uniqueness unavailable for first write.', error);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
throw new UserKeyVersionConflictError();
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
let userKeysIndexesPromise: Promise<void> | undefined;
|
|
375
|
+
|
|
376
|
+
/** Runs the production index migration at most once per warm process. */
|
|
377
|
+
export const ensureUserKeysIndexes = (): Promise<void> => {
|
|
378
|
+
// Never let an index-migration failure take the API down: log it, and clear the
|
|
379
|
+
// memo so the next request/boot retries instead of failing on a poisoned promise.
|
|
380
|
+
userKeysIndexesPromise ??= createUserKeysIndexes().catch((error: unknown) => {
|
|
381
|
+
console.error(
|
|
382
|
+
'[UserKey indexes] migration failed; requests will proceed and the migration will retry.',
|
|
383
|
+
error
|
|
384
|
+
);
|
|
385
|
+
userKeysIndexesPromise = undefined;
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
return userKeysIndexesPromise;
|
|
114
389
|
};
|
|
115
390
|
|
|
116
391
|
export const findUserKeyByContactMethod = async (
|
|
@@ -145,6 +420,22 @@ export const findUserKeyByDid = async (did: string): Promise<MongoUserKeyType |
|
|
|
145
420
|
});
|
|
146
421
|
};
|
|
147
422
|
|
|
423
|
+
/** Recovery email is a proof channel, never account identity. Ambiguous matches are rejected. */
|
|
424
|
+
export const findUniqueUserKeyByVerifiedRecoveryEmail = async (
|
|
425
|
+
email: string
|
|
426
|
+
): Promise<MongoUserKeyType | null> => {
|
|
427
|
+
const escapedEmail = email.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
428
|
+
const matches = await getUserKeysCollection()
|
|
429
|
+
.find({
|
|
430
|
+
recoveryEmail: { $regex: `^${escapedEmail}$`, $options: 'i' },
|
|
431
|
+
recoveryEmailVerifiedAt: { $exists: true },
|
|
432
|
+
})
|
|
433
|
+
.limit(2)
|
|
434
|
+
.toArray();
|
|
435
|
+
|
|
436
|
+
return matches.length === 1 ? matches[0]! : null;
|
|
437
|
+
};
|
|
438
|
+
|
|
148
439
|
/**
|
|
149
440
|
* Look up a specific auth share version from the current or previous shares.
|
|
150
441
|
* Returns the matching auth share, or null if the version is not found.
|
|
@@ -212,10 +503,19 @@ export const upsertUserKey = async (
|
|
|
212
503
|
const prunedMethods = pruneOrphanedRecoveryMethods(
|
|
213
504
|
existing.recoveryMethods ?? [],
|
|
214
505
|
newCurrentVersion,
|
|
215
|
-
survivingVersions
|
|
506
|
+
survivingVersions
|
|
216
507
|
);
|
|
217
508
|
|
|
218
509
|
(updateOps.$set as Record<string, unknown>).recoveryMethods = prunedMethods;
|
|
510
|
+
if (!prunedMethods.some(method => method.type === 'escrow')) {
|
|
511
|
+
delete (updateOps.$set as Record<string, unknown>).escrowBlob;
|
|
512
|
+
delete (updateOps.$set as Record<string, unknown>).escrowPin;
|
|
513
|
+
updateOps.$unset = {
|
|
514
|
+
...((updateOps.$unset as Record<string, unknown> | undefined) ?? {}),
|
|
515
|
+
escrowBlob: '',
|
|
516
|
+
escrowPin: '',
|
|
517
|
+
};
|
|
518
|
+
}
|
|
219
519
|
} else if (data.authShare) {
|
|
220
520
|
// First auth share — no history to push
|
|
221
521
|
updateOps.$inc = { shareVersion: 1 };
|
|
@@ -248,6 +548,8 @@ export const upsertUserKey = async (
|
|
|
248
548
|
previousAuthShares: [],
|
|
249
549
|
migratedFromWeb3Auth: data.migratedFromWeb3Auth ?? false,
|
|
250
550
|
migratedAt: data.migratedAt,
|
|
551
|
+
sssActivationState: data.sssActivationState,
|
|
552
|
+
provisionalCreatedAt: data.provisionalCreatedAt,
|
|
251
553
|
createdAt: now,
|
|
252
554
|
updatedAt: now,
|
|
253
555
|
};
|
|
@@ -258,6 +560,143 @@ export const upsertUserKey = async (
|
|
|
258
560
|
}
|
|
259
561
|
};
|
|
260
562
|
|
|
563
|
+
/**
|
|
564
|
+
* Create or update a UserKey by immutable provider identity. A contact-method
|
|
565
|
+
* collision intentionally creates a separate record; linking identities is an
|
|
566
|
+
* explicit authenticated flow and never a side effect of lookup.
|
|
567
|
+
*/
|
|
568
|
+
export const upsertUserKeyByAuthProvider = async (
|
|
569
|
+
contactMethod: ContactMethod,
|
|
570
|
+
authProvider: AuthProviderMapping,
|
|
571
|
+
data: Partial<Omit<MongoUserKeyType, '_id' | 'contactMethod' | 'createdAt' | 'authProviders'>>,
|
|
572
|
+
expectedVersion?: number
|
|
573
|
+
): Promise<MongoUserKeyType> => {
|
|
574
|
+
const collection = getUserKeysCollection();
|
|
575
|
+
const now = new Date();
|
|
576
|
+
const providerFilter: Filter<MongoUserKeyType> = {
|
|
577
|
+
authProviders: { $elemMatch: authProvider },
|
|
578
|
+
};
|
|
579
|
+
const existing = await collection.findOne(providerFilter);
|
|
580
|
+
|
|
581
|
+
if (existing) {
|
|
582
|
+
const currentVersion = existing.authShare ? (existing.shareVersion ?? 1) : 0;
|
|
583
|
+
|
|
584
|
+
if (expectedVersion != null && expectedVersion !== currentVersion) {
|
|
585
|
+
throw new UserKeyVersionConflictError();
|
|
586
|
+
}
|
|
587
|
+
if (data.primaryDid !== undefined && data.primaryDid !== existing.primaryDid) {
|
|
588
|
+
throw new UserKeyVersionConflictError();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const updateOps: Record<string, unknown> = {
|
|
592
|
+
$set: {
|
|
593
|
+
...data,
|
|
594
|
+
updatedAt: now,
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
if (data.authShare && existing.authShare) {
|
|
599
|
+
(updateOps.$set as Record<string, unknown>).shareVersion =
|
|
600
|
+
(existing.shareVersion ?? 1) + 1;
|
|
601
|
+
(updateOps.$set as Record<string, unknown>).shareUpdatedAt = now;
|
|
602
|
+
|
|
603
|
+
const oldEntry: PreviousAuthShare = {
|
|
604
|
+
authShare: existing.authShare,
|
|
605
|
+
shareVersion: existing.shareVersion ?? 1,
|
|
606
|
+
createdAt: existing.shareUpdatedAt ?? existing.updatedAt ?? now,
|
|
607
|
+
};
|
|
608
|
+
const history = [...(existing.previousAuthShares ?? []), oldEntry];
|
|
609
|
+
const trimmedHistory = history.slice(-MAX_PREVIOUS_AUTH_SHARES);
|
|
610
|
+
(updateOps.$set as Record<string, unknown>).previousAuthShares = trimmedHistory;
|
|
611
|
+
|
|
612
|
+
const newCurrentVersion = (existing.shareVersion ?? 1) + 1;
|
|
613
|
+
const survivingVersions = trimmedHistory.map(previous => previous.shareVersion);
|
|
614
|
+
const prunedMethods = pruneOrphanedRecoveryMethods(
|
|
615
|
+
existing.recoveryMethods ?? [],
|
|
616
|
+
newCurrentVersion,
|
|
617
|
+
survivingVersions
|
|
618
|
+
);
|
|
619
|
+
(updateOps.$set as Record<string, unknown>).recoveryMethods = prunedMethods;
|
|
620
|
+
if (!prunedMethods.some(method => method.type === 'escrow')) {
|
|
621
|
+
delete (updateOps.$set as Record<string, unknown>).escrowBlob;
|
|
622
|
+
delete (updateOps.$set as Record<string, unknown>).escrowPin;
|
|
623
|
+
updateOps.$unset = {
|
|
624
|
+
...((updateOps.$unset as Record<string, unknown> | undefined) ?? {}),
|
|
625
|
+
escrowBlob: '',
|
|
626
|
+
escrowPin: '',
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
} else if (data.authShare) {
|
|
630
|
+
(updateOps.$set as Record<string, unknown>).shareVersion =
|
|
631
|
+
(existing.shareVersion ?? 1) + 1;
|
|
632
|
+
(updateOps.$set as Record<string, unknown>).shareUpdatedAt = now;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const result = await collection.findOneAndUpdate(
|
|
636
|
+
{
|
|
637
|
+
...providerFilter,
|
|
638
|
+
primaryDid: existing.primaryDid,
|
|
639
|
+
shareVersion: existing.shareVersion ?? null,
|
|
640
|
+
// Version 0 is a public sentinel, not necessarily the stored counter.
|
|
641
|
+
...(existing.authShare
|
|
642
|
+
? { authShare: existing.authShare }
|
|
643
|
+
: {
|
|
644
|
+
$or: [
|
|
645
|
+
{ authShare: { $exists: false } },
|
|
646
|
+
{ authShare: { $type: 'null' } },
|
|
647
|
+
],
|
|
648
|
+
}),
|
|
649
|
+
},
|
|
650
|
+
updateOps,
|
|
651
|
+
{
|
|
652
|
+
returnDocument: 'after',
|
|
653
|
+
}
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
if (!result) throw new UserKeyVersionConflictError();
|
|
657
|
+
|
|
658
|
+
return result;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (expectedVersion !== undefined && expectedVersion !== 0) {
|
|
662
|
+
throw new UserKeyVersionConflictError();
|
|
663
|
+
}
|
|
664
|
+
await ensureProviderIdentityUniqueForInsert();
|
|
665
|
+
const newDoc: MongoUserKeyType = {
|
|
666
|
+
contactMethod,
|
|
667
|
+
authProviders: [authProvider],
|
|
668
|
+
primaryDid: data.primaryDid ?? '',
|
|
669
|
+
linkedDids: data.linkedDids ?? [],
|
|
670
|
+
keyProvider: data.keyProvider ?? 'sss',
|
|
671
|
+
authShare: data.authShare,
|
|
672
|
+
shareVersion: 1,
|
|
673
|
+
shareUpdatedAt: data.authShare ? now : undefined,
|
|
674
|
+
securityLevel: data.securityLevel ?? 'basic',
|
|
675
|
+
recoveryMethods: data.recoveryMethods ?? [],
|
|
676
|
+
previousAuthShares: [],
|
|
677
|
+
recoveryEmail: data.recoveryEmail,
|
|
678
|
+
recoveryEmailVerifiedAt: data.recoveryEmailVerifiedAt,
|
|
679
|
+
migratedFromWeb3Auth: data.migratedFromWeb3Auth ?? false,
|
|
680
|
+
migratedAt: data.migratedAt,
|
|
681
|
+
sssActivationState: data.sssActivationState,
|
|
682
|
+
provisionalCreatedAt: data.provisionalCreatedAt,
|
|
683
|
+
createdAt: now,
|
|
684
|
+
updatedAt: now,
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
try {
|
|
688
|
+
// auth_provider_identity_unique arbitrates concurrent first writes.
|
|
689
|
+
await collection.insertOne(newDoc);
|
|
690
|
+
} catch (error) {
|
|
691
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 11000) {
|
|
692
|
+
throw new UserKeyVersionConflictError();
|
|
693
|
+
}
|
|
694
|
+
throw error;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return newDoc;
|
|
698
|
+
};
|
|
699
|
+
|
|
261
700
|
export const addAuthProviderToUserKey = async (
|
|
262
701
|
contactMethod: ContactMethod,
|
|
263
702
|
authProvider: AuthProviderMapping
|
|
@@ -280,47 +719,441 @@ export const addRecoveryMethodToUserKey = async (
|
|
|
280
719
|
contactMethod: ContactMethod,
|
|
281
720
|
recoveryMethod: RecoveryMethod
|
|
282
721
|
): Promise<void> => {
|
|
283
|
-
|
|
722
|
+
await addRecoveryMethodWithFilter(
|
|
723
|
+
{
|
|
724
|
+
'contactMethod.type': contactMethod.type,
|
|
725
|
+
'contactMethod.value': contactMethod.value,
|
|
726
|
+
},
|
|
727
|
+
recoveryMethod
|
|
728
|
+
);
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
const getAuthProviderFilter = (authProvider: AuthProviderMapping): Filter<MongoUserKeyType> => ({
|
|
732
|
+
authProviders: { $elemMatch: authProvider },
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
/** Persist verified escrow and its confirmed descriptor atomically. */
|
|
736
|
+
export const setEscrowBlobByAuthProvider = async (
|
|
737
|
+
authProvider: AuthProviderMapping,
|
|
738
|
+
blob: Omit<EscrowBlob, 'enrollmentEpoch' | 'blobHash'>,
|
|
739
|
+
expectedShareVersion: number,
|
|
740
|
+
pin?: { salt: string },
|
|
741
|
+
carryFrom?: { blob: EscrowBlob; pin: NonNullable<MongoUserKeyType['escrowPin']> }
|
|
742
|
+
): Promise<MongoUserKeyType | null> => {
|
|
284
743
|
const now = new Date();
|
|
744
|
+
const parsed = EscrowBlobValidator.omit({ enrollmentEpoch: true, blobHash: true }).safeParse(
|
|
745
|
+
blob
|
|
746
|
+
);
|
|
747
|
+
if (!parsed.success) throw new Error('Invalid escrow payload');
|
|
748
|
+
const blobHash = escrowBlobIdentity(parsed.data.envelope);
|
|
749
|
+
const assembled = EscrowBlobValidator.safeParse({
|
|
750
|
+
...parsed.data,
|
|
751
|
+
blobHash,
|
|
752
|
+
enrollmentEpoch: 1,
|
|
753
|
+
});
|
|
754
|
+
if (!assembled.success) throw new Error('Invalid escrow payload');
|
|
755
|
+
const { enrollmentEpoch: _initialEpoch, ...validated } = assembled.data;
|
|
756
|
+
if (
|
|
757
|
+
validated.shareVersion !== expectedShareVersion ||
|
|
758
|
+
validated.enclaveKeyId !== validated.envelope.keyId
|
|
759
|
+
)
|
|
760
|
+
return null;
|
|
761
|
+
return getUserKeysCollection().findOneAndUpdate(
|
|
762
|
+
{
|
|
763
|
+
...getAuthProviderFilter(authProvider),
|
|
764
|
+
shareVersion: expectedShareVersion,
|
|
765
|
+
escrowOptedOutAt: { $exists: false },
|
|
766
|
+
...(carryFrom ? { escrowBlob: carryFrom.blob, escrowPin: carryFrom.pin } : {}),
|
|
767
|
+
},
|
|
768
|
+
[
|
|
769
|
+
{
|
|
770
|
+
$set: {
|
|
771
|
+
escrowBlob: {
|
|
772
|
+
$mergeObjects: [
|
|
773
|
+
{ $literal: validated },
|
|
774
|
+
{
|
|
775
|
+
enrollmentEpoch: {
|
|
776
|
+
$add: [{ $ifNull: ['$escrowBlob.enrollmentEpoch', 0] }, 1],
|
|
777
|
+
},
|
|
778
|
+
},
|
|
779
|
+
],
|
|
780
|
+
},
|
|
781
|
+
escrowPin: carryFrom
|
|
782
|
+
? { $literal: { ...carryFrom.pin, shareVersion: expectedShareVersion } }
|
|
783
|
+
: pin
|
|
784
|
+
? {
|
|
785
|
+
$literal: EscrowPinValidator.parse({
|
|
786
|
+
salt: pin.salt,
|
|
787
|
+
failedAttempts: 0,
|
|
788
|
+
enabledAt: now,
|
|
789
|
+
shareVersion: expectedShareVersion,
|
|
790
|
+
}),
|
|
791
|
+
}
|
|
792
|
+
: '$$REMOVE',
|
|
793
|
+
updatedAt: now,
|
|
794
|
+
recoveryMethods: {
|
|
795
|
+
$concatArrays: [
|
|
796
|
+
{
|
|
797
|
+
$filter: {
|
|
798
|
+
input: { $ifNull: ['$recoveryMethods', []] },
|
|
799
|
+
as: 'm',
|
|
800
|
+
cond: { $ne: ['$$m.type', 'escrow'] },
|
|
801
|
+
},
|
|
802
|
+
},
|
|
803
|
+
[
|
|
804
|
+
{
|
|
805
|
+
type: 'escrow',
|
|
806
|
+
createdAt: now,
|
|
807
|
+
confirmationStatus: 'confirmed',
|
|
808
|
+
confirmedAt: now,
|
|
809
|
+
shareVersion: expectedShareVersion,
|
|
810
|
+
},
|
|
811
|
+
],
|
|
812
|
+
],
|
|
813
|
+
},
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
],
|
|
817
|
+
{ returnDocument: 'after' }
|
|
818
|
+
);
|
|
819
|
+
};
|
|
285
820
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
821
|
+
/**
|
|
822
|
+
* P9.3: migrates an escrow blob sealed under a previous enclave key onto the
|
|
823
|
+
* current one. Atomic compare-and-swap against the OLD envelope/blob object
|
|
824
|
+
* AND the unchanged `escrowPin` (read at the same time as `oldBlob`, so ANY
|
|
825
|
+
* concurrent write to this account since — a re-enroll, a PIN change, an
|
|
826
|
+
* opt-out — loses the race safely rather than overwriting a stale
|
|
827
|
+
* assumption), even though this write never touches `escrowPin` itself.
|
|
828
|
+
* Writes the new envelope under the CURRENT `enclaveKeyId`, a fresh
|
|
829
|
+
* Rust-order `blobHash` (identical algorithm to `setEscrowBlobByAuthProvider`),
|
|
830
|
+
* the SAME `enrollmentEpoch` and `shareVersion` — `escrowPin` is left
|
|
831
|
+
* exactly as read. Lost race (version bump, opt-out, or any other change
|
|
832
|
+
* since `oldBlob`/`oldPin` were read) -> null, no-op; callers retry on a
|
|
833
|
+
* later job run rather than treating this as an error.
|
|
834
|
+
*/
|
|
835
|
+
export const rewrapEscrowBlobByAuthProvider = async (
|
|
836
|
+
authProvider: AuthProviderMapping,
|
|
837
|
+
oldBlob: EscrowBlob,
|
|
838
|
+
oldPin: MongoUserKeyType['escrowPin'],
|
|
839
|
+
newEnvelope: EscrowBlob['envelope'],
|
|
840
|
+
identity: Pick<EscrowBlob, 'enclaveKeyId' | 'enclaveMode' | 'measurements'>
|
|
841
|
+
): Promise<MongoUserKeyType | null> => {
|
|
842
|
+
const now = new Date();
|
|
843
|
+
const blobHash = escrowBlobIdentity(newEnvelope);
|
|
844
|
+
const parsed = EscrowBlobValidator.safeParse({
|
|
845
|
+
envelope: newEnvelope,
|
|
846
|
+
enclaveKeyId: identity.enclaveKeyId,
|
|
847
|
+
enclaveMode: identity.enclaveMode,
|
|
848
|
+
measurements: identity.measurements,
|
|
849
|
+
shareVersion: oldBlob.shareVersion,
|
|
850
|
+
blobHash,
|
|
851
|
+
enrollmentEpoch: oldBlob.enrollmentEpoch,
|
|
852
|
+
createdAt: now,
|
|
853
|
+
});
|
|
854
|
+
if (!parsed.success || parsed.data.enclaveKeyId !== parsed.data.envelope.keyId) return null;
|
|
855
|
+
const filter: Filter<MongoUserKeyType> = {
|
|
856
|
+
...getAuthProviderFilter(authProvider),
|
|
857
|
+
escrowBlob: oldBlob,
|
|
858
|
+
escrowOptedOutAt: { $exists: false },
|
|
859
|
+
...(oldPin ? { escrowPin: oldPin } : { escrowPin: { $exists: false } }),
|
|
860
|
+
};
|
|
861
|
+
return getUserKeysCollection().findOneAndUpdate(
|
|
862
|
+
filter,
|
|
863
|
+
{ $set: { escrowBlob: parsed.data, updatedAt: now } },
|
|
864
|
+
{ returnDocument: 'after' }
|
|
865
|
+
);
|
|
866
|
+
};
|
|
294
867
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
868
|
+
/**
|
|
869
|
+
* P9.3 migration job candidates: escrow blobs still sealed under any of the
|
|
870
|
+
* given (previous) enclave keys. Never returns opted-out accounts — escrow
|
|
871
|
+
* is fully disabled for them, so there is nothing to migrate. Bounded by
|
|
872
|
+
* `limit`, mirroring `findEscrowHoldsDueForReminder`'s own batch shape.
|
|
873
|
+
*/
|
|
874
|
+
export const findUserKeysWithEscrowBlobKeyId = async (
|
|
875
|
+
keyIds: string[],
|
|
876
|
+
limit: number
|
|
877
|
+
): Promise<MongoUserKeyType[]> =>
|
|
878
|
+
getUserKeysCollection()
|
|
879
|
+
.find({
|
|
880
|
+
'escrowBlob.enclaveKeyId': { $in: keyIds },
|
|
881
|
+
escrowOptedOutAt: { $exists: false },
|
|
882
|
+
})
|
|
883
|
+
.limit(limit)
|
|
884
|
+
.toArray();
|
|
885
|
+
|
|
886
|
+
export const clearEscrowByAuthProvider = async (
|
|
887
|
+
authProvider: AuthProviderMapping,
|
|
888
|
+
{ optOut }: { optOut: boolean }
|
|
889
|
+
): Promise<MongoUserKeyType | null> => {
|
|
890
|
+
const now = new Date();
|
|
891
|
+
return getUserKeysCollection().findOneAndUpdate(
|
|
892
|
+
getAuthProviderFilter(authProvider),
|
|
893
|
+
{
|
|
894
|
+
$unset: { escrowBlob: '', escrowPin: '' },
|
|
895
|
+
$pull: { recoveryMethods: { type: 'escrow' } },
|
|
896
|
+
$set: { updatedAt: now, ...(optOut ? { escrowOptedOutAt: now } : {}) },
|
|
897
|
+
},
|
|
898
|
+
{ returnDocument: 'after' }
|
|
899
|
+
);
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
export const setEscrowOptInByAuthProvider = async (
|
|
903
|
+
authProvider: AuthProviderMapping
|
|
904
|
+
): Promise<MongoUserKeyType | null> =>
|
|
905
|
+
getUserKeysCollection().findOneAndUpdate(
|
|
906
|
+
getAuthProviderFilter(authProvider),
|
|
907
|
+
{ $unset: { escrowOptedOutAt: '' }, $set: { updatedAt: new Date() } },
|
|
908
|
+
{ returnDocument: 'after' }
|
|
909
|
+
);
|
|
298
910
|
|
|
299
|
-
|
|
911
|
+
/** Reserve before claiming a hold or invoking the enclave; never read-then-increment. */
|
|
912
|
+
export const reserveEscrowPinAttempt = async (
|
|
913
|
+
authProvider: AuthProviderMapping,
|
|
914
|
+
shareVersion: number,
|
|
915
|
+
expectedCiphertext?: string
|
|
916
|
+
): Promise<MongoUserKeyType | null> =>
|
|
917
|
+
getUserKeysCollection().findOneAndUpdate(
|
|
300
918
|
{
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
919
|
+
...getAuthProviderFilter(authProvider),
|
|
920
|
+
shareVersion,
|
|
921
|
+
'escrowPin.shareVersion': shareVersion,
|
|
922
|
+
'escrowPin.failedAttempts': { $lt: ESCROW_PIN_MAX_ATTEMPTS },
|
|
923
|
+
'escrowPin.disabledAt': { $exists: false },
|
|
924
|
+
...(expectedCiphertext === undefined
|
|
925
|
+
? {}
|
|
926
|
+
: { 'escrowBlob.envelope.ciphertext': expectedCiphertext }),
|
|
927
|
+
},
|
|
928
|
+
[
|
|
929
|
+
{
|
|
930
|
+
$set: {
|
|
931
|
+
// Legacy charged history may include unresolved reservations. Keep
|
|
932
|
+
// its budget, but never infer verified mismatches from it.
|
|
933
|
+
'escrowPin.verifiedFailedAttempts': {
|
|
934
|
+
$ifNull: ['$escrowPin.verifiedFailedAttempts', 0],
|
|
935
|
+
},
|
|
936
|
+
'escrowPin.failedAttempts': { $add: ['$escrowPin.failedAttempts', 1] },
|
|
937
|
+
updatedAt: new Date(),
|
|
308
938
|
},
|
|
309
939
|
},
|
|
940
|
+
],
|
|
941
|
+
{ returnDocument: 'after' }
|
|
942
|
+
);
|
|
943
|
+
|
|
944
|
+
/** Refund infrastructure/claim failures without modifying a replacement PIN enrollment. */
|
|
945
|
+
export const refundEscrowPinAttempt = async (
|
|
946
|
+
authProvider: AuthProviderMapping,
|
|
947
|
+
shareVersion: number,
|
|
948
|
+
expectedCiphertext: string
|
|
949
|
+
): Promise<void> => {
|
|
950
|
+
await getUserKeysCollection().updateOne(
|
|
951
|
+
{
|
|
952
|
+
...getAuthProviderFilter(authProvider),
|
|
953
|
+
shareVersion,
|
|
954
|
+
'escrowPin.shareVersion': shareVersion,
|
|
955
|
+
'escrowPin.failedAttempts': { $gt: 0 },
|
|
956
|
+
'escrowPin.disabledAt': { $exists: false },
|
|
957
|
+
'escrowBlob.envelope.ciphertext': expectedCiphertext,
|
|
310
958
|
},
|
|
959
|
+
{ $inc: { 'escrowPin.failedAttempts': -1 }, $set: { updatedAt: new Date() } }
|
|
960
|
+
);
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
/** Only an enclave mismatch can advance verified failures and atomically disable a PIN. */
|
|
964
|
+
export const recordEscrowPinFailure = async (
|
|
965
|
+
authProvider: AuthProviderMapping,
|
|
966
|
+
shareVersion: number,
|
|
967
|
+
expectedCiphertext: string
|
|
968
|
+
): Promise<MongoUserKeyType | null> =>
|
|
969
|
+
getUserKeysCollection().findOneAndUpdate(
|
|
311
970
|
{
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
971
|
+
...getAuthProviderFilter(authProvider),
|
|
972
|
+
shareVersion,
|
|
973
|
+
'escrowPin.shareVersion': shareVersion,
|
|
974
|
+
'escrowBlob.envelope.ciphertext': expectedCiphertext,
|
|
975
|
+
'escrowPin.disabledAt': { $exists: false },
|
|
976
|
+
},
|
|
977
|
+
[
|
|
978
|
+
{
|
|
979
|
+
$set: {
|
|
980
|
+
'escrowPin.verifiedFailedAttempts': {
|
|
981
|
+
$add: ['$escrowPin.verifiedFailedAttempts', 1],
|
|
982
|
+
},
|
|
983
|
+
updatedAt: new Date(),
|
|
984
|
+
},
|
|
985
|
+
},
|
|
986
|
+
{
|
|
987
|
+
$set: {
|
|
988
|
+
'escrowPin.disabledAt': {
|
|
989
|
+
$cond: [
|
|
990
|
+
{
|
|
991
|
+
$gte: [
|
|
992
|
+
'$escrowPin.verifiedFailedAttempts',
|
|
993
|
+
ESCROW_PIN_MAX_ATTEMPTS,
|
|
994
|
+
],
|
|
995
|
+
},
|
|
996
|
+
'$$NOW',
|
|
997
|
+
'$$REMOVE',
|
|
998
|
+
],
|
|
999
|
+
},
|
|
316
1000
|
},
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
1001
|
+
},
|
|
1002
|
+
],
|
|
1003
|
+
{ returnDocument: 'after' }
|
|
1004
|
+
);
|
|
1005
|
+
|
|
1006
|
+
export const disableEscrowPin = async (
|
|
1007
|
+
authProvider: AuthProviderMapping,
|
|
1008
|
+
shareVersion?: number,
|
|
1009
|
+
expectedCiphertext?: string
|
|
1010
|
+
): Promise<boolean> => {
|
|
1011
|
+
const result = await getUserKeysCollection().updateOne(
|
|
1012
|
+
{
|
|
1013
|
+
...getAuthProviderFilter(authProvider),
|
|
1014
|
+
escrowPin: { $exists: true },
|
|
1015
|
+
'escrowPin.verifiedFailedAttempts': { $gte: ESCROW_PIN_MAX_ATTEMPTS },
|
|
1016
|
+
...(shareVersion === undefined ? {} : { 'escrowPin.shareVersion': shareVersion }),
|
|
1017
|
+
...(expectedCiphertext === undefined
|
|
1018
|
+
? {}
|
|
1019
|
+
: { 'escrowBlob.envelope.ciphertext': expectedCiphertext }),
|
|
1020
|
+
},
|
|
1021
|
+
{ $set: { 'escrowPin.disabledAt': new Date(), updatedAt: new Date() } }
|
|
1022
|
+
);
|
|
1023
|
+
return result.matchedCount === 1;
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
const addRecoveryMethodWithFilter = async (
|
|
1027
|
+
filter: Filter<MongoUserKeyType>,
|
|
1028
|
+
recoveryMethod: RecoveryMethod
|
|
1029
|
+
): Promise<void> => {
|
|
1030
|
+
const collection = getUserKeysCollection();
|
|
1031
|
+
const now = new Date();
|
|
1032
|
+
|
|
1033
|
+
// For passkeys, scope the $pull by credentialId so multiple passkeys
|
|
1034
|
+
// (each with a unique credentialId) can coexist. For other types
|
|
1035
|
+
// (backup, phrase, email) there is at most one per type.
|
|
1036
|
+
const keepExistingMethod =
|
|
1037
|
+
recoveryMethod.type === 'passkey'
|
|
1038
|
+
? {
|
|
1039
|
+
$or: [
|
|
1040
|
+
{ $ne: ['$$method.type', recoveryMethod.type] },
|
|
1041
|
+
{
|
|
1042
|
+
$ne: [
|
|
1043
|
+
{ $ifNull: ['$$method.credentialId', null] },
|
|
1044
|
+
recoveryMethod.credentialId ?? null,
|
|
1045
|
+
],
|
|
1046
|
+
},
|
|
1047
|
+
],
|
|
1048
|
+
}
|
|
1049
|
+
: { $ne: ['$$method.type', recoveryMethod.type] };
|
|
1050
|
+
|
|
1051
|
+
await collection.updateOne(filter, [
|
|
1052
|
+
{
|
|
1053
|
+
$set: {
|
|
1054
|
+
recoveryMethods: {
|
|
1055
|
+
$concatArrays: [
|
|
1056
|
+
{
|
|
1057
|
+
$filter: {
|
|
1058
|
+
input: { $ifNull: ['$recoveryMethods', []] },
|
|
1059
|
+
as: 'method',
|
|
1060
|
+
cond: keepExistingMethod,
|
|
1061
|
+
},
|
|
1062
|
+
},
|
|
1063
|
+
[{ $literal: recoveryMethod }],
|
|
1064
|
+
],
|
|
320
1065
|
},
|
|
1066
|
+
updatedAt: now,
|
|
1067
|
+
},
|
|
1068
|
+
},
|
|
1069
|
+
]);
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
export const addRecoveryMethodToUserKeyByAuthProvider = async (
|
|
1073
|
+
authProvider: AuthProviderMapping,
|
|
1074
|
+
recoveryMethod: RecoveryMethod
|
|
1075
|
+
): Promise<void> => {
|
|
1076
|
+
await addRecoveryMethodWithFilter(getAuthProviderFilter(authProvider), recoveryMethod);
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
export const removeRecoveryMethodFromUserKeyByAuthProvider = async (
|
|
1080
|
+
authProvider: AuthProviderMapping,
|
|
1081
|
+
type: RecoveryMethod['type'],
|
|
1082
|
+
credentialId?: string
|
|
1083
|
+
): Promise<void> => {
|
|
1084
|
+
const pullFilter: Record<string, unknown> = { type };
|
|
1085
|
+
|
|
1086
|
+
if (type === 'passkey' && credentialId) pullFilter.credentialId = credentialId;
|
|
1087
|
+
|
|
1088
|
+
await getUserKeysCollection().updateOne(getAuthProviderFilter(authProvider), {
|
|
1089
|
+
$pull: { recoveryMethods: pullFilter },
|
|
1090
|
+
...(type === 'escrow'
|
|
1091
|
+
? { $unset: { escrowBlob: '' as const, escrowPin: '' as const } }
|
|
1092
|
+
: {}),
|
|
1093
|
+
$set: { updatedAt: new Date() },
|
|
1094
|
+
});
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
const getRecoveryMethodSelector = (
|
|
1098
|
+
type: RecoveryMethod['type'],
|
|
1099
|
+
shareVersion: number,
|
|
1100
|
+
credentialId?: string
|
|
1101
|
+
): Record<string, unknown> => ({
|
|
1102
|
+
type,
|
|
1103
|
+
shareVersion,
|
|
1104
|
+
confirmationStatus: 'pending',
|
|
1105
|
+
...(type === 'passkey' && credentialId ? { credentialId } : {}),
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
export const incrementRecoveryConfirmationAttemptsByAuthProvider = async (
|
|
1109
|
+
authProvider: AuthProviderMapping,
|
|
1110
|
+
shareVersion: number
|
|
1111
|
+
): Promise<boolean> => {
|
|
1112
|
+
const result = await getUserKeysCollection().updateOne(
|
|
1113
|
+
{
|
|
1114
|
+
...getAuthProviderFilter(authProvider),
|
|
1115
|
+
recoveryMethods: {
|
|
1116
|
+
$elemMatch: getRecoveryMethodSelector('email', shareVersion),
|
|
321
1117
|
},
|
|
322
1118
|
},
|
|
323
|
-
|
|
1119
|
+
{
|
|
1120
|
+
$inc: { 'recoveryMethods.$.confirmationAttempts': 1 },
|
|
1121
|
+
$set: { updatedAt: new Date() },
|
|
1122
|
+
}
|
|
1123
|
+
);
|
|
1124
|
+
|
|
1125
|
+
return result.modifiedCount === 1;
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
export const confirmRecoveryMethodByAuthProvider = async (
|
|
1129
|
+
authProvider: AuthProviderMapping,
|
|
1130
|
+
type: RecoveryMethod['type'],
|
|
1131
|
+
shareVersion: number,
|
|
1132
|
+
credentialId?: string
|
|
1133
|
+
): Promise<boolean> => {
|
|
1134
|
+
const selector = getRecoveryMethodSelector(type, shareVersion, credentialId);
|
|
1135
|
+
const now = new Date();
|
|
1136
|
+
const result = await getUserKeysCollection().updateOne(
|
|
1137
|
+
{
|
|
1138
|
+
...getAuthProviderFilter(authProvider),
|
|
1139
|
+
recoveryMethods: { $elemMatch: selector },
|
|
1140
|
+
},
|
|
1141
|
+
{
|
|
1142
|
+
$set: {
|
|
1143
|
+
'recoveryMethods.$.confirmationStatus': 'confirmed',
|
|
1144
|
+
'recoveryMethods.$.confirmedAt': now,
|
|
1145
|
+
updatedAt: now,
|
|
1146
|
+
},
|
|
1147
|
+
$unset: {
|
|
1148
|
+
'recoveryMethods.$.confirmationCodeHash': '',
|
|
1149
|
+
'recoveryMethods.$.confirmationCodeExpiresAt': '',
|
|
1150
|
+
'recoveryMethods.$.confirmationAttempts': '',
|
|
1151
|
+
'recoveryMethods.$.confirmationEmail': '',
|
|
1152
|
+
},
|
|
1153
|
+
}
|
|
1154
|
+
);
|
|
1155
|
+
|
|
1156
|
+
return result.modifiedCount === 1;
|
|
324
1157
|
};
|
|
325
1158
|
|
|
326
1159
|
export const markUserKeyMigrated = async (contactMethod: ContactMethod): Promise<void> => {
|
|
@@ -342,6 +1175,114 @@ export const markUserKeyMigrated = async (contactMethod: ContactMethod): Promise
|
|
|
342
1175
|
);
|
|
343
1176
|
};
|
|
344
1177
|
|
|
1178
|
+
export const markUserKeyMigratedByAuthProvider = async (
|
|
1179
|
+
authProvider: AuthProviderMapping
|
|
1180
|
+
): Promise<void> => {
|
|
1181
|
+
await getUserKeysCollection().updateOne(getAuthProviderFilter(authProvider), {
|
|
1182
|
+
$set: {
|
|
1183
|
+
migratedFromWeb3Auth: true,
|
|
1184
|
+
migratedAt: new Date(),
|
|
1185
|
+
keyProvider: 'sss',
|
|
1186
|
+
updatedAt: new Date(),
|
|
1187
|
+
},
|
|
1188
|
+
});
|
|
1189
|
+
};
|
|
1190
|
+
|
|
1191
|
+
export const markUserKeyMigrationProvisionalByAuthProvider = async (
|
|
1192
|
+
authProvider: AuthProviderMapping,
|
|
1193
|
+
provisionalCreatedAt: Date
|
|
1194
|
+
): Promise<boolean> => {
|
|
1195
|
+
const result = await getUserKeysCollection().updateOne(
|
|
1196
|
+
{
|
|
1197
|
+
...getAuthProviderFilter(authProvider),
|
|
1198
|
+
$or: [
|
|
1199
|
+
{ keyProvider: 'web3auth' },
|
|
1200
|
+
{ keyProvider: 'sss', sssActivationState: 'provisional' },
|
|
1201
|
+
],
|
|
1202
|
+
},
|
|
1203
|
+
{
|
|
1204
|
+
$set: {
|
|
1205
|
+
// Preserve legacy fallback until recovery enrollment activates SSS.
|
|
1206
|
+
keyProvider: 'web3auth',
|
|
1207
|
+
sssActivationState: 'provisional',
|
|
1208
|
+
provisionalCreatedAt,
|
|
1209
|
+
updatedAt: new Date(),
|
|
1210
|
+
},
|
|
1211
|
+
}
|
|
1212
|
+
);
|
|
1213
|
+
|
|
1214
|
+
return result.modifiedCount > 0 || result.matchedCount > 0;
|
|
1215
|
+
};
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* Commit activation in one document update. The recovery-method predicate is
|
|
1219
|
+
* included in the update filter so a concurrent removal cannot violate the
|
|
1220
|
+
* activation invariant between validation and commit.
|
|
1221
|
+
*/
|
|
1222
|
+
export const activateUserKeyByAuthProvider = async (
|
|
1223
|
+
authProvider: AuthProviderMapping,
|
|
1224
|
+
shareVersion: number,
|
|
1225
|
+
isMigration: boolean
|
|
1226
|
+
): Promise<boolean> => {
|
|
1227
|
+
const now = new Date();
|
|
1228
|
+
const migrationFields = isMigration ? { migratedFromWeb3Auth: true, migratedAt: now } : {};
|
|
1229
|
+
const result = await getUserKeysCollection().updateOne(
|
|
1230
|
+
{
|
|
1231
|
+
...getAuthProviderFilter(authProvider),
|
|
1232
|
+
shareVersion,
|
|
1233
|
+
sssActivationState: 'provisional',
|
|
1234
|
+
recoveryMethods: { $elemMatch: { shareVersion, confirmedAt: { $exists: true } } },
|
|
1235
|
+
},
|
|
1236
|
+
{
|
|
1237
|
+
$set: {
|
|
1238
|
+
sssActivationState: 'active',
|
|
1239
|
+
keyProvider: 'sss',
|
|
1240
|
+
...migrationFields,
|
|
1241
|
+
updatedAt: now,
|
|
1242
|
+
},
|
|
1243
|
+
$unset: { provisionalCreatedAt: '' },
|
|
1244
|
+
}
|
|
1245
|
+
);
|
|
1246
|
+
|
|
1247
|
+
return result.modifiedCount === 1;
|
|
1248
|
+
};
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Lazily discard abandoned migration shares after the P0-1 TTL. New-user
|
|
1252
|
+
* provisional records are excluded because their keyProvider is already SSS.
|
|
1253
|
+
*/
|
|
1254
|
+
export const purgeExpiredProvisionalMigrationByAuthProvider = async (
|
|
1255
|
+
authProvider: AuthProviderMapping,
|
|
1256
|
+
now = new Date()
|
|
1257
|
+
): Promise<boolean> => {
|
|
1258
|
+
const cutoff = new Date(now.getTime() - PROVISIONAL_MIGRATION_TTL_MS);
|
|
1259
|
+
const result = await getUserKeysCollection().updateOne(
|
|
1260
|
+
{
|
|
1261
|
+
...getAuthProviderFilter(authProvider),
|
|
1262
|
+
keyProvider: 'web3auth',
|
|
1263
|
+
sssActivationState: 'provisional',
|
|
1264
|
+
provisionalCreatedAt: { $lte: cutoff },
|
|
1265
|
+
},
|
|
1266
|
+
{
|
|
1267
|
+
$set: {
|
|
1268
|
+
recoveryMethods: [],
|
|
1269
|
+
previousAuthShares: [],
|
|
1270
|
+
updatedAt: now,
|
|
1271
|
+
},
|
|
1272
|
+
$unset: {
|
|
1273
|
+
authShare: '',
|
|
1274
|
+
escrowBlob: '',
|
|
1275
|
+
escrowPin: '',
|
|
1276
|
+
shareUpdatedAt: '',
|
|
1277
|
+
sssActivationState: '',
|
|
1278
|
+
provisionalCreatedAt: '',
|
|
1279
|
+
},
|
|
1280
|
+
}
|
|
1281
|
+
);
|
|
1282
|
+
|
|
1283
|
+
return result.modifiedCount === 1;
|
|
1284
|
+
};
|
|
1285
|
+
|
|
345
1286
|
export const setRecoveryEmail = async (
|
|
346
1287
|
contactMethod: ContactMethod,
|
|
347
1288
|
email: string
|
|
@@ -355,7 +1296,7 @@ export const setRecoveryEmail = async (
|
|
|
355
1296
|
},
|
|
356
1297
|
{
|
|
357
1298
|
$set: {
|
|
358
|
-
recoveryEmail: email,
|
|
1299
|
+
recoveryEmail: email.trim().toLowerCase(),
|
|
359
1300
|
recoveryEmailVerifiedAt: new Date(),
|
|
360
1301
|
updatedAt: new Date(),
|
|
361
1302
|
},
|
|
@@ -363,6 +1304,19 @@ export const setRecoveryEmail = async (
|
|
|
363
1304
|
);
|
|
364
1305
|
};
|
|
365
1306
|
|
|
1307
|
+
export const setRecoveryEmailByAuthProvider = async (
|
|
1308
|
+
authProvider: AuthProviderMapping,
|
|
1309
|
+
email: string
|
|
1310
|
+
): Promise<void> => {
|
|
1311
|
+
await getUserKeysCollection().updateOne(getAuthProviderFilter(authProvider), {
|
|
1312
|
+
$set: {
|
|
1313
|
+
recoveryEmail: email.trim().toLowerCase(),
|
|
1314
|
+
recoveryEmailVerifiedAt: new Date(),
|
|
1315
|
+
updatedAt: new Date(),
|
|
1316
|
+
},
|
|
1317
|
+
});
|
|
1318
|
+
};
|
|
1319
|
+
|
|
366
1320
|
export const upgradeContactMethod = async (
|
|
367
1321
|
oldContactMethod: ContactMethod,
|
|
368
1322
|
newContactMethod: ContactMethod
|
|
@@ -395,6 +1349,28 @@ export const upgradeContactMethod = async (
|
|
|
395
1349
|
return result.modifiedCount > 0;
|
|
396
1350
|
};
|
|
397
1351
|
|
|
1352
|
+
export const upgradeContactMethodByAuthProvider = async (
|
|
1353
|
+
authProvider: AuthProviderMapping,
|
|
1354
|
+
expectedOldContactMethod: ContactMethod,
|
|
1355
|
+
newContactMethod: ContactMethod
|
|
1356
|
+
): Promise<boolean> => {
|
|
1357
|
+
const result = await getUserKeysCollection().updateOne(
|
|
1358
|
+
{
|
|
1359
|
+
...getAuthProviderFilter(authProvider),
|
|
1360
|
+
'contactMethod.type': expectedOldContactMethod.type,
|
|
1361
|
+
'contactMethod.value': expectedOldContactMethod.value,
|
|
1362
|
+
},
|
|
1363
|
+
{
|
|
1364
|
+
$set: {
|
|
1365
|
+
contactMethod: newContactMethod,
|
|
1366
|
+
updatedAt: new Date(),
|
|
1367
|
+
},
|
|
1368
|
+
}
|
|
1369
|
+
);
|
|
1370
|
+
|
|
1371
|
+
return result.modifiedCount > 0;
|
|
1372
|
+
};
|
|
1373
|
+
|
|
398
1374
|
export const deleteUserKey = async (contactMethod: ContactMethod): Promise<void> => {
|
|
399
1375
|
const collection = getUserKeysCollection();
|
|
400
1376
|
|
|
@@ -403,3 +1379,96 @@ export const deleteUserKey = async (contactMethod: ContactMethod): Promise<void>
|
|
|
403
1379
|
'contactMethod.value': contactMethod.value,
|
|
404
1380
|
});
|
|
405
1381
|
};
|
|
1382
|
+
|
|
1383
|
+
export const deleteUserKeyByAuthProvider = async (
|
|
1384
|
+
authProvider: AuthProviderMapping
|
|
1385
|
+
): Promise<void> => {
|
|
1386
|
+
await getUserKeysCollection().deleteOne(getAuthProviderFilter(authProvider));
|
|
1387
|
+
};
|
|
1388
|
+
|
|
1389
|
+
export interface CompleteIdentityRebindInput {
|
|
1390
|
+
oldAuthProvider: AuthProviderMapping;
|
|
1391
|
+
newAuthProvider: AuthProviderMapping;
|
|
1392
|
+
newContactMethod: ContactMethod;
|
|
1393
|
+
primaryDid: string;
|
|
1394
|
+
authShare: ServerEncryptedShare;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* Atomically bind a replacement login identity and commit a security rotation.
|
|
1399
|
+
* Historical auth shares are purged. Existing recovery methods are retained only
|
|
1400
|
+
* as unconfirmed descriptors because their external shares belong to the old split.
|
|
1401
|
+
*/
|
|
1402
|
+
export const completeIdentityRebind = async (
|
|
1403
|
+
input: CompleteIdentityRebindInput
|
|
1404
|
+
): Promise<{
|
|
1405
|
+
shareVersion: number;
|
|
1406
|
+
methodsRequiringConfirmation: RecoveryMethod['type'][];
|
|
1407
|
+
} | null> => {
|
|
1408
|
+
const collection = getUserKeysCollection();
|
|
1409
|
+
const existing = await collection.findOne({
|
|
1410
|
+
...getAuthProviderFilter(input.oldAuthProvider),
|
|
1411
|
+
primaryDid: input.primaryDid,
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
if (!existing) return null;
|
|
1415
|
+
|
|
1416
|
+
const providerConflict = await collection.findOne({
|
|
1417
|
+
...getAuthProviderFilter(input.newAuthProvider),
|
|
1418
|
+
...(existing._id ? { _id: { $ne: existing._id } } : {}),
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
if (providerConflict) return null;
|
|
1422
|
+
|
|
1423
|
+
const now = new Date();
|
|
1424
|
+
const shareVersion = (existing.shareVersion ?? 1) + 1;
|
|
1425
|
+
const remainingAuthProviders = (existing.authProviders ?? []).filter(
|
|
1426
|
+
provider =>
|
|
1427
|
+
provider.type !== input.oldAuthProvider.type || provider.id !== input.oldAuthProvider.id
|
|
1428
|
+
);
|
|
1429
|
+
const authProviders = remainingAuthProviders.some(
|
|
1430
|
+
provider =>
|
|
1431
|
+
provider.type === input.newAuthProvider.type && provider.id === input.newAuthProvider.id
|
|
1432
|
+
)
|
|
1433
|
+
? remainingAuthProviders
|
|
1434
|
+
: [...remainingAuthProviders, input.newAuthProvider];
|
|
1435
|
+
const recoveryMethods = (existing.recoveryMethods ?? []).map(method => ({
|
|
1436
|
+
type: method.type,
|
|
1437
|
+
createdAt: now,
|
|
1438
|
+
confirmationStatus: 'pending' as const,
|
|
1439
|
+
credentialId: method.credentialId,
|
|
1440
|
+
shareVersion,
|
|
1441
|
+
}));
|
|
1442
|
+
const result = await collection.findOneAndUpdate(
|
|
1443
|
+
{
|
|
1444
|
+
...getAuthProviderFilter(input.oldAuthProvider),
|
|
1445
|
+
primaryDid: input.primaryDid,
|
|
1446
|
+
shareVersion: existing.shareVersion ?? 1,
|
|
1447
|
+
},
|
|
1448
|
+
{
|
|
1449
|
+
$set: {
|
|
1450
|
+
authProviders,
|
|
1451
|
+
contactMethod: input.newContactMethod,
|
|
1452
|
+
authShare: input.authShare,
|
|
1453
|
+
shareVersion,
|
|
1454
|
+
shareUpdatedAt: now,
|
|
1455
|
+
previousAuthShares: [],
|
|
1456
|
+
recoveryMethods,
|
|
1457
|
+
keyProvider: 'sss',
|
|
1458
|
+
// The new split has no confirmed recovery method yet. SSS records
|
|
1459
|
+
// are excluded from provisional migration cleanup (Web3Auth only).
|
|
1460
|
+
sssActivationState: 'provisional',
|
|
1461
|
+
updatedAt: now,
|
|
1462
|
+
},
|
|
1463
|
+
$unset: { provisionalCreatedAt: '', escrowBlob: '', escrowPin: '' },
|
|
1464
|
+
},
|
|
1465
|
+
{ returnDocument: 'after' }
|
|
1466
|
+
);
|
|
1467
|
+
|
|
1468
|
+
if (!result) return null;
|
|
1469
|
+
|
|
1470
|
+
return {
|
|
1471
|
+
shareVersion,
|
|
1472
|
+
methodsRequiringConfirmation: recoveryMethods.map(method => method.type),
|
|
1473
|
+
};
|
|
1474
|
+
};
|