@learncard/sss-key-manager 0.2.3 → 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.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/sss-key-manager.cjs.development.js +8178 -2070
- package/dist/sss-key-manager.cjs.development.js.map +3 -3
- package/dist/sss-key-manager.cjs.production.min.js +46 -49
- package/dist/sss-key-manager.cjs.production.min.js.map +3 -3
- package/dist/sss-key-manager.esm.js +8179 -2071
- package/dist/sss-key-manager.esm.js.map +3 -3
- package/dist/sss-strategy.d.ts +31 -0
- package/dist/sss-strategy.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +5 -1
- package/src/sss-authority.test.ts +378 -2
- package/src/sss-strategy.test.ts +41 -2
- package/src/sss-strategy.ts +195 -25
package/dist/sss-strategy.d.ts
CHANGED
|
@@ -14,6 +14,15 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type { SSSKeyDerivationStrategy, EscrowAttestationPolicy } from './types';
|
|
16
16
|
import type { EmailRelayBranding } from './email-relay-crypto';
|
|
17
|
+
/**
|
|
18
|
+
* OFF by default. Every input to the uncarryable-PIN inference (recovery methods,
|
|
19
|
+
* share version, PIN state) comes from lca-api, so a compromised API could forge it
|
|
20
|
+
* for a healthy PIN account and have the client sign away the PIN path (Custard7's
|
|
21
|
+
* PR #1815 review). With it off the strategy returns `pin-reset-required` and the
|
|
22
|
+
* app asks the user to set a new PIN. Enabling it needs an independently trustworthy
|
|
23
|
+
* recovery signal and security sign-off: POST_RECOVERY_ESCROW_REPAIR_DESIGN.md, Q1.
|
|
24
|
+
*/
|
|
25
|
+
export declare const DEFAULT_AUTO_CLEAR_UNCARRYABLE_PIN = false;
|
|
17
26
|
export interface SSSStorageFunctions {
|
|
18
27
|
storeDeviceShare: (share: string, id?: string) => Promise<void>;
|
|
19
28
|
getDeviceShare: (id?: string) => Promise<string | null>;
|
|
@@ -31,6 +40,20 @@ export interface SSSStrategyConfig {
|
|
|
31
40
|
};
|
|
32
41
|
/** Report enrollment failures without logging recovery material. */
|
|
33
42
|
onEscrowError?: (err: unknown) => void;
|
|
43
|
+
/**
|
|
44
|
+
* Nitro repair only: when the signed authority still has a PIN but lca-api holds
|
|
45
|
+
* no PIN to carry (an identity-recovery rebind deleted it), sign a PIN `clear`
|
|
46
|
+
* instead of a `carry` that lca-api would reject with PRECONDITION_FAILED.
|
|
47
|
+
* Defaults to {@link DEFAULT_AUTO_CLEAR_UNCARRYABLE_PIN} (off). Do not enable
|
|
48
|
+
* without security sign-off: POST_RECOVERY_ESCROW_REPAIR_DESIGN.md, open question Q1.
|
|
49
|
+
*/
|
|
50
|
+
autoClearUncarryablePin?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Called once after every committed automatic PIN clear
|
|
53
|
+
* ({@link SSSStrategyConfig.autoClearUncarryablePin}). Must not throw; defaults
|
|
54
|
+
* to a console warning. Never receives the DID, email or PIN material.
|
|
55
|
+
*/
|
|
56
|
+
onEscrowPinAutoCleared?: (event: EscrowPinAutoClearedEvent) => void;
|
|
34
57
|
/** Server URL for key share operations */
|
|
35
58
|
serverUrl: string;
|
|
36
59
|
/** Custom storage functions (defaults to IndexedDB) */
|
|
@@ -56,6 +79,14 @@ export interface SSSStrategyConfig {
|
|
|
56
79
|
tenantId?: string;
|
|
57
80
|
}
|
|
58
81
|
export declare const SERVER_REQUEST_TIMEOUT_MS = 30000;
|
|
82
|
+
/** Telemetry for an automatic post-rebind PIN clear. Carries no DID, email or PIN data. */
|
|
83
|
+
export interface EscrowPinAutoClearedEvent {
|
|
84
|
+
type: 'escrow.pin.auto_cleared';
|
|
85
|
+
/** Share version the PIN-free enrollment was committed at. */
|
|
86
|
+
shareVersion: number;
|
|
87
|
+
/** Share version the signed authority was still active at (pre-rebind). */
|
|
88
|
+
authorityShareVersion: number | null;
|
|
89
|
+
}
|
|
59
90
|
/** HTTP failure metadata without potentially sensitive server response text. */
|
|
60
91
|
export declare class EscrowRequestError extends Error {
|
|
61
92
|
readonly status: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sss-strategy.d.ts","sourceRoot":"","sources":["../src/sss-strategy.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"sss-strategy.d.ts","sourceRoot":"","sources":["../src/sss-strategy.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learncard/sss-key-manager",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Shamir Secret Sharing key manager for LearnCard - replaces Web3Auth SFA",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/sss-key-manager.esm.js",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"vitest": "4.1.11"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@learncard/types": "5.
|
|
42
|
+
"@learncard/types": "5.24.0",
|
|
43
43
|
"@noble/ed25519": "^2.0.0",
|
|
44
44
|
"@peculiar/x509": "^2.1.0",
|
|
45
45
|
"@scure/bip39": "^1.2.1",
|
package/src/index.ts
CHANGED
|
@@ -177,7 +177,11 @@ export {
|
|
|
177
177
|
EscrowRequestError,
|
|
178
178
|
} from './sss-strategy';
|
|
179
179
|
|
|
180
|
-
export type {
|
|
180
|
+
export type {
|
|
181
|
+
EscrowPinAutoClearedEvent,
|
|
182
|
+
SSSStorageFunctions,
|
|
183
|
+
SSSStrategyConfig,
|
|
184
|
+
} from './sss-strategy';
|
|
181
185
|
|
|
182
186
|
export {
|
|
183
187
|
generateEphemeralKeypair,
|
|
@@ -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
|
|
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:
|
|
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',
|
package/src/sss-strategy.test.ts
CHANGED
|
@@ -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 } : {}),
|
|
@@ -740,6 +741,33 @@ describe('escrow strategy', () => {
|
|
|
740
741
|
expect(blob?.pinVerifier).toBeUndefined();
|
|
741
742
|
});
|
|
742
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
|
+
|
|
743
771
|
it.each(['set', 'clear'])('rejects %s PIN when disabled without opting in', async action => {
|
|
744
772
|
config.escrow!.enabled = false;
|
|
745
773
|
await expect(
|
|
@@ -792,7 +820,7 @@ describe('escrow strategy', () => {
|
|
|
792
820
|
it('recovers with a PIN and rotates to hold-only material', async () => {
|
|
793
821
|
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
794
822
|
const recovered = await recoverPin();
|
|
795
|
-
expect(recovered).toEqual({ privateKey, did });
|
|
823
|
+
expect(recovered).toEqual({ privateKey, did, escrowEnrolled: true, escrowRecovery: true });
|
|
796
824
|
expect(version).toBe(3);
|
|
797
825
|
expect(blob?.pinVerifier).toBeUndefined();
|
|
798
826
|
expect(await reconstructFromShares([(await storage.getDeviceShare())!, authShare])).toBe(
|
|
@@ -811,7 +839,12 @@ describe('escrow strategy', () => {
|
|
|
811
839
|
expect(burned).toEqual(['escrow-hold']);
|
|
812
840
|
expect(storage.storeDeviceShare).not.toHaveBeenCalled();
|
|
813
841
|
expect(storage.storeShareVersion).not.toHaveBeenCalled();
|
|
814
|
-
await expect(recoverPin()).resolves.toEqual({
|
|
842
|
+
await expect(recoverPin()).resolves.toEqual({
|
|
843
|
+
privateKey,
|
|
844
|
+
did,
|
|
845
|
+
escrowEnrolled: true,
|
|
846
|
+
escrowRecovery: true,
|
|
847
|
+
});
|
|
815
848
|
const starts = calls.filter(call => call.path === '/keys/escrow/recover');
|
|
816
849
|
const completes = calls.filter(call => call.path === '/keys/escrow/complete');
|
|
817
850
|
expect(starts).toHaveLength(2);
|
|
@@ -937,14 +970,18 @@ describe('escrow strategy', () => {
|
|
|
937
970
|
|
|
938
971
|
it('round trips real crypto, rebinds and persists a new device share', async () => {
|
|
939
972
|
await strategy.ensureEscrowEnrollment!(params);
|
|
973
|
+
const complete = vi.spyOn(strategy, 'completeIdentityRecovery');
|
|
974
|
+
const options = { recoveryEmail: 'owner@example.com', recoveryLanguage: 'es' };
|
|
940
975
|
const previous = await storage.getDeviceShare();
|
|
941
976
|
const start = await strategy.startEscrowRecovery!({ token, providerType });
|
|
942
977
|
const recovered = await strategy.executeRecovery({
|
|
943
978
|
...params,
|
|
979
|
+
options,
|
|
944
980
|
input: { method: 'escrow', ...start, resumeToken: start.resumeToken! },
|
|
945
981
|
didFromPrivateKey: async key => (key === privateKey ? did : ''),
|
|
946
982
|
});
|
|
947
983
|
expect(recovered.privateKey === privateKey).toBe(true);
|
|
984
|
+
expect(complete).toHaveBeenCalledWith(expect.objectContaining({ options }));
|
|
948
985
|
expect((await storage.getDeviceShare()) === previous).toBe(false);
|
|
949
986
|
expect(
|
|
950
987
|
(await reconstructFromShares([(await storage.getDeviceShare())!, authShare])) ===
|
|
@@ -2032,6 +2069,8 @@ describe('createSSSStrategy', () => {
|
|
|
2032
2069
|
await expect(strategy.completeIdentityRecovery!(fixture.params)).resolves.toEqual({
|
|
2033
2070
|
privateKey: fixture.privateKey,
|
|
2034
2071
|
did: fixture.did,
|
|
2072
|
+
escrowEnrolled: false,
|
|
2073
|
+
escrowRecovery: false,
|
|
2035
2074
|
});
|
|
2036
2075
|
expect(fixture.getRebindCalls()).toBe(1);
|
|
2037
2076
|
expect(vi.mocked(storage.storeDeviceShare).mock.calls.slice(writes)).toEqual([
|