@learncard/sss-key-manager 0.2.2 → 0.2.4

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.
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
3
3
  import { getPublicKeyAsync, verifyAsync } from '@noble/ed25519';
4
4
  import {
5
5
  createSSSStrategy,
6
+ formatVersionedEmailShare,
6
7
  type SSSStorageFunctions,
7
8
  type SSSStrategyConfig,
8
9
  } from './sss-strategy';
@@ -78,7 +79,8 @@ const frame = (value: Buffer): Buffer => {
78
79
  size.writeUInt32BE(value.length);
79
80
  return Buffer.concat([size, value]);
80
81
  };
81
- const response = (value: unknown): Response => new Response(JSON.stringify(value), { status: 200 });
82
+ const response = (value: unknown, status = 200): Response =>
83
+ new Response(JSON.stringify(value), { status });
82
84
 
83
85
  const setup = async (legacyEnrolled = false) => {
84
86
  const pins = [
@@ -191,6 +193,12 @@ const setup = async (legacyEnrolled = false) => {
191
193
  snapshotOnly?: boolean;
192
194
  legacySnapshot?: boolean;
193
195
  releaseDid?: string;
196
+ failEnrollment?: boolean;
197
+ missingPinMetadata?: boolean;
198
+ /** Server predating `priorEscrowEnrollment` on /keys/auth-share. */
199
+ oldServer?: boolean;
200
+ /** A compromised lca-api forging post-rebind status for a healthy account. */
201
+ forgedRebindStatus?: boolean;
194
202
  } = {};
195
203
  const signHold = async (record: EscrowHoldRecordLike): Promise<EscrowHoldRecordLike> => {
196
204
  const h = record.hold;
@@ -230,10 +238,21 @@ const setup = async (legacyEnrolled = false) => {
230
238
  enrolled = false;
231
239
  return response({ shareVersion: version, expectedShareVersionChecked: true });
232
240
  }
241
+ if (faults.forgedRebindStatus)
242
+ return response({
243
+ primaryDid: owner.intent.did,
244
+ shareVersion: version + 1,
245
+ authShare: { encryptedData: authShare, encryptedDek: '', iv: '' },
246
+ recoveryMethods: [],
247
+ priorEscrowEnrollment: true,
248
+ escrowPin: { state: 'none', enabled: false, attemptsRemaining: 0 },
249
+ });
233
250
  return response({
234
251
  primaryDid: owner.intent.did,
235
252
  shareVersion: version,
236
253
  authShare: { encryptedData: authShare, encryptedDek: '', iv: '' },
254
+ // Mirror lca-api: only confirmed methods are listed; the rebind's
255
+ // `pending` escrow method surfaces only as priorEscrowEnrollment.
237
256
  recoveryMethods: enrolled
238
257
  ? [
239
258
  {
@@ -243,6 +262,12 @@ const setup = async (legacyEnrolled = false) => {
243
262
  },
244
263
  ]
245
264
  : [],
265
+ ...(faults.oldServer
266
+ ? {}
267
+ : { priorEscrowEnrollment: !enrolled && !!faults.missingPinMetadata }),
268
+ ...(faults.missingPinMetadata
269
+ ? { escrowPin: { state: 'none', enabled: false, attemptsRemaining: 0 } }
270
+ : {}),
246
271
  });
247
272
  }
248
273
  if (path === '/keys/escrow/attestation') return response({ attestation: {} });
@@ -271,6 +296,19 @@ const setup = async (legacyEnrolled = false) => {
271
296
  ).toBe(true);
272
297
  expect(signed.intent.expectedEnrollmentVersion).toBe(state.enrollmentVersion);
273
298
  intents.push(signed);
299
+ if (faults.failEnrollment) throw new Error('Temporary enrollment outage');
300
+ // Mirror escrow.enroll: rebind removes Mongo metadata, so carry cannot work.
301
+ if (
302
+ faults.missingPinMetadata &&
303
+ state.hasPin &&
304
+ signed.intent.pinAction === 'keep'
305
+ ) {
306
+ return response(
307
+ { message: 'Set or clear your recovery PIN before continuing.' },
308
+ 412
309
+ );
310
+ }
311
+ expect(!!body.clearPin).toBe(signed.intent.pinAction === 'clear');
274
312
  let output: EscrowEnvelope | null = null;
275
313
  if (path === '/keys/escrow') {
276
314
  const plaintext = await decryptEscrowBlob(
@@ -462,7 +500,10 @@ const setup = async (legacyEnrolled = false) => {
462
500
  did: faults.releaseDid ?? owner.intent.did,
463
501
  holdId: pendingHold.hold.holdId,
464
502
  shareVersion: version,
465
- recoveryShare: recoveryShares.recoveryShare,
503
+ recoveryShare: state.envelope
504
+ ? (await decryptEscrowBlob(state.envelope, keys.privateKey))
505
+ .recoveryShare
506
+ : recoveryShares.recoveryShare,
466
507
  },
467
508
  pendingHold.hold.clientEphemeralPublicKey
468
509
  ),
@@ -473,6 +514,22 @@ const setup = async (legacyEnrolled = false) => {
473
514
  });
474
515
  }
475
516
  if (path === '/keys/escrow/opt-in') return response({ success: true });
517
+ if (path === '/keys/recovery-session/recover') {
518
+ return response({
519
+ authShare: { encryptedData: authShare },
520
+ primaryDid: owner.intent.did,
521
+ rebindSessionToken: 'rebind-session',
522
+ });
523
+ }
524
+ if (path === '/keys/recovery-session/rebind') {
525
+ enrolled = false;
526
+ faults.missingPinMetadata = true;
527
+ authShare = (body.authShare as { encryptedData: string }).encryptedData;
528
+ return response({
529
+ shareVersion: ++version,
530
+ recoveryMethodsRequireConfirmation: [],
531
+ });
532
+ }
476
533
  throw new Error('Unexpected test request');
477
534
  })
478
535
  );
@@ -508,6 +565,7 @@ const setup = async (legacyEnrolled = false) => {
508
565
  identity,
509
566
  measurements,
510
567
  reload: () => createSSSStrategy(config),
568
+ emailShare: () => formatVersionedEmailShare(recoveryShares.emailShare, version),
511
569
  state: () => state,
512
570
  advance: () => {
513
571
  version++;
@@ -525,6 +583,324 @@ afterEach(() => {
525
583
  });
526
584
 
527
585
  describe('Nitro recovery initiating-client bindings and measurement rollover', () => {
586
+ it('retries recovered enrollment with clear-PIN intent after failure before enclave mutation', async () => {
587
+ const f = await setup();
588
+ await f.strategy.ensureEscrowEnrollment!({
589
+ ...f.params,
590
+ options: { ...f.params.options, pin: '135790' },
591
+ });
592
+ expect(f.state()).toMatchObject({ status: 'active', hasPin: true });
593
+ const hold = await f.strategy.startEscrowRecovery!({
594
+ recoverySessionToken: 'otp-session',
595
+ expectedDid: owner.intent.did,
596
+ });
597
+ await f.strategy.executeRecovery({
598
+ token: '',
599
+ providerType: 'firebase',
600
+ input: { method: 'escrow', ...hold, resumeToken: hold.resumeToken! },
601
+ didFromPrivateKey: async () => owner.intent.did,
602
+ });
603
+ f.faults.failEnrollment = true;
604
+ await f.strategy.completeIdentityRecovery!(f.params);
605
+ expect(f.intents.at(-1)?.intent).toMatchObject({ action: 'replace', pinAction: 'clear' });
606
+ expect(f.state()).toMatchObject({ status: 'active', hasPin: true });
607
+ const writes = f.calls.filter(call => call === 'PUT:/keys/auth-share').length;
608
+ f.faults.failEnrollment = false;
609
+ await expect(
610
+ f.strategy.ensureEscrowEnrollment!({
611
+ ...f.params,
612
+ options: { ...f.params.options, clearPin: true },
613
+ })
614
+ ).resolves.toMatchObject({ enrolled: true });
615
+ expect(f.intents.at(-1)?.intent).toMatchObject({ action: 'replace', pinAction: 'clear' });
616
+ expect(f.state()).toMatchObject({ status: 'active', hasPin: false });
617
+ expect(f.calls.filter(call => call === 'PUT:/keys/auth-share')).toHaveLength(writes);
618
+ });
619
+ it('treats a successful recovered clear-PIN enrollment as satisfying the repair', async () => {
620
+ const f = await setup();
621
+ await f.strategy.ensureEscrowEnrollment!({
622
+ ...f.params,
623
+ options: { ...f.params.options, pin: '135790' },
624
+ });
625
+ const hold = await f.strategy.startEscrowRecovery!({
626
+ recoverySessionToken: 'otp-session',
627
+ expectedDid: owner.intent.did,
628
+ });
629
+ await f.strategy.executeRecovery({
630
+ token: '',
631
+ providerType: 'firebase',
632
+ input: { method: 'escrow', ...hold, resumeToken: hold.resumeToken! },
633
+ didFromPrivateKey: async () => owner.intent.did,
634
+ });
635
+ await f.strategy.completeIdentityRecovery!(f.params);
636
+ expect(f.state()).toMatchObject({ status: 'active', hasPin: false });
637
+ const writes = f.calls.filter(call => call === 'PUT:/keys/auth-share').length;
638
+ const intents = f.intents.length;
639
+ await expect(
640
+ f.strategy.ensureEscrowEnrollment!({
641
+ ...f.params,
642
+ options: { ...f.params.options, clearPin: true },
643
+ })
644
+ ).resolves.toMatchObject({ enrolled: true, changed: false });
645
+ expect(f.calls.filter(call => call === 'PUT:/keys/auth-share')).toHaveLength(writes);
646
+ expect(f.intents).toHaveLength(intents);
647
+ });
648
+ it('repairs a prior enrollment after non-escrow identity recovery outside rollout', async () => {
649
+ const f = await setup();
650
+ await f.strategy.ensureEscrowEnrollment!({
651
+ ...f.params,
652
+ options: { ...f.params.options, pin: '135790' },
653
+ });
654
+ const intents = f.intents.length;
655
+ await f.strategy.prepareIdentityRecovery!({
656
+ recoverySessionToken: 'session',
657
+ input: { method: 'email', emailShare: f.emailShare() },
658
+ didFromPrivateKey: async () => owner.intent.did,
659
+ });
660
+ await expect(f.strategy.completeIdentityRecovery!(f.params)).resolves.toMatchObject({
661
+ escrowEnrolled: false,
662
+ escrowRecovery: false,
663
+ });
664
+ expect(f.intents).toHaveLength(intents);
665
+ // Outside rollout the verified authority proves prior enrollment, so the
666
+ // repair still runs and clears the retired PIN instead of carrying it.
667
+ await expect(
668
+ f.strategy.ensureEscrowEnrollment!({
669
+ ...f.params,
670
+ options: { ...f.params.options, clearPin: true, requirePriorEnrollment: true },
671
+ })
672
+ ).resolves.toMatchObject({ enrolled: true });
673
+ expect(f.intents.at(-1)?.intent).toMatchObject({ action: 'replace', pinAction: 'clear' });
674
+ expect(f.state()).toMatchObject({ status: 'active', hasPin: false });
675
+ });
676
+ const recoverWithFailedDirectEnrollment = async () => {
677
+ const f = await setup();
678
+ await f.strategy.ensureEscrowEnrollment!({
679
+ ...f.params,
680
+ options: { ...f.params.options, pin: '135790' },
681
+ });
682
+ const hold = await f.strategy.startEscrowRecovery!({
683
+ recoverySessionToken: 'otp-session',
684
+ expectedDid: owner.intent.did,
685
+ });
686
+ await f.strategy.executeRecovery({
687
+ token: '',
688
+ providerType: 'firebase',
689
+ input: { method: 'escrow', ...hold, resumeToken: hold.resumeToken! },
690
+ didFromPrivateKey: async () => owner.intent.did,
691
+ });
692
+ f.faults.failEnrollment = true;
693
+ await f.strategy.completeIdentityRecovery!(f.params);
694
+ f.faults.failEnrollment = false;
695
+ expect(f.state()).toMatchObject({ status: 'active', hasPin: true });
696
+ return f;
697
+ };
698
+ const writes = (f: { calls: string[] }) =>
699
+ f.calls.filter(call => call === 'PUT:/keys/auth-share').length;
700
+ it.each([false, true])(
701
+ 'after a reload, asks for a new PIN instead of rotating or signing (outside rollout: %s)',
702
+ async requirePriorEnrollment => {
703
+ const f = await recoverWithFailedDirectEnrollment();
704
+ const intents = f.intents.length;
705
+ const before = writes(f);
706
+ await expect(
707
+ f.reload().ensureEscrowEnrollment!({
708
+ ...f.params,
709
+ options: {
710
+ ...f.params.options,
711
+ ...(requirePriorEnrollment ? { requirePriorEnrollment } : {}),
712
+ },
713
+ })
714
+ ).resolves.toEqual({ enrolled: false, reason: 'pin-reset-required' });
715
+ expect(f.intents).toHaveLength(intents);
716
+ expect(writes(f)).toBe(before);
717
+ expect(f.state()).toMatchObject({ status: 'active', hasPin: true });
718
+ }
719
+ );
720
+ it('lets the user set a new PIN after a rebind left the old one uncarryable', async () => {
721
+ const f = await recoverWithFailedDirectEnrollment();
722
+ const intents = f.intents.length;
723
+ await f.reload().setEscrowPin!({ ...f.params, pin: '246813' });
724
+ expect(f.intents.slice(intents).map(signed => signed.intent)).toEqual([
725
+ // The authority still has the old PIN, so the user's new PIN is a `change`:
726
+ // a replace with fresh PIN material that needs no carry source in lca-api.
727
+ expect.objectContaining({ action: 'replace', pinAction: 'change' }),
728
+ ]);
729
+ expect(f.state()).toMatchObject({ status: 'active', hasPin: true });
730
+ });
731
+ it('signs nothing for a healthy PIN account when lca-api forges rebind status', async () => {
732
+ const f = await setup();
733
+ await f.strategy.ensureEscrowEnrollment!({
734
+ ...f.params,
735
+ options: { ...f.params.options, pin: '135790' },
736
+ });
737
+ expect(f.state()).toMatchObject({ status: 'active', hasPin: true });
738
+ const intents = f.intents.length;
739
+ const before = writes(f);
740
+ f.faults.forgedRebindStatus = true;
741
+ await expect(f.reload().ensureEscrowEnrollment!(f.params)).resolves.toEqual({
742
+ enrolled: false,
743
+ reason: 'pin-reset-required',
744
+ });
745
+ expect(f.intents).toHaveLength(intents);
746
+ expect(writes(f)).toBe(before);
747
+ expect(f.state()).toMatchObject({ status: 'active', hasPin: true });
748
+ });
749
+ it('still clears on forged status when auto-clear is explicitly enabled (needs security sign-off)', async () => {
750
+ const f = await setup();
751
+ await f.strategy.ensureEscrowEnrollment!({
752
+ ...f.params,
753
+ options: { ...f.params.options, pin: '135790' },
754
+ });
755
+ f.faults.forgedRebindStatus = true;
756
+ await expect(
757
+ createSSSStrategy({ ...f.config, autoClearUncarryablePin: true })
758
+ .ensureEscrowEnrollment!(f.params)
759
+ ).resolves.toMatchObject({ enrolled: true, pinCleared: 'automatic' });
760
+ expect(f.intents.at(-1)?.intent).toMatchObject({ action: 'replace', pinAction: 'clear' });
761
+ expect(f.state()).toMatchObject({ hasPin: false });
762
+ });
763
+ it.each([false, true])(
764
+ 'with auto-clear opted in, repairs a recovered PIN account by signing a clear (outside rollout: %s)',
765
+ async requirePriorEnrollment => {
766
+ const f = await recoverWithFailedDirectEnrollment();
767
+ const intents = f.intents.length;
768
+ // Fresh instance: no unenrolledRotation and no clear-PIN intent from the coordinator.
769
+ await expect(
770
+ createSSSStrategy({ ...f.config, autoClearUncarryablePin: true })
771
+ .ensureEscrowEnrollment!({
772
+ ...f.params,
773
+ options: {
774
+ ...f.params.options,
775
+ ...(requirePriorEnrollment ? { requirePriorEnrollment } : {}),
776
+ },
777
+ })
778
+ ).resolves.toMatchObject({ enrolled: true, changed: true });
779
+ expect(f.intents.slice(intents).map(signed => signed.intent)).toEqual([
780
+ expect.objectContaining({ action: 'replace', pinAction: 'clear' }),
781
+ ]);
782
+ expect(f.state()).toMatchObject({ status: 'active', hasPin: false });
783
+ }
784
+ );
785
+ it('reports an automatic clear once, without the DID', async () => {
786
+ const f = await recoverWithFailedDirectEnrollment();
787
+ const onEscrowPinAutoCleared = vi.fn();
788
+ await expect(
789
+ createSSSStrategy({
790
+ ...f.config,
791
+ autoClearUncarryablePin: true,
792
+ onEscrowPinAutoCleared,
793
+ }).ensureEscrowEnrollment!(f.params)
794
+ ).resolves.toEqual({
795
+ enrolled: true,
796
+ changed: true,
797
+ shareVersion: expect.any(Number),
798
+ pinCleared: 'automatic',
799
+ });
800
+ expect(onEscrowPinAutoCleared).toHaveBeenCalledTimes(1);
801
+ expect(onEscrowPinAutoCleared).toHaveBeenCalledWith({
802
+ type: 'escrow.pin.auto_cleared',
803
+ shareVersion: expect.any(Number),
804
+ authorityShareVersion: expect.any(Number),
805
+ });
806
+ expect(JSON.stringify(onEscrowPinAutoCleared.mock.calls)).not.toContain(owner.intent.did);
807
+ });
808
+ it('does not report a clear the caller asked for as automatic', async () => {
809
+ const f = await recoverWithFailedDirectEnrollment();
810
+ const onEscrowPinAutoCleared = vi.fn();
811
+ const result = await createSSSStrategy({
812
+ ...f.config,
813
+ onEscrowPinAutoCleared,
814
+ }).ensureEscrowEnrollment!({
815
+ ...f.params,
816
+ options: { ...f.params.options, clearPin: true },
817
+ });
818
+ expect(result).toMatchObject({ enrolled: true, changed: true });
819
+ expect(result).not.toHaveProperty('pinCleared');
820
+ expect(f.intents.at(-1)?.intent).toMatchObject({ pinAction: 'clear' });
821
+ expect(onEscrowPinAutoCleared).not.toHaveBeenCalled();
822
+ });
823
+ it('does not clear automatically without rebind evidence from the authority', async () => {
824
+ const f = await recoverWithFailedDirectEnrollment();
825
+ const intents = f.intents.length;
826
+ const onEscrowPinAutoCleared = vi.fn();
827
+ // An authority already at the server's share version is no rebind.
828
+ f.state().shareVersion = (
829
+ await f.reload().fetchServerKeyStatus(f.params.token, f.params.providerType)
830
+ ).shareVersion;
831
+ await expect(
832
+ createSSSStrategy({
833
+ ...f.config,
834
+ autoClearUncarryablePin: true,
835
+ onEscrowPinAutoCleared,
836
+ }).ensureEscrowEnrollment!(f.params)
837
+ ).resolves.toEqual({ enrolled: false, reason: 'pin-reset-required' });
838
+ expect(f.intents).toHaveLength(intents);
839
+ expect(onEscrowPinAutoCleared).not.toHaveBeenCalled();
840
+ });
841
+ it('does not burn a share version after a reload when the authority read fails', async () => {
842
+ const f = await recoverWithFailedDirectEnrollment();
843
+ const writes = f.calls.filter(call => call === 'PUT:/keys/auth-share').length;
844
+ const intents = f.intents.length;
845
+ f.faults.snapshotNonce = true;
846
+ await expect(f.reload().ensureEscrowEnrollment!(f.params)).rejects.toThrow();
847
+ expect(f.calls.filter(call => call === 'PUT:/keys/auth-share')).toHaveLength(writes);
848
+ expect(f.intents).toHaveLength(intents);
849
+ });
850
+ it('reports a leftover pending escrow method as prior enrollment after a reload', async () => {
851
+ const f = await recoverWithFailedDirectEnrollment();
852
+ await expect(f.reload().getEscrowEnrollmentState!(f.params)).resolves.toMatchObject({
853
+ state: 'not-enrolled',
854
+ priorEscrowMethod: true,
855
+ });
856
+ });
857
+ it('falls back to the confirmed method list on servers without priorEscrowEnrollment', async () => {
858
+ const f = await recoverWithFailedDirectEnrollment();
859
+ f.faults.oldServer = true;
860
+ const state = await f.reload().getEscrowEnrollmentState!(f.params);
861
+ expect(state).toMatchObject({ state: 'not-enrolled' });
862
+ expect(state).not.toHaveProperty('priorEscrowMethod');
863
+ });
864
+ it('does not enroll a never-enrolled account through prior-enrollment repair', async () => {
865
+ const f = await setup();
866
+ await f.strategy.prepareIdentityRecovery!({
867
+ recoverySessionToken: 'session',
868
+ input: { method: 'email', emailShare: f.emailShare() },
869
+ didFromPrivateKey: async () => owner.intent.did,
870
+ });
871
+ await f.strategy.completeIdentityRecovery!(f.params);
872
+ await expect(
873
+ f.strategy.ensureEscrowEnrollment!({
874
+ ...f.params,
875
+ options: { ...f.params.options, clearPin: true, requirePriorEnrollment: true },
876
+ })
877
+ ).resolves.toEqual({ enrolled: false, reason: 'not-enrolled' });
878
+ expect(f.intents).toHaveLength(0);
879
+ expect(f.state()).toMatchObject({ status: 'empty' });
880
+ });
881
+ it('binds recovery contact into the enrollment intent after identity recovery', async () => {
882
+ const f = await setup();
883
+ const hold = await f.strategy.startEscrowRecovery!({
884
+ recoverySessionToken: 'otp-session',
885
+ expectedDid: owner.intent.did,
886
+ });
887
+ await f.strategy.executeRecovery({
888
+ token: '',
889
+ providerType: 'firebase',
890
+ input: { method: 'escrow', ...hold, resumeToken: hold.resumeToken! },
891
+ didFromPrivateKey: async () => owner.intent.did,
892
+ });
893
+ await f.strategy.completeIdentityRecovery!({
894
+ ...f.params,
895
+ options: { recoveryEmail: 'recovered@example.com', recoveryLanguage: 'es' },
896
+ });
897
+ expect(f.intents.at(-1)?.intent).toMatchObject({
898
+ recoveryEmail: 'recovered@example.com',
899
+ recoveryLanguage: 'es',
900
+ action: 'enroll',
901
+ });
902
+ expect(f.state()).toMatchObject({ status: 'active', hasPin: false });
903
+ });
528
904
  const start = (f: Awaited<ReturnType<typeof setup>>) =>
529
905
  f.strategy.startEscrowRecovery!({
530
906
  token: 'token',
@@ -223,6 +223,7 @@ describe('escrow strategy', () => {
223
223
  recoveryMethods: methods,
224
224
  escrowOptedOut: optedOut,
225
225
  escrowPin: {
226
+ state: !pinSalt ? 'none' : attemptsRemaining > 0 ? 'enabled' : 'locked',
226
227
  enabled: !!pinSalt && attemptsRemaining > 0,
227
228
  attemptsRemaining,
228
229
  ...(pinSalt ? { salt: pinSalt } : {}),
@@ -458,6 +459,38 @@ describe('escrow strategy', () => {
458
459
  });
459
460
  });
460
461
 
462
+ it.each(
463
+ [
464
+ 'codeMismatch',
465
+ 'codeExpired',
466
+ 'codeLocked',
467
+ 'secret response',
468
+ null,
469
+ 403,
470
+ { code: 'codeMismatch' },
471
+ ['codeLocked'],
472
+ ].map(message => ({ message }))
473
+ )('only exposes allowlisted code refusal metadata: %s', async ({ message }) => {
474
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
475
+ new Response(JSON.stringify({ message }), { status: 403 })
476
+ );
477
+ await expect(
478
+ strategy.confirmEscrowRecovery!({
479
+ holdId: 'hold',
480
+ resumeToken: 'resume',
481
+ code: '12345678',
482
+ })
483
+ ).rejects.toMatchObject({
484
+ name: 'EscrowRequestError',
485
+ status: 403,
486
+ message: 'Escrow request failed',
487
+ reason:
488
+ message === 'codeMismatch' || message === 'codeExpired' || message === 'codeLocked'
489
+ ? message
490
+ : undefined,
491
+ });
492
+ });
493
+
461
494
  it('exposes the precondition status without exposing the response body', async () => {
462
495
  optOutStatus = 412;
463
496
  await expect(strategy.disableEscrowRecovery!(params)).rejects.toMatchObject({
@@ -708,6 +741,33 @@ describe('escrow strategy', () => {
708
741
  expect(blob?.pinVerifier).toBeUndefined();
709
742
  });
710
743
 
744
+ it('repairs only a prior enrollment when requirePriorEnrollment is set', async () => {
745
+ const options = { clearPin: true, requirePriorEnrollment: true };
746
+ await expect(strategy.ensureEscrowEnrollment!({ ...params, options })).resolves.toEqual({
747
+ enrolled: false,
748
+ reason: 'not-enrolled',
749
+ });
750
+ expect(version).toBe(1);
751
+ await strategy.ensureEscrowEnrollment!(params);
752
+ expect(version).toBe(2);
753
+ await expect(
754
+ strategy.ensureEscrowEnrollment!({ ...params, options })
755
+ ).resolves.toMatchObject({ enrolled: true });
756
+ });
757
+
758
+ it('treats a current PIN-free enrollment as satisfying a clear request', async () => {
759
+ await strategy.ensureEscrowEnrollment!(params);
760
+ expect(version).toBe(2);
761
+ await expect(
762
+ strategy.ensureEscrowEnrollment!({ ...params, options: { clearPin: true } })
763
+ ).resolves.toEqual({ enrolled: true, changed: false });
764
+ expect(version).toBe(2);
765
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
766
+ await strategy.ensureEscrowEnrollment!({ ...params, options: { clearPin: true } });
767
+ expect(version).toBe(4);
768
+ expect(blob?.pinVerifier).toBeUndefined();
769
+ });
770
+
711
771
  it.each(['set', 'clear'])('rejects %s PIN when disabled without opting in', async action => {
712
772
  config.escrow!.enabled = false;
713
773
  await expect(
@@ -760,7 +820,7 @@ describe('escrow strategy', () => {
760
820
  it('recovers with a PIN and rotates to hold-only material', async () => {
761
821
  await strategy.setEscrowPin!({ ...params, pin: '135790' });
762
822
  const recovered = await recoverPin();
763
- expect(recovered).toEqual({ privateKey, did });
823
+ expect(recovered).toEqual({ privateKey, did, escrowEnrolled: true, escrowRecovery: true });
764
824
  expect(version).toBe(3);
765
825
  expect(blob?.pinVerifier).toBeUndefined();
766
826
  expect(await reconstructFromShares([(await storage.getDeviceShare())!, authShare])).toBe(
@@ -779,7 +839,12 @@ describe('escrow strategy', () => {
779
839
  expect(burned).toEqual(['escrow-hold']);
780
840
  expect(storage.storeDeviceShare).not.toHaveBeenCalled();
781
841
  expect(storage.storeShareVersion).not.toHaveBeenCalled();
782
- await expect(recoverPin()).resolves.toEqual({ privateKey, did });
842
+ await expect(recoverPin()).resolves.toEqual({
843
+ privateKey,
844
+ did,
845
+ escrowEnrolled: true,
846
+ escrowRecovery: true,
847
+ });
783
848
  const starts = calls.filter(call => call.path === '/keys/escrow/recover');
784
849
  const completes = calls.filter(call => call.path === '/keys/escrow/complete');
785
850
  expect(starts).toHaveLength(2);
@@ -905,14 +970,18 @@ describe('escrow strategy', () => {
905
970
 
906
971
  it('round trips real crypto, rebinds and persists a new device share', async () => {
907
972
  await strategy.ensureEscrowEnrollment!(params);
973
+ const complete = vi.spyOn(strategy, 'completeIdentityRecovery');
974
+ const options = { recoveryEmail: 'owner@example.com', recoveryLanguage: 'es' };
908
975
  const previous = await storage.getDeviceShare();
909
976
  const start = await strategy.startEscrowRecovery!({ token, providerType });
910
977
  const recovered = await strategy.executeRecovery({
911
978
  ...params,
979
+ options,
912
980
  input: { method: 'escrow', ...start, resumeToken: start.resumeToken! },
913
981
  didFromPrivateKey: async key => (key === privateKey ? did : ''),
914
982
  });
915
983
  expect(recovered.privateKey === privateKey).toBe(true);
984
+ expect(complete).toHaveBeenCalledWith(expect.objectContaining({ options }));
916
985
  expect((await storage.getDeviceShare()) === previous).toBe(false);
917
986
  expect(
918
987
  (await reconstructFromShares([(await storage.getDeviceShare())!, authShare])) ===
@@ -2000,6 +2069,8 @@ describe('createSSSStrategy', () => {
2000
2069
  await expect(strategy.completeIdentityRecovery!(fixture.params)).resolves.toEqual({
2001
2070
  privateKey: fixture.privateKey,
2002
2071
  did: fixture.did,
2072
+ escrowEnrolled: false,
2073
+ escrowRecovery: false,
2003
2074
  });
2004
2075
  expect(fixture.getRebindCalls()).toBe(1);
2005
2076
  expect(vi.mocked(storage.storeDeviceShare).mock.calls.slice(writes)).toEqual([