@learncard/lca-api-service 1.6.0 → 1.6.2
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/AGENTS.md +21 -0
- package/CHANGELOG.md +31 -0
- package/dist/app.d.ts +41 -0
- package/dist/cache/recoverySessions.d.ts +5 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/helpers/accountSecureLink.d.ts +4 -0
- package/dist/helpers/accountSecureLink.test.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/models/EscrowHold.d.ts +4 -1
- package/dist/models/UserKey.d.ts +199 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/escrow.d.ts +4 -0
- package/dist/routes/keys.d.ts +47 -1
- package/dist/services/escrow-enclave/enrollmentLease.d.ts +8 -0
- package/dist/services/escrow-enclave/notifications.d.ts +1 -1
- package/package.json +11 -11
- package/project.json +1 -1
- package/src/cache/recoverySessions.ts +38 -0
- package/src/helpers/accountSecureLink.test.ts +26 -0
- package/src/helpers/accountSecureLink.ts +16 -0
- package/src/jobs/escrowBlobRewrap.ts +95 -3
- package/src/models/EscrowHold.ts +23 -6
- package/src/models/UserKey.escrowPin.test.ts +21 -0
- package/src/models/UserKey.ts +553 -47
- package/src/routes/escrow.ts +326 -67
- package/src/routes/keys.ts +416 -7
- package/src/services/delivery/adapters/postmark.adapter.ts +11 -0
- package/src/services/escrow-enclave/README.md +8 -3
- package/src/services/escrow-enclave/enrollmentLease.ts +124 -0
- package/src/services/escrow-enclave/notifications.test.ts +69 -0
- package/src/services/escrow-enclave/notifications.ts +19 -1
- package/test/jobs/escrowHoldReminders.spec.ts +88 -4
- package/test/keys-escrow.spec.ts +4 -0
- package/test/keys-lifecycle-invariants.spec.ts +76 -0
- package/test/keys-pin-lease.spec.ts +1132 -0
- package/test/keys-pin-sign-in.spec.ts +1152 -0
- package/tsconfig.json +13 -14
package/src/routes/keys.ts
CHANGED
|
@@ -10,9 +10,10 @@ import admin from 'firebase-admin';
|
|
|
10
10
|
import { createHmac, randomBytes, randomInt, timingSafeEqual } from 'crypto';
|
|
11
11
|
import type { TenantBranding } from '@learncard/email-templates';
|
|
12
12
|
|
|
13
|
-
import { t, openRoute, didRoute, didAndChallengeRoute } from '@routes';
|
|
13
|
+
import { t, openRoute, didRoute, didAndChallengeRoute, type Context } from '@routes';
|
|
14
14
|
import { getDeliveryService, getFrom } from '../services/delivery';
|
|
15
|
-
import { getEscrowBlobStaleReason } from '../services/escrow-enclave';
|
|
15
|
+
import { getEscrowBlobStaleReason, notifyEscrowHoldEvent } from '../services/escrow-enclave';
|
|
16
|
+
import { cancelHoldInEnclave } from '../services/escrow-enclave/cancellation';
|
|
16
17
|
import { verifyAuthToken, getContactMethodFromUser, AuthProviderType } from '@helpers/auth.helpers';
|
|
17
18
|
import { encryptAuthShare, decryptAuthShare } from '@helpers/shareEncryption.helpers';
|
|
18
19
|
import { maskEmail } from '@helpers/maskEmail';
|
|
@@ -24,8 +25,10 @@ import {
|
|
|
24
25
|
claimRecoveryOtpSendWindow,
|
|
25
26
|
consumeRecoverySession,
|
|
26
27
|
createRecoverySession,
|
|
28
|
+
peekRecoverySession,
|
|
27
29
|
storeRecoveryOtp,
|
|
28
30
|
verifyRecoveryOtp,
|
|
31
|
+
type RecoverySessionRecord,
|
|
29
32
|
} from '@cache/recoverySessions';
|
|
30
33
|
import {
|
|
31
34
|
findUserKeyByAuthProvider,
|
|
@@ -46,6 +49,11 @@ import {
|
|
|
46
49
|
upgradeContactMethodByAuthProvider,
|
|
47
50
|
deleteUserKeyByAuthProvider,
|
|
48
51
|
completeIdentityRebind,
|
|
52
|
+
getSecurityEpoch,
|
|
53
|
+
linkAuthProviderKeepingShares,
|
|
54
|
+
revokeOtherDeviceShares,
|
|
55
|
+
findPendingEscrowHoldByAuthProvider,
|
|
56
|
+
cancelEscrowHold,
|
|
49
57
|
ServerEncryptedShareValidator,
|
|
50
58
|
EscrowPinStatusValidator,
|
|
51
59
|
getEscrowPinStatus,
|
|
@@ -231,10 +239,11 @@ const serializeConfirmedRecoveryMethods = (userKey: MongoUserKeyType) =>
|
|
|
231
239
|
...(method.shareVersion != null ? { shareVersion: method.shareVersion } : {}),
|
|
232
240
|
}));
|
|
233
241
|
|
|
234
|
-
|
|
242
|
+
/** Consume a recovery token and return its whole record (origin, released version). */
|
|
243
|
+
export const requireRecoverySessionRecord = async (
|
|
235
244
|
token: string,
|
|
236
245
|
scope: 'recover' | 'rebind'
|
|
237
|
-
): Promise<AuthProviderMapping> => {
|
|
246
|
+
): Promise<RecoverySessionRecord & { authProvider: AuthProviderMapping }> => {
|
|
238
247
|
const session = await consumeRecoverySession(token, scope);
|
|
239
248
|
|
|
240
249
|
if (!session || !AuthProviderTypeValidator.safeParse(session.authProvider.type).success) {
|
|
@@ -244,7 +253,33 @@ export const requireRecoverySession = async (
|
|
|
244
253
|
});
|
|
245
254
|
}
|
|
246
255
|
|
|
247
|
-
return session
|
|
256
|
+
return session as RecoverySessionRecord & { authProvider: AuthProviderMapping };
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
export const requireRecoverySession = async (
|
|
260
|
+
token: string,
|
|
261
|
+
scope: 'recover' | 'rebind'
|
|
262
|
+
): Promise<AuthProviderMapping> => (await requireRecoverySessionRecord(token, scope)).authProvider;
|
|
263
|
+
|
|
264
|
+
/** The security epoch a recovery token was minted at (0 for tokens minted before the field). */
|
|
265
|
+
export const getRecoverySessionEpoch = (session: Pick<RecoverySessionRecord, 'securityEpoch'>) =>
|
|
266
|
+
session.securityEpoch ?? 0;
|
|
267
|
+
|
|
268
|
+
export const STALE_RECOVERY_SESSION_MESSAGE =
|
|
269
|
+
'Your account security changed during recovery. Start again.';
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Reject a recovery token minted before the account's last revoke or wiping rebind.
|
|
273
|
+
* A read-side check for routes that only release material; the routes that write
|
|
274
|
+
* (`/rebind`, `/pin-sign-in`) also enforce the epoch inside the update filter.
|
|
275
|
+
*/
|
|
276
|
+
export const assertRecoverySessionEpoch = (
|
|
277
|
+
session: Pick<RecoverySessionRecord, 'securityEpoch'>,
|
|
278
|
+
userKey: MongoUserKeyType
|
|
279
|
+
): void => {
|
|
280
|
+
if (getRecoverySessionEpoch(session) !== getSecurityEpoch(userKey)) {
|
|
281
|
+
throw new TRPCError({ code: 'CONFLICT', message: STALE_RECOVERY_SESSION_MESSAGE });
|
|
282
|
+
}
|
|
248
283
|
};
|
|
249
284
|
|
|
250
285
|
const notifyIdentityRebind = async (
|
|
@@ -280,6 +315,106 @@ const notifyIdentityRebind = async (
|
|
|
280
315
|
});
|
|
281
316
|
};
|
|
282
317
|
|
|
318
|
+
/** Delivery budget for the "Secure my account" notice, so a slow provider never stalls the route. */
|
|
319
|
+
export const ACCOUNT_SECURED_NOTICE_TIMEOUT_MS = 3_000;
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The account's established contact address and its verified recovery email,
|
|
323
|
+
* deduped case-insensitively. Read from the record as it was before the revoke: the
|
|
324
|
+
* revoke never changes either, and the owner's login may be the one just unlinked.
|
|
325
|
+
*/
|
|
326
|
+
const getAccountSecuredRecipients = (userKey: MongoUserKeyType): string[] => {
|
|
327
|
+
const candidates = [
|
|
328
|
+
userKey.contactMethod.type === 'email' ? userKey.contactMethod.value : undefined,
|
|
329
|
+
userKey.recoveryEmail && userKey.recoveryEmailVerifiedAt
|
|
330
|
+
? userKey.recoveryEmail
|
|
331
|
+
: undefined,
|
|
332
|
+
];
|
|
333
|
+
const recipients = new Set<string>();
|
|
334
|
+
|
|
335
|
+
for (const candidate of candidates) {
|
|
336
|
+
const normalized = candidate?.trim().toLowerCase();
|
|
337
|
+
if (normalized) recipients.add(normalized);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return [...recipients];
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Tell the owner that "Secure my account" ran. If someone with the owner's login and
|
|
345
|
+
* PIN ran it first, the owner's own login was just unlinked, so this notice is their
|
|
346
|
+
* only signal (the PIN sign-in notice's secure link no longer helps them). Awaited
|
|
347
|
+
* within a bounded budget so it is not lost when the request ends; fail-open, never
|
|
348
|
+
* throws, and never logs addresses.
|
|
349
|
+
*/
|
|
350
|
+
const notifyAccountSecured = async (
|
|
351
|
+
userKey: MongoUserKeyType,
|
|
352
|
+
branding: Partial<TenantBranding> | undefined,
|
|
353
|
+
securedAt: Date
|
|
354
|
+
): Promise<void> => {
|
|
355
|
+
const recipients = getAccountSecuredRecipients(userKey);
|
|
356
|
+
if (recipients.length === 0) return;
|
|
357
|
+
|
|
358
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
359
|
+
const send = Promise.allSettled(
|
|
360
|
+
recipients.map(to =>
|
|
361
|
+
getDeliveryService().send({
|
|
362
|
+
to,
|
|
363
|
+
templateAlias: 'account-secured',
|
|
364
|
+
templateModel: { securedAt: securedAt.toISOString() },
|
|
365
|
+
branding,
|
|
366
|
+
from: getFrom({ mailbox: 'recovery', branding }),
|
|
367
|
+
})
|
|
368
|
+
)
|
|
369
|
+
).then(results => {
|
|
370
|
+
const failed = results.filter(result => result.status === 'rejected').length;
|
|
371
|
+
if (failed > 0) {
|
|
372
|
+
console.error('[revokeOtherDevices] Account-secured notice failed', {
|
|
373
|
+
failed,
|
|
374
|
+
total: results.length,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
await Promise.race([
|
|
381
|
+
send,
|
|
382
|
+
new Promise<void>(resolve => {
|
|
383
|
+
timer = setTimeout(() => {
|
|
384
|
+
console.error('[revokeOtherDevices] Account-secured notice timed out');
|
|
385
|
+
resolve();
|
|
386
|
+
}, ACCOUNT_SECURED_NOTICE_TIMEOUT_MS);
|
|
387
|
+
}),
|
|
388
|
+
]);
|
|
389
|
+
} catch {
|
|
390
|
+
console.error('[revokeOtherDevices] Account-secured notice failed');
|
|
391
|
+
} finally {
|
|
392
|
+
clearTimeout(timer);
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
/** Cancel every pending escrow hold on the account (any linked login, either policy). */
|
|
397
|
+
const cancelPendingEscrowHolds = async (
|
|
398
|
+
userKey: MongoUserKeyType,
|
|
399
|
+
tenant: Context['tenant']
|
|
400
|
+
): Promise<void> => {
|
|
401
|
+
for (const authProvider of userKey.authProviders ?? []) {
|
|
402
|
+
for (const policy of ['hold', 'pin'] as const) {
|
|
403
|
+
const pending = await findPendingEscrowHoldByAuthProvider(authProvider, policy);
|
|
404
|
+
const cancelled = pending && (await cancelEscrowHold(pending._id, 'did'));
|
|
405
|
+
if (cancelled) {
|
|
406
|
+
await cancelHoldInEnclave(cancelled, userKey);
|
|
407
|
+
await notifyEscrowHoldEvent({
|
|
408
|
+
kind: 'cancelled',
|
|
409
|
+
hold: cancelled,
|
|
410
|
+
userKey,
|
|
411
|
+
tenant,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
283
418
|
export const keysRouter = t.router({
|
|
284
419
|
startRecoverySession: openRoute
|
|
285
420
|
.meta({
|
|
@@ -405,6 +540,7 @@ export const keysRouter = t.router({
|
|
|
405
540
|
const recoverySessionToken = await createRecoverySession({
|
|
406
541
|
scope: 'recover',
|
|
407
542
|
authProvider: verification.record.authProvider,
|
|
543
|
+
securityEpoch: getSecurityEpoch(userKey),
|
|
408
544
|
});
|
|
409
545
|
|
|
410
546
|
return {
|
|
@@ -441,10 +577,11 @@ export const keysRouter = t.router({
|
|
|
441
577
|
})
|
|
442
578
|
)
|
|
443
579
|
.mutation(async ({ input }) => {
|
|
444
|
-
const
|
|
580
|
+
const recoverSession = await requireRecoverySessionRecord(
|
|
445
581
|
input.recoverySessionToken,
|
|
446
582
|
'recover'
|
|
447
583
|
);
|
|
584
|
+
const { authProvider } = recoverSession;
|
|
448
585
|
if (input.type === 'escrow') {
|
|
449
586
|
throw new TRPCError({
|
|
450
587
|
code: 'BAD_REQUEST',
|
|
@@ -452,6 +589,7 @@ export const keysRouter = t.router({
|
|
|
452
589
|
});
|
|
453
590
|
}
|
|
454
591
|
const userKey = await requireUserKey(authProvider, 'Recovery account not found.');
|
|
592
|
+
assertRecoverySessionEpoch(recoverSession, userKey);
|
|
455
593
|
const recoveryMethod = (userKey.recoveryMethods ?? []).find(method => {
|
|
456
594
|
if (method.type !== input.type || !isRecoveryMethodConfirmed(userKey, method)) {
|
|
457
595
|
return false;
|
|
@@ -481,6 +619,9 @@ export const keysRouter = t.router({
|
|
|
481
619
|
const rebindSessionToken = await createRecoverySession({
|
|
482
620
|
scope: 'rebind',
|
|
483
621
|
authProvider,
|
|
622
|
+
// Inherit the recovery session's epoch, so a revoke at any point after
|
|
623
|
+
// the OTP still invalidates the whole chain at `/rebind`.
|
|
624
|
+
securityEpoch: getRecoverySessionEpoch(recoverSession),
|
|
484
625
|
});
|
|
485
626
|
|
|
486
627
|
return {
|
|
@@ -518,10 +659,11 @@ export const keysRouter = t.router({
|
|
|
518
659
|
})
|
|
519
660
|
)
|
|
520
661
|
.mutation(async ({ input, ctx }) => {
|
|
521
|
-
const
|
|
662
|
+
const session = await requireRecoverySessionRecord(
|
|
522
663
|
input.recoverySessionToken,
|
|
523
664
|
'rebind'
|
|
524
665
|
);
|
|
666
|
+
const oldAuthProvider = session.authProvider;
|
|
525
667
|
const oldUserKey = await requireUserKey(oldAuthProvider, 'Recovery account not found.');
|
|
526
668
|
|
|
527
669
|
assertDidOwner(oldUserKey, ctx.user.did);
|
|
@@ -559,6 +701,17 @@ export const keysRouter = t.router({
|
|
|
559
701
|
newContactMethod,
|
|
560
702
|
primaryDid: input.primaryDid,
|
|
561
703
|
authShare: encryptAuthShare(input.authShare, seed),
|
|
704
|
+
// A PIN-release token is no longer used up by the PIN sign-in itself (keep
|
|
705
|
+
// mode never calls this route), so it may still be live here. Pin it to the
|
|
706
|
+
// version the PIN released: once "Secure my account" (or any rotation)
|
|
707
|
+
// moves the account on, the token can no longer wipe and rebind.
|
|
708
|
+
...(session.origin === 'pin-release'
|
|
709
|
+
? { expectedShareVersion: session.shareVersion ?? -1 }
|
|
710
|
+
: {}),
|
|
711
|
+
// Every token origin (phrase, passkey, backup, waiting-period hold, PIN)
|
|
712
|
+
// is bound to the epoch it was minted at, enforced in the update filter:
|
|
713
|
+
// after a revoke or another wiping rebind, no older token can rebind.
|
|
714
|
+
expectedSecurityEpoch: getRecoverySessionEpoch(session),
|
|
562
715
|
});
|
|
563
716
|
|
|
564
717
|
if (!result) {
|
|
@@ -582,6 +735,246 @@ export const keysRouter = t.router({
|
|
|
582
735
|
};
|
|
583
736
|
}),
|
|
584
737
|
|
|
738
|
+
/**
|
|
739
|
+
* PIN sign-in with a different login (keep mode). Links the new login to the
|
|
740
|
+
* account WITHOUT rotating: shares, history, recovery methods and escrow stay as
|
|
741
|
+
* they are, so other devices and the user's other recovery methods keep working.
|
|
742
|
+
* Only tokens minted by a successful PIN release are accepted; the waiting-period
|
|
743
|
+
* hold and `useRecoverySession` tokens must still use the wiping rebind. The
|
|
744
|
+
* owner was already emailed at the PIN release (`escrow-pin-sign-in`).
|
|
745
|
+
*/
|
|
746
|
+
completePinSignIn: didAndChallengeRoute
|
|
747
|
+
.meta({
|
|
748
|
+
openapi: {
|
|
749
|
+
method: 'POST',
|
|
750
|
+
path: '/keys/recovery-session/pin-sign-in',
|
|
751
|
+
tags: ['Keys'],
|
|
752
|
+
summary: 'Link a new login after a PIN release without rotating shares',
|
|
753
|
+
},
|
|
754
|
+
})
|
|
755
|
+
.input(
|
|
756
|
+
z.object({
|
|
757
|
+
recoverySessionToken: RecoverySessionTokenValidator,
|
|
758
|
+
newAuthToken: z.string().min(1).optional(),
|
|
759
|
+
providerType: AuthProviderTypeValidator,
|
|
760
|
+
primaryDid: z.string(),
|
|
761
|
+
})
|
|
762
|
+
)
|
|
763
|
+
.output(
|
|
764
|
+
z.object({
|
|
765
|
+
success: z.literal(true),
|
|
766
|
+
shareVersion: z.number(),
|
|
767
|
+
linked: z.boolean(),
|
|
768
|
+
})
|
|
769
|
+
)
|
|
770
|
+
.mutation(async ({ input, ctx }) => {
|
|
771
|
+
// Validate before consuming: a waiting-period or phrase token sent here by
|
|
772
|
+
// mistake must stay usable at `/rebind`, and a DID mismatch must not burn
|
|
773
|
+
// the owner's token. Whatever is consumed is re-checked below.
|
|
774
|
+
const isPinSession = (
|
|
775
|
+
record: RecoverySessionRecord | null
|
|
776
|
+
): record is RecoverySessionRecord & { primaryDid: string; shareVersion: number } =>
|
|
777
|
+
!!record &&
|
|
778
|
+
record.origin === 'pin-release' &&
|
|
779
|
+
!!record.primaryDid &&
|
|
780
|
+
record.shareVersion !== undefined;
|
|
781
|
+
const peeked = await peekRecoverySession(input.recoverySessionToken, 'rebind');
|
|
782
|
+
|
|
783
|
+
if (peeked && !isPinSession(peeked)) {
|
|
784
|
+
throw new TRPCError({
|
|
785
|
+
code: 'FORBIDDEN',
|
|
786
|
+
message: 'This recovery session cannot sign in without a reset.',
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
if (
|
|
790
|
+
peeked &&
|
|
791
|
+
(input.primaryDid !== ctx.user.did || input.primaryDid !== peeked.primaryDid)
|
|
792
|
+
) {
|
|
793
|
+
throw new TRPCError({
|
|
794
|
+
code: 'FORBIDDEN',
|
|
795
|
+
message: 'Recovered DID does not match.',
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const session = await requireRecoverySessionRecord(
|
|
800
|
+
input.recoverySessionToken,
|
|
801
|
+
'rebind'
|
|
802
|
+
);
|
|
803
|
+
|
|
804
|
+
if (!isPinSession(session)) {
|
|
805
|
+
throw new TRPCError({
|
|
806
|
+
code: 'FORBIDDEN',
|
|
807
|
+
message: 'This recovery session cannot sign in without a reset.',
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const oldAuthProvider = session.authProvider;
|
|
812
|
+
const userKey = await requireUserKey(oldAuthProvider, 'Recovery account not found.');
|
|
813
|
+
|
|
814
|
+
assertDidOwner(userKey, ctx.user.did);
|
|
815
|
+
if (input.primaryDid !== ctx.user.did || input.primaryDid !== session.primaryDid) {
|
|
816
|
+
throw new TRPCError({
|
|
817
|
+
code: 'FORBIDDEN',
|
|
818
|
+
message: 'Recovered DID does not match.',
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const newUser = await verifyAuthToken(
|
|
823
|
+
input.newAuthToken || ctx.providerToken || '',
|
|
824
|
+
input.providerType
|
|
825
|
+
);
|
|
826
|
+
const newContactMethod = getContactMethodFromUser(newUser);
|
|
827
|
+
|
|
828
|
+
if (!newContactMethod) {
|
|
829
|
+
throw new TRPCError({
|
|
830
|
+
code: 'BAD_REQUEST',
|
|
831
|
+
message: 'New sign-in needs an email or phone.',
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const newAuthProvider: AuthProviderMapping = {
|
|
836
|
+
type: input.providerType,
|
|
837
|
+
id: newUser.id,
|
|
838
|
+
};
|
|
839
|
+
const alreadyLinked = (userKey.authProviders ?? []).some(
|
|
840
|
+
provider =>
|
|
841
|
+
provider.type === newAuthProvider.type && provider.id === newAuthProvider.id
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
if (alreadyLinked) {
|
|
845
|
+
if (
|
|
846
|
+
(userKey.shareVersion ?? 1) !== session.shareVersion ||
|
|
847
|
+
getSecurityEpoch(userKey) !== getRecoverySessionEpoch(session)
|
|
848
|
+
) {
|
|
849
|
+
throw new TRPCError({
|
|
850
|
+
code: 'CONFLICT',
|
|
851
|
+
message: 'Your account changed while signing in. Try again.',
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return {
|
|
856
|
+
success: true as const,
|
|
857
|
+
shareVersion: session.shareVersion,
|
|
858
|
+
linked: false,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const result = await linkAuthProviderKeepingShares({
|
|
863
|
+
oldAuthProvider,
|
|
864
|
+
newAuthProvider,
|
|
865
|
+
primaryDid: input.primaryDid,
|
|
866
|
+
shareVersion: session.shareVersion,
|
|
867
|
+
securityEpoch: getRecoverySessionEpoch(session),
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
if (!result) {
|
|
871
|
+
throw new TRPCError({
|
|
872
|
+
code: 'CONFLICT',
|
|
873
|
+
message:
|
|
874
|
+
'The sign-in is already linked elsewhere or your account changed. Try again.',
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Notification is intentionally fail-open after the link commits.
|
|
879
|
+
void notifyIdentityRebind(userKey, newContactMethod, ctx.tenant?.emailBranding).catch(
|
|
880
|
+
error => console.error('[completePinSignIn] Notification failed:', error)
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
return { success: true as const, shareVersion: result.shareVersion, linked: true };
|
|
884
|
+
}),
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* "Secure my account" / "Sign out of all other devices". The signed-in owner
|
|
888
|
+
* commits a fresh split: every older auth share is purged (other devices and the
|
|
889
|
+
* old phrase, backup file, passkey and email key stop working), recovery methods
|
|
890
|
+
* go back to pending, escrow is cleared for the client to re-enroll, and every
|
|
891
|
+
* login except the caller's is unlinked. One atomic CAS on `expectedShareVersion`;
|
|
892
|
+
* a concurrent rotation gets CONFLICT. The private key is unchanged, so a device
|
|
893
|
+
* that already holds it is not evicted (SECURITY.md D19). The security epoch
|
|
894
|
+
* advances, so every recovery token minted before the revoke is dead. After the
|
|
895
|
+
* commit the contact and verified recovery addresses get the `account-secured`
|
|
896
|
+
* notice (fail-open): the caller may not be the owner.
|
|
897
|
+
*/
|
|
898
|
+
revokeOtherDevices: didAndChallengeRoute
|
|
899
|
+
.meta({
|
|
900
|
+
openapi: {
|
|
901
|
+
method: 'POST',
|
|
902
|
+
path: '/keys/revoke-other-devices',
|
|
903
|
+
tags: ['Keys'],
|
|
904
|
+
summary: 'Rotate shares, purge older versions and unlink other logins',
|
|
905
|
+
},
|
|
906
|
+
})
|
|
907
|
+
.input(
|
|
908
|
+
z.object({
|
|
909
|
+
/** Login token; falls back to the `X-Auth-Token` header like the rebind route. */
|
|
910
|
+
authToken: z.string().min(1).optional(),
|
|
911
|
+
providerType: AuthProviderTypeValidator,
|
|
912
|
+
primaryDid: z.string(),
|
|
913
|
+
authShare: ServerEncryptedShareValidator,
|
|
914
|
+
expectedShareVersion: z.number().int().positive(),
|
|
915
|
+
})
|
|
916
|
+
)
|
|
917
|
+
.output(
|
|
918
|
+
z.object({
|
|
919
|
+
success: z.literal(true),
|
|
920
|
+
shareVersion: z.number(),
|
|
921
|
+
recoveryMethodsRequireConfirmation: z.array(RecoveryMethodTypeValidator),
|
|
922
|
+
})
|
|
923
|
+
)
|
|
924
|
+
.mutation(async ({ input, ctx }) => {
|
|
925
|
+
const user = await verifyAuthToken(
|
|
926
|
+
input.authToken || ctx.providerToken || '',
|
|
927
|
+
input.providerType
|
|
928
|
+
);
|
|
929
|
+
const authProvider: AuthProviderMapping = { type: input.providerType, id: user.id };
|
|
930
|
+
const userKey = await requireUserKey(authProvider);
|
|
931
|
+
|
|
932
|
+
assertDidOwner(userKey, ctx.user.did);
|
|
933
|
+
if (input.primaryDid !== ctx.user.did) {
|
|
934
|
+
throw new TRPCError({
|
|
935
|
+
code: 'FORBIDDEN',
|
|
936
|
+
message: 'The authenticated DID does not match primaryDid.',
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const seed = environment.SEED;
|
|
941
|
+
if (!seed) {
|
|
942
|
+
throw new TRPCError({
|
|
943
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
944
|
+
message: 'Server configuration error.',
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const result = await revokeOtherDeviceShares({
|
|
949
|
+
authProvider,
|
|
950
|
+
primaryDid: input.primaryDid,
|
|
951
|
+
expectedShareVersion: input.expectedShareVersion,
|
|
952
|
+
authShare: encryptAuthShare(input.authShare, seed),
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
if (!result) {
|
|
956
|
+
throw new TRPCError({
|
|
957
|
+
code: 'CONFLICT',
|
|
958
|
+
message: `Key material changed; please retry (current version ${userKey.shareVersion ?? 1}).`,
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Clean-up only: pending holds already cannot release once the commit lands
|
|
963
|
+
// (`/complete` needs a confirmed escrow at the hold's version, and escrow is
|
|
964
|
+
// cleared). Best-effort, so a failure never reports the committed revoke as failed.
|
|
965
|
+
await cancelPendingEscrowHolds(userKey, ctx.tenant).catch(() =>
|
|
966
|
+
console.error('[revokeOtherDevices] Pending escrow hold clean-up failed')
|
|
967
|
+
);
|
|
968
|
+
// Fail-open after the commit: the owner may not be the one who ran this.
|
|
969
|
+
await notifyAccountSecured(userKey, ctx.tenant?.emailBranding, new Date());
|
|
970
|
+
|
|
971
|
+
return {
|
|
972
|
+
success: true as const,
|
|
973
|
+
shareVersion: result.shareVersion,
|
|
974
|
+
recoveryMethodsRequireConfirmation: result.methodsRequiringConfirmation,
|
|
975
|
+
};
|
|
976
|
+
}),
|
|
977
|
+
|
|
585
978
|
issueDidChallenge: didRoute
|
|
586
979
|
.meta({
|
|
587
980
|
openapi: {
|
|
@@ -643,6 +1036,20 @@ export const keysRouter = t.router({
|
|
|
643
1036
|
escrowOptedOut: z.boolean(),
|
|
644
1037
|
escrowPin: EscrowPinStatusValidator,
|
|
645
1038
|
escrowStale: z.enum(['mode-mismatch', 'key-rotated']).optional(),
|
|
1039
|
+
/**
|
|
1040
|
+
* Identity (blob hash) of the escrow copy lca-api holds. Authority-mode
|
|
1041
|
+
* clients compare it with the authority's envelope and repair a
|
|
1042
|
+
* mismatch instead of trusting a stale copy.
|
|
1043
|
+
*/
|
|
1044
|
+
escrowBlobIdentity: z
|
|
1045
|
+
.string()
|
|
1046
|
+
.regex(/^[0-9a-f]{64}$/)
|
|
1047
|
+
.optional(),
|
|
1048
|
+
/**
|
|
1049
|
+
* A PIN operation holds the account's lease: lca-api's copy may trail
|
|
1050
|
+
* the authority until it settles, so a mismatch is not yet stale.
|
|
1051
|
+
*/
|
|
1052
|
+
escrowUpdating: z.boolean().optional(),
|
|
646
1053
|
/**
|
|
647
1054
|
* The account has an escrow recovery method (any confirmation state)
|
|
648
1055
|
* but none confirmed at the current share version, e.g. `pending`
|
|
@@ -731,6 +1138,8 @@ export const keysRouter = t.router({
|
|
|
731
1138
|
escrowStale: userKey.escrowBlob
|
|
732
1139
|
? await getEscrowBlobStaleReason(userKey.escrowBlob)
|
|
733
1140
|
: undefined,
|
|
1141
|
+
...(userKey.escrowBlob ? { escrowBlobIdentity: userKey.escrowBlob.blobHash } : {}),
|
|
1142
|
+
...(userKey.escrowEnrollmentLease ? { escrowUpdating: true } : {}),
|
|
734
1143
|
priorEscrowEnrollment:
|
|
735
1144
|
escrowMethods.length > 0 &&
|
|
736
1145
|
!escrowMethods.some(
|
|
@@ -44,6 +44,8 @@ const LOCAL_TEMPLATE_ALIASES: Record<string, TemplateId> = {
|
|
|
44
44
|
'escrow-hold-released': 'escrow-hold-released',
|
|
45
45
|
'escrow-hold-cancelled': 'escrow-hold-cancelled',
|
|
46
46
|
'escrow-pin-locked': 'escrow-pin-locked',
|
|
47
|
+
'escrow-pin-sign-in': 'escrow-pin-sign-in',
|
|
48
|
+
'account-secured': 'account-secured',
|
|
47
49
|
};
|
|
48
50
|
|
|
49
51
|
/** Whether the given alias is a sentinel we registered (vs. a real Postmark alias). */
|
|
@@ -248,6 +250,15 @@ export class PostmarkAdapter implements DeliveryService {
|
|
|
248
250
|
cancelUrl: model.cancelUrl as string | undefined,
|
|
249
251
|
};
|
|
250
252
|
|
|
253
|
+
case 'escrow-pin-sign-in':
|
|
254
|
+
return {
|
|
255
|
+
signedInAt: (model.signedInAt as string) ?? '',
|
|
256
|
+
secureUrl: (model.secureUrl as string) ?? '',
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
case 'account-secured':
|
|
260
|
+
return { securedAt: (model.securedAt as string) ?? '' };
|
|
261
|
+
|
|
251
262
|
default:
|
|
252
263
|
return model as TemplateDataMap[TemplateId];
|
|
253
264
|
}
|
|
@@ -14,9 +14,14 @@ existing host-trusted model, but does not protect against a malicious host eithe
|
|
|
14
14
|
The API atomically reserves one of ten lifetime attempts before claiming the hold
|
|
15
15
|
and calling the enclave. Failed releases burn the single-use hold. Exhaustion
|
|
16
16
|
disables PIN recovery and cancels pending PIN holds; delayed recovery remains
|
|
17
|
-
available. Successful PIN recovery does not replenish the lifetime budget;
|
|
18
|
-
enrollment
|
|
19
|
-
|
|
17
|
+
available. Successful PIN recovery does not replenish the lifetime budget by itself;
|
|
18
|
+
authenticated enrollment of a PIN starts a fresh counter. A PIN sign-in (keep mode,
|
|
19
|
+
pending security approval; see `services/escrow-enclave-app/SECURITY.md` D19) re-enrolls
|
|
20
|
+
the same PIN at the same share version straight after the release, which starts a fresh
|
|
21
|
+
counter without a rotation, but only when the PIN status records no wrong attempt for the
|
|
22
|
+
current PIN; otherwise the client keeps the reduced budget and asks the user for a different
|
|
23
|
+
PIN. Otherwise, setting or changing a PIN requires client share
|
|
24
|
+
rotation. Inbox compromise plus a correct PIN guess can recover the
|
|
20
25
|
account (at most 10/1,000,000 for a uniformly random six-digit PIN).
|
|
21
26
|
|
|
22
27
|
Software hold records contain a non-cryptographic signature placeholder and no
|