@learncard/lca-api-service 1.6.1 → 1.6.3
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 +35 -0
- package/dist/app.d.ts +41 -0
- package/dist/cache/recoverySessions.d.ts +5 -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 +10 -10
- 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 +17 -3
- package/src/models/UserKey.escrowPin.test.ts +21 -0
- package/src/models/UserKey.ts +540 -48
- package/src/routes/escrow.ts +337 -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/keys-escrow.spec.ts +22 -0
- package/test/keys-pin-lease.spec.ts +1132 -0
- package/test/keys-pin-sign-in.spec.ts +1184 -0
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,49 @@ 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
|
+
* `wrongAttempts` may be non-zero (own-typos rule, approved 2026-11-03): the client
|
|
560
|
+
* sends it only when every recorded wrong entry is one it submitted itself in the same
|
|
561
|
+
* sign-in flow. lca-api cannot tell whose entries they are; it only guarantees that
|
|
562
|
+
* nothing changed since the client's check, so a foreign entry still blocks the write.
|
|
563
|
+
*/
|
|
564
|
+
const resolveExpectedPinCharges = (
|
|
565
|
+
input: {
|
|
566
|
+
pinSalt?: string | undefined;
|
|
567
|
+
clearPin?: boolean | undefined;
|
|
568
|
+
expectedPinCharges?: { attemptsRemaining: number; wrongAttempts: number } | undefined;
|
|
569
|
+
},
|
|
570
|
+
userKey: MongoUserKeyType
|
|
571
|
+
): { failedAttempts: number; verifiedFailedAttempts: number } | undefined => {
|
|
572
|
+
const expected = input.expectedPinCharges;
|
|
573
|
+
if (!expected) return undefined;
|
|
574
|
+
if (!input.pinSalt || input.clearPin)
|
|
575
|
+
throw new TRPCError({
|
|
576
|
+
code: 'BAD_REQUEST',
|
|
577
|
+
message: 'Invalid automatic recovery material.',
|
|
578
|
+
});
|
|
579
|
+
const pin = userKey.escrowPin;
|
|
580
|
+
const failedAttempts = ESCROW_PIN_MAX_ATTEMPTS - expected.attemptsRemaining;
|
|
581
|
+
if (
|
|
582
|
+
failedAttempts < 0 ||
|
|
583
|
+
expected.wrongAttempts > failedAttempts ||
|
|
584
|
+
!pin ||
|
|
585
|
+
pin.disabledAt ||
|
|
586
|
+
pin.failedAttempts !== failedAttempts ||
|
|
587
|
+
pin.verifiedFailedAttempts !== expected.wrongAttempts
|
|
588
|
+
)
|
|
589
|
+
throw new TRPCError({
|
|
590
|
+
code: 'CONFLICT',
|
|
591
|
+
message: 'Recovery PIN attempts changed; please retry.',
|
|
592
|
+
});
|
|
593
|
+
return { failedAttempts, verifiedFailedAttempts: expected.wrongAttempts };
|
|
594
|
+
};
|
|
595
|
+
|
|
425
596
|
export const escrowRouter = t.router({
|
|
426
597
|
getAttestation: openRoute
|
|
427
598
|
.meta({ openapi: { method: 'GET', path: '/keys/escrow/attestation', tags: ['Keys'] } })
|
|
@@ -479,6 +650,9 @@ export const escrowRouter = t.router({
|
|
|
479
650
|
const enclave = getEscrowEnclave();
|
|
480
651
|
if (!isEscrowRemoteMode() || !enclave.getEnrollmentState)
|
|
481
652
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: unavailableMessage });
|
|
653
|
+
// Clients compare this state with Mongo's blob; settle a crashed lease first so
|
|
654
|
+
// they see the converged copy. Best effort: the state read below still decides.
|
|
655
|
+
await settleExpiredEscrowLease(authProvider, userKey).catch(() => undefined);
|
|
482
656
|
const result = await enclaveOperation(() =>
|
|
483
657
|
enclave.getEnrollmentState!({ expectedDid: ctx.user.did, nonce: input.nonce })
|
|
484
658
|
);
|
|
@@ -498,6 +672,19 @@ export const escrowRouter = t.router({
|
|
|
498
672
|
clearPin: z.boolean().optional(),
|
|
499
673
|
signedIntent: SignedEnrollmentIntentValidator.optional(),
|
|
500
674
|
sourceEnvelope: EscrowEnvelopeValidator.nullable().optional(),
|
|
675
|
+
/**
|
|
676
|
+
* Same-PIN budget refresh after a PIN sign-in (E): the PIN status the
|
|
677
|
+
* client saw right after its release. The new PIN is written only while
|
|
678
|
+
* the current PIN still shows exactly these charges (no wrong entry and
|
|
679
|
+
* no other reservation since), otherwise CONFLICT.
|
|
680
|
+
*/
|
|
681
|
+
expectedPinCharges: z
|
|
682
|
+
.object({
|
|
683
|
+
attemptsRemaining: z.number().int().nonnegative(),
|
|
684
|
+
wrongAttempts: z.number().int().nonnegative(),
|
|
685
|
+
})
|
|
686
|
+
.strict()
|
|
687
|
+
.optional(),
|
|
501
688
|
}).strict()
|
|
502
689
|
)
|
|
503
690
|
.output(
|
|
@@ -509,8 +696,12 @@ export const escrowRouter = t.router({
|
|
|
509
696
|
)
|
|
510
697
|
.mutation(async ({ input, ctx }) => {
|
|
511
698
|
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
512
|
-
|
|
699
|
+
let userKey = await requireUserKey(authProvider);
|
|
513
700
|
assertDidOwner(userKey, ctx.user.did);
|
|
701
|
+
if (isEscrowRemoteMode()) {
|
|
702
|
+
userKey = await settleExpiredEscrowLease(authProvider, userKey);
|
|
703
|
+
assertNoLiveEscrowLease(userKey);
|
|
704
|
+
}
|
|
514
705
|
if (userKey.escrowOptedOutAt) {
|
|
515
706
|
throw new TRPCError({
|
|
516
707
|
code: 'CONFLICT',
|
|
@@ -523,6 +714,7 @@ export const escrowRouter = t.router({
|
|
|
523
714
|
message: 'Key material changed; please retry.',
|
|
524
715
|
});
|
|
525
716
|
}
|
|
717
|
+
const expectedPinCharges = resolveExpectedPinCharges(input, userKey);
|
|
526
718
|
if (isEscrowRemoteMode()) {
|
|
527
719
|
const enclave = getEscrowEnclave();
|
|
528
720
|
const signed = input.signedIntent;
|
|
@@ -574,47 +766,40 @@ export const escrowRouter = t.router({
|
|
|
574
766
|
input.enclaveKeyId !== attestation.keyId
|
|
575
767
|
)
|
|
576
768
|
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(
|
|
769
|
+
// The lease is claimed on the blob and PIN read above, which
|
|
770
|
+
// `resolveExpectedPinCharges` validated: a same-PIN refresh whose charges
|
|
771
|
+
// moved since, or that races a compare still in flight, is refused here
|
|
772
|
+
// with nothing sent to the authority.
|
|
773
|
+
const result = await applyEnrollmentIntentUnderLease({
|
|
593
774
|
authProvider,
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
775
|
+
userKey,
|
|
776
|
+
lease: {
|
|
777
|
+
action: intent.action,
|
|
778
|
+
pinAction: intent.pinAction,
|
|
779
|
+
pin: input.pinSalt ? 'fresh' : preservePin ? 'keep' : 'clear',
|
|
780
|
+
...(input.pinSalt ? { pinSalt: input.pinSalt } : {}),
|
|
599
781
|
shareVersion: input.shareVersion,
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
782
|
+
authorityGeneration: current.state.authorityGeneration,
|
|
783
|
+
expectedEnrollmentVersion: current.state.enrollmentVersion,
|
|
784
|
+
expectedPinGeneration: current.state.pinGeneration,
|
|
785
|
+
enrollmentEpoch: intent.enrollmentEpoch,
|
|
786
|
+
measurements: attestation.measurements,
|
|
604
787
|
},
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
788
|
+
requireOptedIn: true,
|
|
789
|
+
apply: () =>
|
|
790
|
+
enclave.applyEnrollmentIntent!({
|
|
791
|
+
signedIntent: signed,
|
|
792
|
+
sourceEnvelope:
|
|
793
|
+
input.sourceEnvelope === undefined
|
|
794
|
+
? current.state.envelope
|
|
795
|
+
: input.sourceEnvelope,
|
|
796
|
+
targetEnvelope: input.envelope,
|
|
797
|
+
}),
|
|
798
|
+
});
|
|
799
|
+
if (!result.envelope)
|
|
615
800
|
throw new TRPCError({
|
|
616
|
-
code: '
|
|
617
|
-
message:
|
|
801
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
802
|
+
message: unavailableMessage,
|
|
618
803
|
});
|
|
619
804
|
return {
|
|
620
805
|
success: true as const,
|
|
@@ -680,7 +865,11 @@ export const escrowRouter = t.router({
|
|
|
680
865
|
(attestation.previousKeyIds ?? []).includes(oldBlob.enclaveKeyId)) &&
|
|
681
866
|
input.shareVersion > oldBlob.shareVersion &&
|
|
682
867
|
// A released blob's PIN is retired: it was just used, or the user forgot it.
|
|
683
|
-
|
|
868
|
+
// Releases before the PIN was (re-)set do not count: PIN sign-in keeps the
|
|
869
|
+
// share version and re-sets the PIN, and that fresh PIN must carry.
|
|
870
|
+
!(await hasCompletedEscrowHoldForVersion(authProvider, oldBlob.shareVersion, {
|
|
871
|
+
completedAfter: oldPin.enabledAt,
|
|
872
|
+
}))
|
|
684
873
|
) {
|
|
685
874
|
try {
|
|
686
875
|
const carried = await getEscrowEnclave().carryPinVerifier({
|
|
@@ -714,7 +903,10 @@ export const escrowRouter = t.router({
|
|
|
714
903
|
authProvider,
|
|
715
904
|
blob,
|
|
716
905
|
input.shareVersion,
|
|
717
|
-
input.pinSalt ? { salt: input.pinSalt } : undefined
|
|
906
|
+
input.pinSalt ? { salt: input.pinSalt } : undefined,
|
|
907
|
+
undefined,
|
|
908
|
+
undefined,
|
|
909
|
+
expectedPinCharges
|
|
718
910
|
);
|
|
719
911
|
if (!stored)
|
|
720
912
|
throw new TRPCError({
|
|
@@ -780,13 +972,17 @@ export const escrowRouter = t.router({
|
|
|
780
972
|
});
|
|
781
973
|
}
|
|
782
974
|
let authProvider: AuthProviderMapping;
|
|
975
|
+
// Epoch of the recovery session, when started from one (lost-login recovery).
|
|
976
|
+
let recoverSessionEpoch: number | undefined;
|
|
783
977
|
if (input.releasePolicy === 'pin') await limitPinCompletion(ctx.clientIp, 'start');
|
|
784
978
|
if (input.recoverySessionToken !== undefined) {
|
|
785
979
|
try {
|
|
786
|
-
|
|
980
|
+
const session = await requireRecoverySessionRecord(
|
|
787
981
|
input.recoverySessionToken,
|
|
788
982
|
'recover'
|
|
789
983
|
);
|
|
984
|
+
authProvider = session.authProvider;
|
|
985
|
+
recoverSessionEpoch = getRecoverySessionEpoch(session);
|
|
790
986
|
} catch (error) {
|
|
791
987
|
if (!(error instanceof TRPCError) || error.code !== 'UNAUTHORIZED') throw error;
|
|
792
988
|
throw new TRPCError({ code: 'NOT_FOUND', message: unavailableMessage });
|
|
@@ -797,14 +993,25 @@ export const escrowRouter = t.router({
|
|
|
797
993
|
providerType: input.providerType!,
|
|
798
994
|
}));
|
|
799
995
|
}
|
|
800
|
-
|
|
996
|
+
let userKey = await findUserKeyByAuthProvider(authProvider.type, authProvider.id);
|
|
997
|
+
// A crashed PIN operation may have committed a new copy at the authority: adopt
|
|
998
|
+
// it before a hold is bound to the old one.
|
|
999
|
+
if (userKey && isEscrowRemoteMode())
|
|
1000
|
+
userKey = await settleExpiredEscrowLease(authProvider, userKey);
|
|
801
1001
|
if (
|
|
802
1002
|
!userKey?.escrowBlob ||
|
|
803
1003
|
userKey.escrowOptedOutAt ||
|
|
804
|
-
!hasConfirmedEscrow(userKey, userKey.escrowBlob.shareVersion)
|
|
1004
|
+
!hasConfirmedEscrow(userKey, userKey.escrowBlob.shareVersion) ||
|
|
1005
|
+
// A recovery session minted before a revoke or wiping rebind is stale.
|
|
1006
|
+
(recoverSessionEpoch !== undefined &&
|
|
1007
|
+
recoverSessionEpoch !== getSecurityEpoch(userKey))
|
|
805
1008
|
) {
|
|
806
1009
|
throw new TRPCError({ code: 'NOT_FOUND', message: unavailableMessage });
|
|
807
1010
|
}
|
|
1011
|
+
// A PIN operation is at the authority: a hold created now would be bound to
|
|
1012
|
+
// the copy its intent is replacing. Retryable, nothing created.
|
|
1013
|
+
if (userKey.escrowEnrollmentLease)
|
|
1014
|
+
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: escrowBusyMessage });
|
|
808
1015
|
if (await hasUnconfirmedEscrowCancellation(userKey.primaryDid)) {
|
|
809
1016
|
throw new TRPCError({
|
|
810
1017
|
code: 'PRECONDITION_FAILED',
|
|
@@ -1198,10 +1405,12 @@ export const escrowRouter = t.router({
|
|
|
1198
1405
|
message: 'This recovery request is still in its waiting period.',
|
|
1199
1406
|
});
|
|
1200
1407
|
}
|
|
1201
|
-
|
|
1408
|
+
let userKey = await findUserKeyByAuthProvider(
|
|
1202
1409
|
hold.authProvider.type,
|
|
1203
1410
|
hold.authProvider.id
|
|
1204
1411
|
);
|
|
1412
|
+
if (userKey && isEscrowRemoteMode())
|
|
1413
|
+
userKey = await settleExpiredEscrowLease(hold.authProvider, userKey);
|
|
1205
1414
|
if (
|
|
1206
1415
|
!userKey?.escrowBlob ||
|
|
1207
1416
|
userKey.escrowOptedOutAt ||
|
|
@@ -1210,25 +1419,57 @@ export const escrowRouter = t.router({
|
|
|
1210
1419
|
) {
|
|
1211
1420
|
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
1212
1421
|
}
|
|
1422
|
+
// A PIN operation is at the authority: comparing now would move its operation
|
|
1423
|
+
// revision under the intent. Nothing is charged; the client retries.
|
|
1424
|
+
if (hold.releasePolicy === 'pin' && userKey.escrowEnrollmentLease)
|
|
1425
|
+
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: escrowBusyMessage });
|
|
1426
|
+
// The authority binds a hold to the exact copy it was created on and refuses
|
|
1427
|
+
// it against any other (a PIN operation committed since the hold started).
|
|
1428
|
+
// Refuse before reserving, so a stale hold never charges a guess Mongo-side
|
|
1429
|
+
// that the authority never counts.
|
|
1430
|
+
if (
|
|
1431
|
+
isEscrowRemoteMode() &&
|
|
1432
|
+
hold.releasePolicy === 'pin' &&
|
|
1433
|
+
hold.holdRecord.hold.blobHash !== escrowBlobIdentity(userKey.escrowBlob.envelope)
|
|
1434
|
+
)
|
|
1435
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
1213
1436
|
await assertFreshEscrowBlob(userKey.escrowBlob);
|
|
1214
1437
|
const encryptedAuthShare = findAuthShareByVersion(userKey, hold.shareVersion);
|
|
1215
1438
|
if (!encryptedAuthShare || !environment.SEED) {
|
|
1216
1439
|
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
1217
1440
|
}
|
|
1218
1441
|
const expectedCiphertext = userKey.escrowBlob.envelope.ciphertext;
|
|
1442
|
+
// Marks this compare in flight until the authority answers, so no
|
|
1443
|
+
// PIN-operation lease can be claimed under it.
|
|
1444
|
+
const compareId = randomUUID();
|
|
1219
1445
|
const reserved =
|
|
1220
1446
|
hold.releasePolicy === 'pin'
|
|
1221
1447
|
? await reserveEscrowPinAttempt(
|
|
1222
1448
|
hold.authProvider,
|
|
1223
1449
|
hold.shareVersion,
|
|
1224
|
-
expectedCiphertext
|
|
1450
|
+
expectedCiphertext,
|
|
1451
|
+
compareId
|
|
1225
1452
|
)
|
|
1226
1453
|
: undefined;
|
|
1454
|
+
// Best effort: the authority has answered by now, so a failed release must not
|
|
1455
|
+
// replace the route's own outcome or skip its cleanup. The marker's TTL
|
|
1456
|
+
// stops it blocking a lease for longer than that.
|
|
1457
|
+
const releaseCompare = async (): Promise<void> => {
|
|
1458
|
+
if (reserved)
|
|
1459
|
+
await releaseEscrowPinCompare(hold.authProvider, compareId).catch(
|
|
1460
|
+
() => undefined
|
|
1461
|
+
);
|
|
1462
|
+
};
|
|
1227
1463
|
if (reserved === null) {
|
|
1228
1464
|
const current = await findUserKeyByAuthProvider(
|
|
1229
1465
|
hold.authProvider.type,
|
|
1230
1466
|
hold.authProvider.id
|
|
1231
1467
|
);
|
|
1468
|
+
if (current?.escrowEnrollmentLease)
|
|
1469
|
+
throw new TRPCError({
|
|
1470
|
+
code: 'TOO_MANY_REQUESTS',
|
|
1471
|
+
message: escrowBusyMessage,
|
|
1472
|
+
});
|
|
1232
1473
|
if (
|
|
1233
1474
|
!current?.escrowPin ||
|
|
1234
1475
|
current.escrowPin.disabledAt ||
|
|
@@ -1281,12 +1522,14 @@ export const escrowRouter = t.router({
|
|
|
1281
1522
|
} catch (error) {
|
|
1282
1523
|
// No enclave evaluation occurred, including when Mongo throws rather
|
|
1283
1524
|
// than returning a lost CAS. Refund before any fallible hold cleanup.
|
|
1284
|
-
if (reserved)
|
|
1525
|
+
if (reserved) {
|
|
1285
1526
|
await refundEscrowPinAttempt(
|
|
1286
1527
|
hold.authProvider,
|
|
1287
1528
|
hold.shareVersion,
|
|
1288
1529
|
expectedCiphertext
|
|
1289
1530
|
);
|
|
1531
|
+
await releaseCompare();
|
|
1532
|
+
}
|
|
1290
1533
|
if (completed) {
|
|
1291
1534
|
await markClaimedEscrowHoldFailed(
|
|
1292
1535
|
hold._id,
|
|
@@ -1323,6 +1566,9 @@ export const escrowRouter = t.router({
|
|
|
1323
1566
|
hold.shareVersion,
|
|
1324
1567
|
expectedCiphertext
|
|
1325
1568
|
);
|
|
1569
|
+
// A transport failure may still reach the authority: keep the
|
|
1570
|
+
// compare marked until its TTL. Any answer settles it now.
|
|
1571
|
+
if (!(error instanceof EscrowUnavailableError)) await releaseCompare();
|
|
1326
1572
|
// Policy includes "not yet releasable"; time/ledger/transport
|
|
1327
1573
|
// refusals map to unavailable. Only malformed blobs are terminal.
|
|
1328
1574
|
// Retries still require the enclave's single-use ledger.
|
|
@@ -1345,6 +1591,7 @@ export const escrowRouter = t.router({
|
|
|
1345
1591
|
hold.shareVersion,
|
|
1346
1592
|
expectedCiphertext
|
|
1347
1593
|
);
|
|
1594
|
+
await releaseCompare();
|
|
1348
1595
|
const attemptsRemaining =
|
|
1349
1596
|
ESCROW_PIN_MAX_ATTEMPTS -
|
|
1350
1597
|
(failed?.escrowPin?.failedAttempts ?? reserved!.escrowPin!.failedAttempts);
|
|
@@ -1364,6 +1611,12 @@ export const escrowRouter = t.router({
|
|
|
1364
1611
|
userKey,
|
|
1365
1612
|
tenant: ctx.tenant,
|
|
1366
1613
|
});
|
|
1614
|
+
// The record changed under this compare (re-enrolled, rotated or disabled
|
|
1615
|
+
// elsewhere), so no wrong entry was recorded for it. Never answer
|
|
1616
|
+
// "Incorrect PIN." here: clients count those as their own recorded entries
|
|
1617
|
+
// (own-typos rule), and an unrecorded one would later excuse a real guess.
|
|
1618
|
+
if (!failed)
|
|
1619
|
+
throw new TRPCError({ code: 'CONFLICT', message: escrowChangedMessage });
|
|
1367
1620
|
if (locked) {
|
|
1368
1621
|
await lockPin(hold, userKey, ctx.tenant);
|
|
1369
1622
|
throw new TRPCError({
|
|
@@ -1377,6 +1630,7 @@ export const escrowRouter = t.router({
|
|
|
1377
1630
|
message: escrowPinMismatchMessage(attemptsRemaining),
|
|
1378
1631
|
});
|
|
1379
1632
|
}
|
|
1633
|
+
await releaseCompare();
|
|
1380
1634
|
const { sealed } = release.result;
|
|
1381
1635
|
if (release.result.receipt)
|
|
1382
1636
|
await recordEscrowReleaseReceipt(hold._id, release.result.receipt);
|
|
@@ -1388,9 +1642,25 @@ export const escrowRouter = t.router({
|
|
|
1388
1642
|
const rebindSessionToken = await createRecoverySession({
|
|
1389
1643
|
scope: 'rebind',
|
|
1390
1644
|
authProvider: hold.authProvider,
|
|
1645
|
+
// Bound to the epoch read above for this release, for every policy: a
|
|
1646
|
+
// revoke or wiping rebind after this point makes the token unusable.
|
|
1647
|
+
securityEpoch: getSecurityEpoch(userKey),
|
|
1648
|
+
// Only a PIN release may sign in without rotating (keys.completePinSignIn).
|
|
1649
|
+
...(hold.releasePolicy === 'pin'
|
|
1650
|
+
? {
|
|
1651
|
+
origin: 'pin-release' as const,
|
|
1652
|
+
primaryDid: userKey.primaryDid,
|
|
1653
|
+
shareVersion: hold.shareVersion,
|
|
1654
|
+
}
|
|
1655
|
+
: {}),
|
|
1391
1656
|
});
|
|
1657
|
+
// A PIN release signs a new device in without signing anything out, so the
|
|
1658
|
+
// owner gets the "Not you? Secure your account" notice instead of the plain
|
|
1659
|
+
// recovery confirmation. Sent here, at the only point the server sees a
|
|
1660
|
+
// successful PIN proof, so the client can neither skip nor forge it. Fail-open
|
|
1661
|
+
// with a bounded budget (notifyEscrowHoldEvent never throws).
|
|
1392
1662
|
await notifyEscrowHoldEvent({
|
|
1393
|
-
kind: 'completed',
|
|
1663
|
+
kind: hold.releasePolicy === 'pin' ? 'pin-sign-in' : 'completed',
|
|
1394
1664
|
hold: completed,
|
|
1395
1665
|
userKey,
|
|
1396
1666
|
tenant: ctx.tenant,
|