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