@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/escrow.ts
CHANGED
|
@@ -5,7 +5,13 @@ import {
|
|
|
5
5
|
AuthorityHoldReceiptValidator,
|
|
6
6
|
AuthorityStateResponseValidator,
|
|
7
7
|
SignedEnrollmentIntentValidator,
|
|
8
|
+
type AuthorityMutationResult,
|
|
8
9
|
} from '../services/escrow-enclave/authorityContract';
|
|
10
|
+
import { escrowBlobIdentity } from '../services/escrow-enclave/blobIdentity';
|
|
11
|
+
import {
|
|
12
|
+
isDefiniteAuthorityRefusal,
|
|
13
|
+
settleEscrowEnrollmentLease,
|
|
14
|
+
} from '../services/escrow-enclave/enrollmentLease';
|
|
9
15
|
import { z } from 'zod';
|
|
10
16
|
import { TRPCError } from '@trpc/server';
|
|
11
17
|
import { ESCROW_PIN_MAX_ATTEMPTS } from '@learncard/sss-key-manager';
|
|
@@ -29,7 +35,13 @@ import {
|
|
|
29
35
|
EscrowBlobValidator,
|
|
30
36
|
EscrowPinSaltValidator,
|
|
31
37
|
reserveEscrowPinAttempt,
|
|
38
|
+
releaseEscrowPinCompare,
|
|
32
39
|
refundEscrowPinAttempt,
|
|
40
|
+
claimEscrowEnrollmentLease,
|
|
41
|
+
commitEscrowEnrollmentLease,
|
|
42
|
+
releaseEscrowEnrollmentLease,
|
|
43
|
+
ESCROW_ENROLLMENT_LEASE_TTL_MS,
|
|
44
|
+
type EscrowEnrollmentLease,
|
|
33
45
|
recordEscrowPinFailure,
|
|
34
46
|
disableEscrowPin,
|
|
35
47
|
markClaimedEscrowHoldFailed,
|
|
@@ -61,6 +73,7 @@ import {
|
|
|
61
73
|
type EscrowHold,
|
|
62
74
|
type MongoUserKeyType,
|
|
63
75
|
type AuthProviderMapping,
|
|
76
|
+
getSecurityEpoch,
|
|
64
77
|
} from '@models';
|
|
65
78
|
import {
|
|
66
79
|
AuthInputValidator,
|
|
@@ -69,7 +82,8 @@ import {
|
|
|
69
82
|
verifyAndGetContactMethod,
|
|
70
83
|
requireUserKey,
|
|
71
84
|
assertDidOwner,
|
|
72
|
-
|
|
85
|
+
requireRecoverySessionRecord,
|
|
86
|
+
getRecoverySessionEpoch,
|
|
73
87
|
} from './keys';
|
|
74
88
|
import {
|
|
75
89
|
getEscrowEnclave,
|
|
@@ -93,6 +107,9 @@ const invalidMessage = 'This recovery request is no longer valid.';
|
|
|
93
107
|
const staleEscrowMessage = 'Automatic recovery needs to be set up again on a signed-in device.';
|
|
94
108
|
// Keep throttling distinct: clients reserve the locked message for lifetime PIN exhaustion.
|
|
95
109
|
const pinThrottledMessage = 'Please wait before trying again.';
|
|
110
|
+
// A PIN-operation lease is held (TOO_MANY_REQUESTS: clients treat it as "try again").
|
|
111
|
+
const escrowBusyMessage = 'Automatic recovery is being updated. Please try again in a moment.';
|
|
112
|
+
const escrowChangedMessage = 'Automatic recovery changed; please refresh.';
|
|
96
113
|
|
|
97
114
|
// Follow qr-login's Redis INCR/EXPIRE per-IP limiter. keys.ts has only OTP-specific limits.
|
|
98
115
|
const limitPinCompletion = async (
|
|
@@ -350,14 +367,114 @@ const removalInput = AuthInputValidator.extend({
|
|
|
350
367
|
}).strict();
|
|
351
368
|
const removalOutput = successValidator.extend({ receipt: AuthorityCommitValidator.optional() });
|
|
352
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Settle a lease left behind by a crashed or lost request before the account is used
|
|
372
|
+
* again (the "next request touching the account" leg of the reconciliation). A live
|
|
373
|
+
* lease is returned untouched for the caller to refuse.
|
|
374
|
+
*/
|
|
375
|
+
const settleExpiredEscrowLease = async (
|
|
376
|
+
authProvider: AuthProviderMapping,
|
|
377
|
+
userKey: MongoUserKeyType
|
|
378
|
+
): Promise<MongoUserKeyType> => {
|
|
379
|
+
const lease = userKey.escrowEnrollmentLease;
|
|
380
|
+
if (!lease || lease.expiresAt > new Date()) return userKey;
|
|
381
|
+
await enclaveOperation(() =>
|
|
382
|
+
settleEscrowEnrollmentLease(authProvider, userKey.primaryDid, lease, {
|
|
383
|
+
releaseIfUncommitted: true,
|
|
384
|
+
})
|
|
385
|
+
);
|
|
386
|
+
return (await findUserKeyByAuthProvider(authProvider.type, authProvider.id)) ?? userKey;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
/** Refuse, before the authority is called, while another PIN operation holds the lease. */
|
|
390
|
+
const assertNoLiveEscrowLease = (userKey: MongoUserKeyType): void => {
|
|
391
|
+
if (userKey.escrowEnrollmentLease)
|
|
392
|
+
throw new TRPCError({ code: 'CONFLICT', message: escrowBusyMessage });
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Send an owner-signed intent to the authority under the account's PIN-operation
|
|
397
|
+
* lease and persist its result. The lease is claimed first (atomically conditioned on
|
|
398
|
+
* the blob and PIN the caller validated and on no PIN compare in flight), so no
|
|
399
|
+
* compare can advance the authority's operation revision while the intent is
|
|
400
|
+
* evaluated. The final write is conditioned on the lease, not on PIN charges. Any
|
|
401
|
+
* failure after the authority may have committed is reconciled against its state.
|
|
402
|
+
*/
|
|
403
|
+
const applyEnrollmentIntentUnderLease = async (input: {
|
|
404
|
+
authProvider: AuthProviderMapping;
|
|
405
|
+
userKey: MongoUserKeyType;
|
|
406
|
+
lease: Omit<EscrowEnrollmentLease, 'id' | 'expiresAt' | 'sourceBlobHash'>;
|
|
407
|
+
requireOptedIn: boolean;
|
|
408
|
+
apply: () => Promise<AuthorityMutationResult>;
|
|
409
|
+
}): Promise<AuthorityMutationResult> => {
|
|
410
|
+
const { authProvider, userKey } = input;
|
|
411
|
+
const lease: EscrowEnrollmentLease = {
|
|
412
|
+
...input.lease,
|
|
413
|
+
id: randomUUID(),
|
|
414
|
+
expiresAt: new Date(Date.now() + ESCROW_ENROLLMENT_LEASE_TTL_MS),
|
|
415
|
+
sourceBlobHash: userKey.escrowBlob?.blobHash ?? null,
|
|
416
|
+
};
|
|
417
|
+
const claimed = await claimEscrowEnrollmentLease(authProvider, {
|
|
418
|
+
lease,
|
|
419
|
+
expectedBlob: userKey.escrowBlob,
|
|
420
|
+
expectedPin: userKey.escrowPin,
|
|
421
|
+
requireOptedIn: input.requireOptedIn,
|
|
422
|
+
});
|
|
423
|
+
// A guess reserved or resolved since the read, a compare still in flight, or
|
|
424
|
+
// another lease: nothing has been sent to the authority.
|
|
425
|
+
if (!claimed) throw new TRPCError({ code: 'CONFLICT', message: escrowChangedMessage });
|
|
426
|
+
const result = await enclaveOperation(async () => {
|
|
427
|
+
try {
|
|
428
|
+
return await input.apply();
|
|
429
|
+
} catch (error) {
|
|
430
|
+
// A definite refusal never committed. Anything else (timeout, lost or
|
|
431
|
+
// malformed response) may have: adopt a visible commit, else keep the
|
|
432
|
+
// lease until it expires so a late commit is still settled. Even when the
|
|
433
|
+
// commit is adopted the request still fails: without the authority's
|
|
434
|
+
// receipt (it refuses a replayed request id) the client cannot verify it,
|
|
435
|
+
// and re-reads the authority's state instead.
|
|
436
|
+
if (isDefiniteAuthorityRefusal(error))
|
|
437
|
+
await releaseEscrowEnrollmentLease(authProvider, lease.id);
|
|
438
|
+
else
|
|
439
|
+
await settleEscrowEnrollmentLease(authProvider, userKey.primaryDid, lease, {
|
|
440
|
+
releaseIfUncommitted: false,
|
|
441
|
+
}).catch(() => undefined);
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
const committed = await commitEscrowEnrollmentLease(authProvider, lease, {
|
|
446
|
+
envelope: result.envelope,
|
|
447
|
+
authorityGeneration: result.authorityGeneration,
|
|
448
|
+
enrollmentVersion: result.enrollmentVersion,
|
|
449
|
+
enrollmentEpoch: result.enrollmentEpoch,
|
|
450
|
+
receipt: result.receipt,
|
|
451
|
+
}).catch(() => null);
|
|
452
|
+
if (committed) return result;
|
|
453
|
+
// The authority committed but the lease's write missed (lost lease, transient
|
|
454
|
+
// Mongo failure): converge to the authority rather than leave the old blob.
|
|
455
|
+
const settled = await settleEscrowEnrollmentLease(authProvider, userKey.primaryDid, lease, {
|
|
456
|
+
received: result,
|
|
457
|
+
releaseIfUncommitted: false,
|
|
458
|
+
}).catch(() => 'kept' as const);
|
|
459
|
+
if (settled === 'adopted') return result;
|
|
460
|
+
throw new TRPCError({
|
|
461
|
+
code: 'CONFLICT',
|
|
462
|
+
message: 'Automatic recovery changed; refresh its authoritative state.',
|
|
463
|
+
});
|
|
464
|
+
};
|
|
465
|
+
|
|
353
466
|
const removeEscrow = async (
|
|
354
467
|
input: z.infer<typeof removalInput>,
|
|
355
468
|
did: string,
|
|
356
469
|
tenant: Context['tenant']
|
|
357
470
|
): Promise<z.infer<typeof removalOutput>> => {
|
|
358
471
|
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
359
|
-
|
|
472
|
+
let userKey = await requireUserKey(authProvider);
|
|
360
473
|
assertDidOwner(userKey, did);
|
|
474
|
+
if (isEscrowRemoteMode()) {
|
|
475
|
+
userKey = await settleExpiredEscrowLease(authProvider, userKey);
|
|
476
|
+
assertNoLiveEscrowLease(userKey);
|
|
477
|
+
}
|
|
361
478
|
if (
|
|
362
479
|
input.optOut &&
|
|
363
480
|
!userKey.recoveryMethods.some(
|
|
@@ -387,30 +504,41 @@ const removeEscrow = async (
|
|
|
387
504
|
code: 'BAD_REQUEST',
|
|
388
505
|
message: 'Signed disable intent is required.',
|
|
389
506
|
});
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
507
|
+
const sourceEnvelope = input.sourceEnvelope ?? userKey.escrowBlob?.envelope ?? null;
|
|
508
|
+
// Same PIN-operation lease as enroll: a compare cannot land between the intent
|
|
509
|
+
// and the Mongo clear, and a lost result is reconciled from the authority.
|
|
510
|
+
const result = await applyEnrollmentIntentUnderLease({
|
|
511
|
+
authProvider,
|
|
512
|
+
userKey,
|
|
513
|
+
lease: {
|
|
514
|
+
action: 'disable',
|
|
515
|
+
pinAction: signed.intent.pinAction,
|
|
516
|
+
pin: 'clear',
|
|
517
|
+
optOut: input.optOut,
|
|
518
|
+
shareVersion: userKey.shareVersion,
|
|
519
|
+
authorityGeneration: signed.intent.authorityGeneration,
|
|
520
|
+
expectedEnrollmentVersion: signed.intent.expectedEnrollmentVersion,
|
|
521
|
+
enrollmentEpoch: signed.intent.enrollmentEpoch,
|
|
522
|
+
measurements: userKey.escrowBlob?.measurements ?? {},
|
|
523
|
+
},
|
|
524
|
+
requireOptedIn: false,
|
|
525
|
+
apply: () =>
|
|
526
|
+
enclave.applyEnrollmentIntent!({
|
|
527
|
+
signedIntent: signed,
|
|
528
|
+
sourceEnvelope,
|
|
529
|
+
targetEnvelope: null,
|
|
530
|
+
}),
|
|
531
|
+
});
|
|
397
532
|
receipt = result.receipt;
|
|
398
533
|
} else if (input.signedIntent)
|
|
399
534
|
throw new TRPCError({
|
|
400
535
|
code: 'PRECONDITION_FAILED',
|
|
401
536
|
message: 'Signed authority operations require remote mode.',
|
|
402
537
|
});
|
|
403
|
-
|
|
404
|
-
optOut: input.optOut
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
: {}),
|
|
408
|
-
});
|
|
409
|
-
if (!cleared)
|
|
410
|
-
throw new TRPCError({
|
|
411
|
-
code: 'CONFLICT',
|
|
412
|
-
message: 'Automatic recovery changed; please refresh.',
|
|
413
|
-
});
|
|
538
|
+
else {
|
|
539
|
+
const cleared = await clearEscrowByAuthProvider(authProvider, { optOut: input.optOut });
|
|
540
|
+
if (!cleared) throw new TRPCError({ code: 'CONFLICT', message: escrowChangedMessage });
|
|
541
|
+
}
|
|
414
542
|
for (const policy of ['hold', 'pin'] as const) {
|
|
415
543
|
const pending = await findPendingEscrowHoldByAuthProvider(authProvider, policy);
|
|
416
544
|
const cancelled = pending && (await cancelEscrowHold(pending._id, 'did'));
|
|
@@ -422,6 +550,44 @@ const removeEscrow = async (
|
|
|
422
550
|
return { success: true, ...(receipt ? { receipt } : {}) };
|
|
423
551
|
};
|
|
424
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Validate a same-PIN refresh's `expectedPinCharges` (E) against the current PIN and
|
|
555
|
+
* convert it to the stored counters for the write's compare-and-set. Rejected with
|
|
556
|
+
* CONFLICT when a wrong entry or another reservation has landed since the client's
|
|
557
|
+
* check, so the refresh never wipes a recorded or in-flight guess.
|
|
558
|
+
*/
|
|
559
|
+
const resolveExpectedPinCharges = (
|
|
560
|
+
input: {
|
|
561
|
+
pinSalt?: string | undefined;
|
|
562
|
+
clearPin?: boolean | undefined;
|
|
563
|
+
expectedPinCharges?: { attemptsRemaining: number; wrongAttempts: number } | undefined;
|
|
564
|
+
},
|
|
565
|
+
userKey: MongoUserKeyType
|
|
566
|
+
): { failedAttempts: number; verifiedFailedAttempts: number } | undefined => {
|
|
567
|
+
const expected = input.expectedPinCharges;
|
|
568
|
+
if (!expected) return undefined;
|
|
569
|
+
if (!input.pinSalt || input.clearPin)
|
|
570
|
+
throw new TRPCError({
|
|
571
|
+
code: 'BAD_REQUEST',
|
|
572
|
+
message: 'Invalid automatic recovery material.',
|
|
573
|
+
});
|
|
574
|
+
const pin = userKey.escrowPin;
|
|
575
|
+
const failedAttempts = ESCROW_PIN_MAX_ATTEMPTS - expected.attemptsRemaining;
|
|
576
|
+
if (
|
|
577
|
+
expected.wrongAttempts !== 0 ||
|
|
578
|
+
failedAttempts < 0 ||
|
|
579
|
+
!pin ||
|
|
580
|
+
pin.disabledAt ||
|
|
581
|
+
pin.failedAttempts !== failedAttempts ||
|
|
582
|
+
pin.verifiedFailedAttempts !== expected.wrongAttempts
|
|
583
|
+
)
|
|
584
|
+
throw new TRPCError({
|
|
585
|
+
code: 'CONFLICT',
|
|
586
|
+
message: 'Recovery PIN attempts changed; please retry.',
|
|
587
|
+
});
|
|
588
|
+
return { failedAttempts, verifiedFailedAttempts: expected.wrongAttempts };
|
|
589
|
+
};
|
|
590
|
+
|
|
425
591
|
export const escrowRouter = t.router({
|
|
426
592
|
getAttestation: openRoute
|
|
427
593
|
.meta({ openapi: { method: 'GET', path: '/keys/escrow/attestation', tags: ['Keys'] } })
|
|
@@ -479,6 +645,9 @@ export const escrowRouter = t.router({
|
|
|
479
645
|
const enclave = getEscrowEnclave();
|
|
480
646
|
if (!isEscrowRemoteMode() || !enclave.getEnrollmentState)
|
|
481
647
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: unavailableMessage });
|
|
648
|
+
// Clients compare this state with Mongo's blob; settle a crashed lease first so
|
|
649
|
+
// they see the converged copy. Best effort: the state read below still decides.
|
|
650
|
+
await settleExpiredEscrowLease(authProvider, userKey).catch(() => undefined);
|
|
482
651
|
const result = await enclaveOperation(() =>
|
|
483
652
|
enclave.getEnrollmentState!({ expectedDid: ctx.user.did, nonce: input.nonce })
|
|
484
653
|
);
|
|
@@ -498,6 +667,19 @@ export const escrowRouter = t.router({
|
|
|
498
667
|
clearPin: z.boolean().optional(),
|
|
499
668
|
signedIntent: SignedEnrollmentIntentValidator.optional(),
|
|
500
669
|
sourceEnvelope: EscrowEnvelopeValidator.nullable().optional(),
|
|
670
|
+
/**
|
|
671
|
+
* Same-PIN budget refresh after a PIN sign-in (E): the PIN status the
|
|
672
|
+
* client saw right after its release. The new PIN is written only while
|
|
673
|
+
* the current PIN still shows exactly these charges (no wrong entry and
|
|
674
|
+
* no other reservation since), otherwise CONFLICT.
|
|
675
|
+
*/
|
|
676
|
+
expectedPinCharges: z
|
|
677
|
+
.object({
|
|
678
|
+
attemptsRemaining: z.number().int().nonnegative(),
|
|
679
|
+
wrongAttempts: z.number().int().nonnegative(),
|
|
680
|
+
})
|
|
681
|
+
.strict()
|
|
682
|
+
.optional(),
|
|
501
683
|
}).strict()
|
|
502
684
|
)
|
|
503
685
|
.output(
|
|
@@ -509,8 +691,12 @@ export const escrowRouter = t.router({
|
|
|
509
691
|
)
|
|
510
692
|
.mutation(async ({ input, ctx }) => {
|
|
511
693
|
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
512
|
-
|
|
694
|
+
let userKey = await requireUserKey(authProvider);
|
|
513
695
|
assertDidOwner(userKey, ctx.user.did);
|
|
696
|
+
if (isEscrowRemoteMode()) {
|
|
697
|
+
userKey = await settleExpiredEscrowLease(authProvider, userKey);
|
|
698
|
+
assertNoLiveEscrowLease(userKey);
|
|
699
|
+
}
|
|
514
700
|
if (userKey.escrowOptedOutAt) {
|
|
515
701
|
throw new TRPCError({
|
|
516
702
|
code: 'CONFLICT',
|
|
@@ -523,6 +709,7 @@ export const escrowRouter = t.router({
|
|
|
523
709
|
message: 'Key material changed; please retry.',
|
|
524
710
|
});
|
|
525
711
|
}
|
|
712
|
+
const expectedPinCharges = resolveExpectedPinCharges(input, userKey);
|
|
526
713
|
if (isEscrowRemoteMode()) {
|
|
527
714
|
const enclave = getEscrowEnclave();
|
|
528
715
|
const signed = input.signedIntent;
|
|
@@ -574,47 +761,40 @@ export const escrowRouter = t.router({
|
|
|
574
761
|
input.enclaveKeyId !== attestation.keyId
|
|
575
762
|
)
|
|
576
763
|
throw new TRPCError({ code: 'BAD_REQUEST', message: invalidMessage });
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
? current.state.envelope
|
|
583
|
-
: input.sourceEnvelope,
|
|
584
|
-
targetEnvelope: input.envelope,
|
|
585
|
-
})
|
|
586
|
-
);
|
|
587
|
-
if (!result.envelope)
|
|
588
|
-
throw new TRPCError({
|
|
589
|
-
code: 'SERVICE_UNAVAILABLE',
|
|
590
|
-
message: unavailableMessage,
|
|
591
|
-
});
|
|
592
|
-
const stored = await setEscrowBlobByAuthProvider(
|
|
764
|
+
// The lease is claimed on the blob and PIN read above, which
|
|
765
|
+
// `resolveExpectedPinCharges` validated: a same-PIN refresh whose charges
|
|
766
|
+
// moved since, or that races a compare still in flight, is refused here
|
|
767
|
+
// with nothing sent to the authority.
|
|
768
|
+
const result = await applyEnrollmentIntentUnderLease({
|
|
593
769
|
authProvider,
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
770
|
+
userKey,
|
|
771
|
+
lease: {
|
|
772
|
+
action: intent.action,
|
|
773
|
+
pinAction: intent.pinAction,
|
|
774
|
+
pin: input.pinSalt ? 'fresh' : preservePin ? 'keep' : 'clear',
|
|
775
|
+
...(input.pinSalt ? { pinSalt: input.pinSalt } : {}),
|
|
599
776
|
shareVersion: input.shareVersion,
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
777
|
+
authorityGeneration: current.state.authorityGeneration,
|
|
778
|
+
expectedEnrollmentVersion: current.state.enrollmentVersion,
|
|
779
|
+
expectedPinGeneration: current.state.pinGeneration,
|
|
780
|
+
enrollmentEpoch: intent.enrollmentEpoch,
|
|
781
|
+
measurements: attestation.measurements,
|
|
604
782
|
},
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
783
|
+
requireOptedIn: true,
|
|
784
|
+
apply: () =>
|
|
785
|
+
enclave.applyEnrollmentIntent!({
|
|
786
|
+
signedIntent: signed,
|
|
787
|
+
sourceEnvelope:
|
|
788
|
+
input.sourceEnvelope === undefined
|
|
789
|
+
? current.state.envelope
|
|
790
|
+
: input.sourceEnvelope,
|
|
791
|
+
targetEnvelope: input.envelope,
|
|
792
|
+
}),
|
|
793
|
+
});
|
|
794
|
+
if (!result.envelope)
|
|
615
795
|
throw new TRPCError({
|
|
616
|
-
code: '
|
|
617
|
-
message:
|
|
796
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
797
|
+
message: unavailableMessage,
|
|
618
798
|
});
|
|
619
799
|
return {
|
|
620
800
|
success: true as const,
|
|
@@ -680,7 +860,11 @@ export const escrowRouter = t.router({
|
|
|
680
860
|
(attestation.previousKeyIds ?? []).includes(oldBlob.enclaveKeyId)) &&
|
|
681
861
|
input.shareVersion > oldBlob.shareVersion &&
|
|
682
862
|
// A released blob's PIN is retired: it was just used, or the user forgot it.
|
|
683
|
-
|
|
863
|
+
// Releases before the PIN was (re-)set do not count: PIN sign-in keeps the
|
|
864
|
+
// share version and re-sets the PIN, and that fresh PIN must carry.
|
|
865
|
+
!(await hasCompletedEscrowHoldForVersion(authProvider, oldBlob.shareVersion, {
|
|
866
|
+
completedAfter: oldPin.enabledAt,
|
|
867
|
+
}))
|
|
684
868
|
) {
|
|
685
869
|
try {
|
|
686
870
|
const carried = await getEscrowEnclave().carryPinVerifier({
|
|
@@ -714,7 +898,10 @@ export const escrowRouter = t.router({
|
|
|
714
898
|
authProvider,
|
|
715
899
|
blob,
|
|
716
900
|
input.shareVersion,
|
|
717
|
-
input.pinSalt ? { salt: input.pinSalt } : undefined
|
|
901
|
+
input.pinSalt ? { salt: input.pinSalt } : undefined,
|
|
902
|
+
undefined,
|
|
903
|
+
undefined,
|
|
904
|
+
expectedPinCharges
|
|
718
905
|
);
|
|
719
906
|
if (!stored)
|
|
720
907
|
throw new TRPCError({
|
|
@@ -780,13 +967,17 @@ export const escrowRouter = t.router({
|
|
|
780
967
|
});
|
|
781
968
|
}
|
|
782
969
|
let authProvider: AuthProviderMapping;
|
|
970
|
+
// Epoch of the recovery session, when started from one (lost-login recovery).
|
|
971
|
+
let recoverSessionEpoch: number | undefined;
|
|
783
972
|
if (input.releasePolicy === 'pin') await limitPinCompletion(ctx.clientIp, 'start');
|
|
784
973
|
if (input.recoverySessionToken !== undefined) {
|
|
785
974
|
try {
|
|
786
|
-
|
|
975
|
+
const session = await requireRecoverySessionRecord(
|
|
787
976
|
input.recoverySessionToken,
|
|
788
977
|
'recover'
|
|
789
978
|
);
|
|
979
|
+
authProvider = session.authProvider;
|
|
980
|
+
recoverSessionEpoch = getRecoverySessionEpoch(session);
|
|
790
981
|
} catch (error) {
|
|
791
982
|
if (!(error instanceof TRPCError) || error.code !== 'UNAUTHORIZED') throw error;
|
|
792
983
|
throw new TRPCError({ code: 'NOT_FOUND', message: unavailableMessage });
|
|
@@ -797,14 +988,25 @@ export const escrowRouter = t.router({
|
|
|
797
988
|
providerType: input.providerType!,
|
|
798
989
|
}));
|
|
799
990
|
}
|
|
800
|
-
|
|
991
|
+
let userKey = await findUserKeyByAuthProvider(authProvider.type, authProvider.id);
|
|
992
|
+
// A crashed PIN operation may have committed a new copy at the authority: adopt
|
|
993
|
+
// it before a hold is bound to the old one.
|
|
994
|
+
if (userKey && isEscrowRemoteMode())
|
|
995
|
+
userKey = await settleExpiredEscrowLease(authProvider, userKey);
|
|
801
996
|
if (
|
|
802
997
|
!userKey?.escrowBlob ||
|
|
803
998
|
userKey.escrowOptedOutAt ||
|
|
804
|
-
!hasConfirmedEscrow(userKey, userKey.escrowBlob.shareVersion)
|
|
999
|
+
!hasConfirmedEscrow(userKey, userKey.escrowBlob.shareVersion) ||
|
|
1000
|
+
// A recovery session minted before a revoke or wiping rebind is stale.
|
|
1001
|
+
(recoverSessionEpoch !== undefined &&
|
|
1002
|
+
recoverSessionEpoch !== getSecurityEpoch(userKey))
|
|
805
1003
|
) {
|
|
806
1004
|
throw new TRPCError({ code: 'NOT_FOUND', message: unavailableMessage });
|
|
807
1005
|
}
|
|
1006
|
+
// A PIN operation is at the authority: a hold created now would be bound to
|
|
1007
|
+
// the copy its intent is replacing. Retryable, nothing created.
|
|
1008
|
+
if (userKey.escrowEnrollmentLease)
|
|
1009
|
+
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: escrowBusyMessage });
|
|
808
1010
|
if (await hasUnconfirmedEscrowCancellation(userKey.primaryDid)) {
|
|
809
1011
|
throw new TRPCError({
|
|
810
1012
|
code: 'PRECONDITION_FAILED',
|
|
@@ -1198,10 +1400,12 @@ export const escrowRouter = t.router({
|
|
|
1198
1400
|
message: 'This recovery request is still in its waiting period.',
|
|
1199
1401
|
});
|
|
1200
1402
|
}
|
|
1201
|
-
|
|
1403
|
+
let userKey = await findUserKeyByAuthProvider(
|
|
1202
1404
|
hold.authProvider.type,
|
|
1203
1405
|
hold.authProvider.id
|
|
1204
1406
|
);
|
|
1407
|
+
if (userKey && isEscrowRemoteMode())
|
|
1408
|
+
userKey = await settleExpiredEscrowLease(hold.authProvider, userKey);
|
|
1205
1409
|
if (
|
|
1206
1410
|
!userKey?.escrowBlob ||
|
|
1207
1411
|
userKey.escrowOptedOutAt ||
|
|
@@ -1210,25 +1414,57 @@ export const escrowRouter = t.router({
|
|
|
1210
1414
|
) {
|
|
1211
1415
|
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
1212
1416
|
}
|
|
1417
|
+
// A PIN operation is at the authority: comparing now would move its operation
|
|
1418
|
+
// revision under the intent. Nothing is charged; the client retries.
|
|
1419
|
+
if (hold.releasePolicy === 'pin' && userKey.escrowEnrollmentLease)
|
|
1420
|
+
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: escrowBusyMessage });
|
|
1421
|
+
// The authority binds a hold to the exact copy it was created on and refuses
|
|
1422
|
+
// it against any other (a PIN operation committed since the hold started).
|
|
1423
|
+
// Refuse before reserving, so a stale hold never charges a guess Mongo-side
|
|
1424
|
+
// that the authority never counts.
|
|
1425
|
+
if (
|
|
1426
|
+
isEscrowRemoteMode() &&
|
|
1427
|
+
hold.releasePolicy === 'pin' &&
|
|
1428
|
+
hold.holdRecord.hold.blobHash !== escrowBlobIdentity(userKey.escrowBlob.envelope)
|
|
1429
|
+
)
|
|
1430
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
1213
1431
|
await assertFreshEscrowBlob(userKey.escrowBlob);
|
|
1214
1432
|
const encryptedAuthShare = findAuthShareByVersion(userKey, hold.shareVersion);
|
|
1215
1433
|
if (!encryptedAuthShare || !environment.SEED) {
|
|
1216
1434
|
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
1217
1435
|
}
|
|
1218
1436
|
const expectedCiphertext = userKey.escrowBlob.envelope.ciphertext;
|
|
1437
|
+
// Marks this compare in flight until the authority answers, so no
|
|
1438
|
+
// PIN-operation lease can be claimed under it.
|
|
1439
|
+
const compareId = randomUUID();
|
|
1219
1440
|
const reserved =
|
|
1220
1441
|
hold.releasePolicy === 'pin'
|
|
1221
1442
|
? await reserveEscrowPinAttempt(
|
|
1222
1443
|
hold.authProvider,
|
|
1223
1444
|
hold.shareVersion,
|
|
1224
|
-
expectedCiphertext
|
|
1445
|
+
expectedCiphertext,
|
|
1446
|
+
compareId
|
|
1225
1447
|
)
|
|
1226
1448
|
: undefined;
|
|
1449
|
+
// Best effort: the authority has answered by now, so a failed release must not
|
|
1450
|
+
// replace the route's own outcome or skip its cleanup. The marker's TTL
|
|
1451
|
+
// stops it blocking a lease for longer than that.
|
|
1452
|
+
const releaseCompare = async (): Promise<void> => {
|
|
1453
|
+
if (reserved)
|
|
1454
|
+
await releaseEscrowPinCompare(hold.authProvider, compareId).catch(
|
|
1455
|
+
() => undefined
|
|
1456
|
+
);
|
|
1457
|
+
};
|
|
1227
1458
|
if (reserved === null) {
|
|
1228
1459
|
const current = await findUserKeyByAuthProvider(
|
|
1229
1460
|
hold.authProvider.type,
|
|
1230
1461
|
hold.authProvider.id
|
|
1231
1462
|
);
|
|
1463
|
+
if (current?.escrowEnrollmentLease)
|
|
1464
|
+
throw new TRPCError({
|
|
1465
|
+
code: 'TOO_MANY_REQUESTS',
|
|
1466
|
+
message: escrowBusyMessage,
|
|
1467
|
+
});
|
|
1232
1468
|
if (
|
|
1233
1469
|
!current?.escrowPin ||
|
|
1234
1470
|
current.escrowPin.disabledAt ||
|
|
@@ -1281,12 +1517,14 @@ export const escrowRouter = t.router({
|
|
|
1281
1517
|
} catch (error) {
|
|
1282
1518
|
// No enclave evaluation occurred, including when Mongo throws rather
|
|
1283
1519
|
// than returning a lost CAS. Refund before any fallible hold cleanup.
|
|
1284
|
-
if (reserved)
|
|
1520
|
+
if (reserved) {
|
|
1285
1521
|
await refundEscrowPinAttempt(
|
|
1286
1522
|
hold.authProvider,
|
|
1287
1523
|
hold.shareVersion,
|
|
1288
1524
|
expectedCiphertext
|
|
1289
1525
|
);
|
|
1526
|
+
await releaseCompare();
|
|
1527
|
+
}
|
|
1290
1528
|
if (completed) {
|
|
1291
1529
|
await markClaimedEscrowHoldFailed(
|
|
1292
1530
|
hold._id,
|
|
@@ -1323,6 +1561,9 @@ export const escrowRouter = t.router({
|
|
|
1323
1561
|
hold.shareVersion,
|
|
1324
1562
|
expectedCiphertext
|
|
1325
1563
|
);
|
|
1564
|
+
// A transport failure may still reach the authority: keep the
|
|
1565
|
+
// compare marked until its TTL. Any answer settles it now.
|
|
1566
|
+
if (!(error instanceof EscrowUnavailableError)) await releaseCompare();
|
|
1326
1567
|
// Policy includes "not yet releasable"; time/ledger/transport
|
|
1327
1568
|
// refusals map to unavailable. Only malformed blobs are terminal.
|
|
1328
1569
|
// Retries still require the enclave's single-use ledger.
|
|
@@ -1345,6 +1586,7 @@ export const escrowRouter = t.router({
|
|
|
1345
1586
|
hold.shareVersion,
|
|
1346
1587
|
expectedCiphertext
|
|
1347
1588
|
);
|
|
1589
|
+
await releaseCompare();
|
|
1348
1590
|
const attemptsRemaining =
|
|
1349
1591
|
ESCROW_PIN_MAX_ATTEMPTS -
|
|
1350
1592
|
(failed?.escrowPin?.failedAttempts ?? reserved!.escrowPin!.failedAttempts);
|
|
@@ -1377,6 +1619,7 @@ export const escrowRouter = t.router({
|
|
|
1377
1619
|
message: escrowPinMismatchMessage(attemptsRemaining),
|
|
1378
1620
|
});
|
|
1379
1621
|
}
|
|
1622
|
+
await releaseCompare();
|
|
1380
1623
|
const { sealed } = release.result;
|
|
1381
1624
|
if (release.result.receipt)
|
|
1382
1625
|
await recordEscrowReleaseReceipt(hold._id, release.result.receipt);
|
|
@@ -1388,9 +1631,25 @@ export const escrowRouter = t.router({
|
|
|
1388
1631
|
const rebindSessionToken = await createRecoverySession({
|
|
1389
1632
|
scope: 'rebind',
|
|
1390
1633
|
authProvider: hold.authProvider,
|
|
1634
|
+
// Bound to the epoch read above for this release, for every policy: a
|
|
1635
|
+
// revoke or wiping rebind after this point makes the token unusable.
|
|
1636
|
+
securityEpoch: getSecurityEpoch(userKey),
|
|
1637
|
+
// Only a PIN release may sign in without rotating (keys.completePinSignIn).
|
|
1638
|
+
...(hold.releasePolicy === 'pin'
|
|
1639
|
+
? {
|
|
1640
|
+
origin: 'pin-release' as const,
|
|
1641
|
+
primaryDid: userKey.primaryDid,
|
|
1642
|
+
shareVersion: hold.shareVersion,
|
|
1643
|
+
}
|
|
1644
|
+
: {}),
|
|
1391
1645
|
});
|
|
1646
|
+
// A PIN release signs a new device in without signing anything out, so the
|
|
1647
|
+
// owner gets the "Not you? Secure your account" notice instead of the plain
|
|
1648
|
+
// recovery confirmation. Sent here, at the only point the server sees a
|
|
1649
|
+
// successful PIN proof, so the client can neither skip nor forge it. Fail-open
|
|
1650
|
+
// with a bounded budget (notifyEscrowHoldEvent never throws).
|
|
1392
1651
|
await notifyEscrowHoldEvent({
|
|
1393
|
-
kind: 'completed',
|
|
1652
|
+
kind: hold.releasePolicy === 'pin' ? 'pin-sign-in' : 'completed',
|
|
1394
1653
|
hold: completed,
|
|
1395
1654
|
userKey,
|
|
1396
1655
|
tenant: ctx.tenant,
|