@learncard/sss-key-manager 0.2.3 → 0.2.5
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/README.md +15 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/sss-key-manager.cjs.development.js +8189 -2067
- 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 +8190 -2068
- package/dist/sss-key-manager.esm.js.map +3 -3
- package/dist/sss-strategy.d.ts +35 -0
- package/dist/sss-strategy.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/critical-paths.test.ts +20 -24
- package/src/index.ts +6 -1
- package/src/sss-authority.test.ts +378 -2
- package/src/sss-strategy.test.ts +52 -2
- package/src/sss-strategy.ts +213 -25
package/src/sss-strategy.test.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
createSSSStrategy,
|
|
22
22
|
formatVersionedEmailShare,
|
|
23
23
|
IdentityRecoverySessionConsumedError,
|
|
24
|
+
DuplicateShareCoordinateError,
|
|
24
25
|
parseVersionedEmailShare,
|
|
25
26
|
selectRecoveryPhraseChallengeIndices,
|
|
26
27
|
withRotationLock,
|
|
@@ -223,6 +224,7 @@ describe('escrow strategy', () => {
|
|
|
223
224
|
recoveryMethods: methods,
|
|
224
225
|
escrowOptedOut: optedOut,
|
|
225
226
|
escrowPin: {
|
|
227
|
+
state: !pinSalt ? 'none' : attemptsRemaining > 0 ? 'enabled' : 'locked',
|
|
226
228
|
enabled: !!pinSalt && attemptsRemaining > 0,
|
|
227
229
|
attemptsRemaining,
|
|
228
230
|
...(pinSalt ? { salt: pinSalt } : {}),
|
|
@@ -740,6 +742,33 @@ describe('escrow strategy', () => {
|
|
|
740
742
|
expect(blob?.pinVerifier).toBeUndefined();
|
|
741
743
|
});
|
|
742
744
|
|
|
745
|
+
it('repairs only a prior enrollment when requirePriorEnrollment is set', async () => {
|
|
746
|
+
const options = { clearPin: true, requirePriorEnrollment: true };
|
|
747
|
+
await expect(strategy.ensureEscrowEnrollment!({ ...params, options })).resolves.toEqual({
|
|
748
|
+
enrolled: false,
|
|
749
|
+
reason: 'not-enrolled',
|
|
750
|
+
});
|
|
751
|
+
expect(version).toBe(1);
|
|
752
|
+
await strategy.ensureEscrowEnrollment!(params);
|
|
753
|
+
expect(version).toBe(2);
|
|
754
|
+
await expect(
|
|
755
|
+
strategy.ensureEscrowEnrollment!({ ...params, options })
|
|
756
|
+
).resolves.toMatchObject({ enrolled: true });
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('treats a current PIN-free enrollment as satisfying a clear request', async () => {
|
|
760
|
+
await strategy.ensureEscrowEnrollment!(params);
|
|
761
|
+
expect(version).toBe(2);
|
|
762
|
+
await expect(
|
|
763
|
+
strategy.ensureEscrowEnrollment!({ ...params, options: { clearPin: true } })
|
|
764
|
+
).resolves.toEqual({ enrolled: true, changed: false });
|
|
765
|
+
expect(version).toBe(2);
|
|
766
|
+
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
767
|
+
await strategy.ensureEscrowEnrollment!({ ...params, options: { clearPin: true } });
|
|
768
|
+
expect(version).toBe(4);
|
|
769
|
+
expect(blob?.pinVerifier).toBeUndefined();
|
|
770
|
+
});
|
|
771
|
+
|
|
743
772
|
it.each(['set', 'clear'])('rejects %s PIN when disabled without opting in', async action => {
|
|
744
773
|
config.escrow!.enabled = false;
|
|
745
774
|
await expect(
|
|
@@ -792,7 +821,7 @@ describe('escrow strategy', () => {
|
|
|
792
821
|
it('recovers with a PIN and rotates to hold-only material', async () => {
|
|
793
822
|
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
794
823
|
const recovered = await recoverPin();
|
|
795
|
-
expect(recovered).toEqual({ privateKey, did });
|
|
824
|
+
expect(recovered).toEqual({ privateKey, did, escrowEnrolled: true, escrowRecovery: true });
|
|
796
825
|
expect(version).toBe(3);
|
|
797
826
|
expect(blob?.pinVerifier).toBeUndefined();
|
|
798
827
|
expect(await reconstructFromShares([(await storage.getDeviceShare())!, authShare])).toBe(
|
|
@@ -811,7 +840,12 @@ describe('escrow strategy', () => {
|
|
|
811
840
|
expect(burned).toEqual(['escrow-hold']);
|
|
812
841
|
expect(storage.storeDeviceShare).not.toHaveBeenCalled();
|
|
813
842
|
expect(storage.storeShareVersion).not.toHaveBeenCalled();
|
|
814
|
-
await expect(recoverPin()).resolves.toEqual({
|
|
843
|
+
await expect(recoverPin()).resolves.toEqual({
|
|
844
|
+
privateKey,
|
|
845
|
+
did,
|
|
846
|
+
escrowEnrolled: true,
|
|
847
|
+
escrowRecovery: true,
|
|
848
|
+
});
|
|
815
849
|
const starts = calls.filter(call => call.path === '/keys/escrow/recover');
|
|
816
850
|
const completes = calls.filter(call => call.path === '/keys/escrow/complete');
|
|
817
851
|
expect(starts).toHaveLength(2);
|
|
@@ -937,14 +971,18 @@ describe('escrow strategy', () => {
|
|
|
937
971
|
|
|
938
972
|
it('round trips real crypto, rebinds and persists a new device share', async () => {
|
|
939
973
|
await strategy.ensureEscrowEnrollment!(params);
|
|
974
|
+
const complete = vi.spyOn(strategy, 'completeIdentityRecovery');
|
|
975
|
+
const options = { recoveryEmail: 'owner@example.com', recoveryLanguage: 'es' };
|
|
940
976
|
const previous = await storage.getDeviceShare();
|
|
941
977
|
const start = await strategy.startEscrowRecovery!({ token, providerType });
|
|
942
978
|
const recovered = await strategy.executeRecovery({
|
|
943
979
|
...params,
|
|
980
|
+
options,
|
|
944
981
|
input: { method: 'escrow', ...start, resumeToken: start.resumeToken! },
|
|
945
982
|
didFromPrivateKey: async key => (key === privateKey ? did : ''),
|
|
946
983
|
});
|
|
947
984
|
expect(recovered.privateKey === privateKey).toBe(true);
|
|
985
|
+
expect(complete).toHaveBeenCalledWith(expect.objectContaining({ options }));
|
|
948
986
|
expect((await storage.getDeviceShare()) === previous).toBe(false);
|
|
949
987
|
expect(
|
|
950
988
|
(await reconstructFromShares([(await storage.getDeviceShare())!, authShare])) ===
|
|
@@ -1581,6 +1619,16 @@ describe('createSSSStrategy', () => {
|
|
|
1581
1619
|
|
|
1582
1620
|
expect(reconstructed).toBe(originalKey);
|
|
1583
1621
|
});
|
|
1622
|
+
|
|
1623
|
+
it('classifies duplicate coordinates without changing other reconstruction errors', async () => {
|
|
1624
|
+
const { localKey } = await strategy.splitKey('a'.repeat(64));
|
|
1625
|
+
await expect(strategy.reconstructKey(localKey, localKey)).rejects.toBeInstanceOf(
|
|
1626
|
+
DuplicateShareCoordinateError
|
|
1627
|
+
);
|
|
1628
|
+
await expect(strategy.reconstructKey('bad', 'bad')).rejects.not.toBeInstanceOf(
|
|
1629
|
+
DuplicateShareCoordinateError
|
|
1630
|
+
);
|
|
1631
|
+
});
|
|
1584
1632
|
});
|
|
1585
1633
|
|
|
1586
1634
|
// -----------------------------------------------------------------------
|
|
@@ -2032,6 +2080,8 @@ describe('createSSSStrategy', () => {
|
|
|
2032
2080
|
await expect(strategy.completeIdentityRecovery!(fixture.params)).resolves.toEqual({
|
|
2033
2081
|
privateKey: fixture.privateKey,
|
|
2034
2082
|
did: fixture.did,
|
|
2083
|
+
escrowEnrolled: false,
|
|
2084
|
+
escrowRecovery: false,
|
|
2035
2085
|
});
|
|
2036
2086
|
expect(fixture.getRebindCalls()).toBe(1);
|
|
2037
2087
|
expect(vi.mocked(storage.storeDeviceShare).mock.calls.slice(writes)).toEqual([
|
package/src/sss-strategy.ts
CHANGED
|
@@ -108,6 +108,16 @@ const SSS_DB_NAME = 'lcb-sss-keys';
|
|
|
108
108
|
const DEFAULT_DEVICE_SHARE_ID = 'sss-device-share';
|
|
109
109
|
const MAX_RECONCILIATION_HISTORY = 5;
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* OFF by default. Every input to the uncarryable-PIN inference (recovery methods,
|
|
113
|
+
* share version, PIN state) comes from lca-api, so a compromised API could forge it
|
|
114
|
+
* for a healthy PIN account and have the client sign away the PIN path (Custard7's
|
|
115
|
+
* PR #1815 review). With it off the strategy returns `pin-reset-required` and the
|
|
116
|
+
* app asks the user to set a new PIN. Enabling it needs an independently trustworthy
|
|
117
|
+
* recovery signal and security sign-off: POST_RECOVERY_ESCROW_REPAIR_DESIGN.md, Q1.
|
|
118
|
+
*/
|
|
119
|
+
export const DEFAULT_AUTO_CLEAR_UNCARRYABLE_PIN = false;
|
|
120
|
+
|
|
111
121
|
export interface SSSStorageFunctions {
|
|
112
122
|
storeDeviceShare: (share: string, id?: string) => Promise<void>;
|
|
113
123
|
getDeviceShare: (id?: string) => Promise<string | null>;
|
|
@@ -125,6 +135,22 @@ export interface SSSStrategyConfig {
|
|
|
125
135
|
/** Report enrollment failures without logging recovery material. */
|
|
126
136
|
onEscrowError?: (err: unknown) => void;
|
|
127
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Nitro repair only: when the signed authority still has a PIN but lca-api holds
|
|
140
|
+
* no PIN to carry (an identity-recovery rebind deleted it), sign a PIN `clear`
|
|
141
|
+
* instead of a `carry` that lca-api would reject with PRECONDITION_FAILED.
|
|
142
|
+
* Defaults to {@link DEFAULT_AUTO_CLEAR_UNCARRYABLE_PIN} (off). Do not enable
|
|
143
|
+
* without security sign-off: POST_RECOVERY_ESCROW_REPAIR_DESIGN.md, open question Q1.
|
|
144
|
+
*/
|
|
145
|
+
autoClearUncarryablePin?: boolean;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Called once after every committed automatic PIN clear
|
|
149
|
+
* ({@link SSSStrategyConfig.autoClearUncarryablePin}). Must not throw; defaults
|
|
150
|
+
* to a console warning. Never receives the DID, email or PIN material.
|
|
151
|
+
*/
|
|
152
|
+
onEscrowPinAutoCleared?: (event: EscrowPinAutoClearedEvent) => void;
|
|
153
|
+
|
|
128
154
|
/** Server URL for key share operations */
|
|
129
155
|
serverUrl: string;
|
|
130
156
|
|
|
@@ -193,9 +219,18 @@ const describeHttpStatus = (response: Response): string =>
|
|
|
193
219
|
response.statusText ? `${response.status} ${response.statusText}` : `${response.status}`;
|
|
194
220
|
|
|
195
221
|
type EscrowEnrollmentResult =
|
|
196
|
-
| { enrolled: false; reason: 'disabled' | 'opted-out' }
|
|
222
|
+
| { enrolled: false; reason: 'disabled' | 'opted-out' | 'not-enrolled' | 'pin-reset-required' }
|
|
197
223
|
| { enrolled: true; changed: false }
|
|
198
|
-
| { enrolled: true; changed: true; shareVersion: number };
|
|
224
|
+
| { enrolled: true; changed: true; shareVersion: number; pinCleared?: 'automatic' };
|
|
225
|
+
|
|
226
|
+
/** Telemetry for an automatic post-rebind PIN clear. Carries no DID, email or PIN data. */
|
|
227
|
+
export interface EscrowPinAutoClearedEvent {
|
|
228
|
+
type: 'escrow.pin.auto_cleared';
|
|
229
|
+
/** Share version the PIN-free enrollment was committed at. */
|
|
230
|
+
shareVersion: number;
|
|
231
|
+
/** Share version the signed authority was still active at (pre-rebind). */
|
|
232
|
+
authorityShareVersion: number | null;
|
|
233
|
+
}
|
|
199
234
|
|
|
200
235
|
/** HTTP failure metadata without potentially sensitive server response text. */
|
|
201
236
|
export class EscrowRequestError extends Error {
|
|
@@ -208,6 +243,14 @@ export class EscrowRequestError extends Error {
|
|
|
208
243
|
}
|
|
209
244
|
}
|
|
210
245
|
|
|
246
|
+
/** Independently generated splits can reuse a share's final-byte x-coordinate. */
|
|
247
|
+
export class DuplicateShareCoordinateError extends Error {
|
|
248
|
+
constructor() {
|
|
249
|
+
super('Stored shares have the same coordinate and cannot be combined');
|
|
250
|
+
this.name = 'DuplicateShareCoordinateError';
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
211
254
|
const fetchAuthShareRaw = async (
|
|
212
255
|
serverUrl: string,
|
|
213
256
|
token: string,
|
|
@@ -1065,6 +1108,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1065
1108
|
recoveryShare: string;
|
|
1066
1109
|
authShare: string;
|
|
1067
1110
|
rebindSessionToken: string;
|
|
1111
|
+
/** Recovered from an existing escrow enrollment (hold or PIN), not another method. */
|
|
1112
|
+
fromEscrow: boolean;
|
|
1068
1113
|
rebind?: { shares: SSSShares; storageId: string | undefined; attempted: boolean };
|
|
1069
1114
|
}
|
|
1070
1115
|
| undefined;
|
|
@@ -1421,10 +1466,11 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1421
1466
|
}
|
|
1422
1467
|
};
|
|
1423
1468
|
|
|
1424
|
-
const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<
|
|
1425
|
-
if (!config.escrow?.enabled) return;
|
|
1469
|
+
const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<boolean> => {
|
|
1470
|
+
if (!config.escrow?.enabled) return false;
|
|
1426
1471
|
try {
|
|
1427
1472
|
await enrollEscrow(...args);
|
|
1473
|
+
return true;
|
|
1428
1474
|
} catch (err) {
|
|
1429
1475
|
// A reporting callback must not turn a successful rotation into a failure.
|
|
1430
1476
|
try {
|
|
@@ -1433,7 +1479,28 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1433
1479
|
} catch {
|
|
1434
1480
|
console.warn('SSS: escrow error reporting failed');
|
|
1435
1481
|
}
|
|
1482
|
+
return false;
|
|
1483
|
+
}
|
|
1484
|
+
};
|
|
1485
|
+
|
|
1486
|
+
/** Report a committed automatic PIN clear so the caller can prompt for a new PIN. */
|
|
1487
|
+
const withAutoClear = (
|
|
1488
|
+
shareVersion: number,
|
|
1489
|
+
autoClearedFrom: { authorityShareVersion: number | null } | undefined
|
|
1490
|
+
): EscrowEnrollmentResult => {
|
|
1491
|
+
if (!autoClearedFrom) return { enrolled: true, changed: true, shareVersion };
|
|
1492
|
+
try {
|
|
1493
|
+
const event: EscrowPinAutoClearedEvent = {
|
|
1494
|
+
type: 'escrow.pin.auto_cleared',
|
|
1495
|
+
shareVersion,
|
|
1496
|
+
authorityShareVersion: autoClearedFrom.authorityShareVersion,
|
|
1497
|
+
};
|
|
1498
|
+
if (config.onEscrowPinAutoCleared) config.onEscrowPinAutoCleared(event);
|
|
1499
|
+
else console.warn('SSS: escrow PIN cleared automatically', event);
|
|
1500
|
+
} catch {
|
|
1501
|
+
console.warn('SSS: escrow event reporting failed');
|
|
1436
1502
|
}
|
|
1503
|
+
return { enrolled: true, changed: true, shareVersion, pinCleared: 'automatic' };
|
|
1437
1504
|
};
|
|
1438
1505
|
|
|
1439
1506
|
const ensureEscrowEnrollmentUnguarded = async (
|
|
@@ -1443,7 +1510,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1443
1510
|
forceRotate = false,
|
|
1444
1511
|
allowDisabledEnrollment = false
|
|
1445
1512
|
): Promise<EscrowEnrollmentResult> => {
|
|
1446
|
-
let clearPinForEnrollment = forceRotate;
|
|
1513
|
+
let clearPinForEnrollment = forceRotate || params.options?.clearPin === true;
|
|
1514
|
+
let autoClearedFrom: { authorityShareVersion: number | null } | undefined;
|
|
1447
1515
|
if (!config.escrow?.enabled) return { enrolled: false, reason: 'disabled' };
|
|
1448
1516
|
const pin = params.options?.pin;
|
|
1449
1517
|
const recoveryEmail = params.options?.recoveryEmail ?? '';
|
|
@@ -1470,7 +1538,15 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1470
1538
|
('confirmationStatus' in method &&
|
|
1471
1539
|
method.confirmationStatus === 'confirmed'))
|
|
1472
1540
|
);
|
|
1541
|
+
if (
|
|
1542
|
+
params.options?.requirePriorEnrollment &&
|
|
1543
|
+
config.escrow.attestation.mode !== 'nitro' &&
|
|
1544
|
+
!status.recoveryMethods.some(method => method.type === 'escrow')
|
|
1545
|
+
)
|
|
1546
|
+
return { enrolled: false, reason: 'not-enrolled' };
|
|
1473
1547
|
let verifiedKey: VerifiedEnclaveIdentity | undefined;
|
|
1548
|
+
// Locked, stale or unknown PIN state never satisfies a clear request.
|
|
1549
|
+
let hasPin = status.escrowPin?.state !== 'none';
|
|
1474
1550
|
if (config.escrow.attestation.mode === 'nitro') {
|
|
1475
1551
|
if (!status.primaryDid || !params.signDidAuthVp)
|
|
1476
1552
|
throw new Error('Owner proof signing is required for escrow enrollment.');
|
|
@@ -1484,6 +1560,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1484
1560
|
verifiedKey,
|
|
1485
1561
|
storage.assertCurrent
|
|
1486
1562
|
);
|
|
1563
|
+
// The signed authority survives the identity rebind that drops Mongo's blob.
|
|
1564
|
+
if (params.options?.requirePriorEnrollment && current.status === 'empty')
|
|
1565
|
+
return { enrolled: false, reason: 'not-enrolled' };
|
|
1487
1566
|
if (current.status === 'disabled') {
|
|
1488
1567
|
verifiedEscrowEnvelope = undefined;
|
|
1489
1568
|
if (!allowDisabledEnrollment) return { enrolled: false, reason: 'opted-out' };
|
|
@@ -1496,7 +1575,51 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1496
1575
|
current.shareVersion === status.shareVersion &&
|
|
1497
1576
|
current.envelope?.keyId === verifiedKey.keyId;
|
|
1498
1577
|
if (currentlyEnrolled) verifiedEscrowEnvelope = current.envelope ?? undefined;
|
|
1578
|
+
hasPin = current.hasPin;
|
|
1579
|
+
// After an identity-recovery rebind lca-api has no PIN left to carry, so an
|
|
1580
|
+
// automatic carry/keep can only fail.
|
|
1581
|
+
const uncarryablePin =
|
|
1582
|
+
!clearPinForEnrollment &&
|
|
1583
|
+
current.status === 'active' &&
|
|
1584
|
+
current.hasPin &&
|
|
1585
|
+
pin === undefined &&
|
|
1586
|
+
!currentlyEnrolled &&
|
|
1587
|
+
status.escrowPin?.state === 'none';
|
|
1588
|
+
// Opt-in only (design doc Q1): sign a clear instead. Rebind evidence: the
|
|
1589
|
+
// signed authority is still active at an older share version, and lca-api
|
|
1590
|
+
// lists no confirmed escrow method at any version. All but the authority
|
|
1591
|
+
// are lca-api claims, so this is forgeable by a compromised API.
|
|
1592
|
+
if (
|
|
1593
|
+
uncarryablePin &&
|
|
1594
|
+
(config.autoClearUncarryablePin ?? DEFAULT_AUTO_CLEAR_UNCARRYABLE_PIN) &&
|
|
1595
|
+
current.shareVersion !== null &&
|
|
1596
|
+
status.shareVersion !== null &&
|
|
1597
|
+
current.shareVersion < status.shareVersion &&
|
|
1598
|
+
!status.recoveryMethods.some(
|
|
1599
|
+
method =>
|
|
1600
|
+
method.type === 'escrow' &&
|
|
1601
|
+
(Boolean(method.confirmedAt) ||
|
|
1602
|
+
('confirmationStatus' in method &&
|
|
1603
|
+
method.confirmationStatus === 'confirmed'))
|
|
1604
|
+
)
|
|
1605
|
+
) {
|
|
1606
|
+
clearPinForEnrollment = true;
|
|
1607
|
+
autoClearedFrom = { authorityShareVersion: current.shareVersion };
|
|
1608
|
+
} else if (uncarryablePin) {
|
|
1609
|
+
// Never rotate or sign here: only a user-chosen PIN (`set`) resolves it.
|
|
1610
|
+
return { enrolled: false, reason: 'pin-reset-required' };
|
|
1611
|
+
}
|
|
1499
1612
|
}
|
|
1613
|
+
// A current, PIN-free enrollment already satisfies a clear request
|
|
1614
|
+
// (e.g. post-recovery repair after direct enrollment succeeded).
|
|
1615
|
+
if (
|
|
1616
|
+
currentlyEnrolled &&
|
|
1617
|
+
!forceRotate &&
|
|
1618
|
+
pin === undefined &&
|
|
1619
|
+
!hasPin &&
|
|
1620
|
+
!status.escrowStale
|
|
1621
|
+
)
|
|
1622
|
+
clearPinForEnrollment = false;
|
|
1500
1623
|
// A confirmed enrollment whose sealed blob no longer matches the active
|
|
1501
1624
|
// enclave (P6 mode migration/rollback, or a rotated enclave key) still
|
|
1502
1625
|
// needs the same rotation a missing enrollment would get — but only one
|
|
@@ -1519,7 +1642,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1519
1642
|
}
|
|
1520
1643
|
try {
|
|
1521
1644
|
if (
|
|
1522
|
-
!
|
|
1645
|
+
!forceRotate &&
|
|
1523
1646
|
pin === undefined &&
|
|
1524
1647
|
unenrolledRotation &&
|
|
1525
1648
|
unenrolledRotation.shareVersion === status.shareVersion &&
|
|
@@ -1536,13 +1659,13 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1536
1659
|
params.signDidAuthVp,
|
|
1537
1660
|
undefined,
|
|
1538
1661
|
undefined,
|
|
1539
|
-
retry.clearPin,
|
|
1662
|
+
clearPinForEnrollment || retry.clearPin,
|
|
1540
1663
|
storage.assertCurrent,
|
|
1541
1664
|
recoveryEmail,
|
|
1542
1665
|
recoveryLanguage
|
|
1543
1666
|
);
|
|
1544
1667
|
unenrolledRotation = undefined;
|
|
1545
|
-
return
|
|
1668
|
+
return withAutoClear(retry.shareVersion, autoClearedFrom);
|
|
1546
1669
|
}
|
|
1547
1670
|
// Verify the enclave before rotating: a bad attestation must not burn
|
|
1548
1671
|
// a share version (and, repeated, the retained auth-share history).
|
|
@@ -1601,7 +1724,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1601
1724
|
recoveryLanguage
|
|
1602
1725
|
);
|
|
1603
1726
|
unenrolledRotation = undefined;
|
|
1604
|
-
return
|
|
1727
|
+
return withAutoClear(shareVersion, autoClearedFrom);
|
|
1605
1728
|
} catch (err) {
|
|
1606
1729
|
// A background repair the caller never asked for must not surface as
|
|
1607
1730
|
// a hard failure (mirrors tryEnrollEscrow's existing swallow-and-report
|
|
@@ -1737,6 +1860,16 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1737
1860
|
},
|
|
1738
1861
|
|
|
1739
1862
|
async reconstructKey(localKey: string, remoteKey: string): Promise<string> {
|
|
1863
|
+
const coordinate = localKey.slice(-2).toLowerCase();
|
|
1864
|
+
if (
|
|
1865
|
+
localKey.length === remoteKey.length &&
|
|
1866
|
+
/^(?:[0-9a-f]{2}){2,}$/i.test(localKey) &&
|
|
1867
|
+
/^(?:[0-9a-f]{2}){2,}$/i.test(remoteKey) &&
|
|
1868
|
+
coordinate !== '00' &&
|
|
1869
|
+
coordinate === remoteKey.slice(-2).toLowerCase()
|
|
1870
|
+
) {
|
|
1871
|
+
throw new DuplicateShareCoordinateError();
|
|
1872
|
+
}
|
|
1740
1873
|
return reconstructFromShares([localKey, remoteKey]);
|
|
1741
1874
|
},
|
|
1742
1875
|
|
|
@@ -2036,6 +2169,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
2036
2169
|
escrowPin: data.escrowPin,
|
|
2037
2170
|
escrowStale: data.escrowStale,
|
|
2038
2171
|
sssActivationState: data.sssActivationState ?? 'active',
|
|
2172
|
+
...(typeof data.priorEscrowEnrollment === 'boolean'
|
|
2173
|
+
? { priorEscrowEnrollment: data.priorEscrowEnrollment }
|
|
2174
|
+
: {}),
|
|
2039
2175
|
};
|
|
2040
2176
|
},
|
|
2041
2177
|
|
|
@@ -2123,7 +2259,19 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
2123
2259
|
escrowPin: status.escrowPin,
|
|
2124
2260
|
staleReason: status.escrowStale,
|
|
2125
2261
|
};
|
|
2126
|
-
return { state:
|
|
2262
|
+
if (enrolled) return { state: 'enrolled', escrowPin: status.escrowPin };
|
|
2263
|
+
// A rebind leaves escrow methods `pending`, which /keys/auth-share omits but
|
|
2264
|
+
// reports as priorEscrowEnrollment; flag them so the caller can request a
|
|
2265
|
+
// prior-enrollment repair instead of a first-time enrollment. Older servers
|
|
2266
|
+
// lack the field, so fall back to the (confirmed-only) method list.
|
|
2267
|
+
const priorEscrowMethod =
|
|
2268
|
+
status.priorEscrowEnrollment ??
|
|
2269
|
+
status.recoveryMethods.some(method => method.type === 'escrow');
|
|
2270
|
+
return {
|
|
2271
|
+
state: 'not-enrolled',
|
|
2272
|
+
escrowPin: status.escrowPin,
|
|
2273
|
+
...(priorEscrowMethod ? { priorEscrowMethod: true } : {}),
|
|
2274
|
+
};
|
|
2127
2275
|
},
|
|
2128
2276
|
|
|
2129
2277
|
async disableEscrowRecovery(params): Promise<void> {
|
|
@@ -2220,7 +2368,20 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
2220
2368
|
providerType: params.providerType,
|
|
2221
2369
|
}),
|
|
2222
2370
|
});
|
|
2223
|
-
|
|
2371
|
+
const result = await ensureEscrowEnrollmentUnguarded(
|
|
2372
|
+
this,
|
|
2373
|
+
params,
|
|
2374
|
+
storage,
|
|
2375
|
+
false,
|
|
2376
|
+
true
|
|
2377
|
+
);
|
|
2378
|
+
// Explicit enable never requests prior-enrollment-only repair.
|
|
2379
|
+
if (result.enrolled) return result;
|
|
2380
|
+
if (result.reason === 'not-enrolled')
|
|
2381
|
+
throw new Error('Escrow enrollment did not complete');
|
|
2382
|
+
if (result.reason === 'pin-reset-required')
|
|
2383
|
+
throw new Error('Set a new recovery PIN to turn on automatic recovery.');
|
|
2384
|
+
return { enrolled: false as const, reason: result.reason };
|
|
2224
2385
|
});
|
|
2225
2386
|
},
|
|
2226
2387
|
|
|
@@ -2478,6 +2639,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
2478
2639
|
input: RecoveryInput;
|
|
2479
2640
|
didFromPrivateKey?: (privateKey: string) => Promise<string>;
|
|
2480
2641
|
signDidAuthVp?: DidAuthVpSigner;
|
|
2642
|
+
options?: { recoveryEmail?: string; recoveryLanguage?: string };
|
|
2481
2643
|
}): Promise<RecoveryResult> {
|
|
2482
2644
|
const storage = guardedStorage();
|
|
2483
2645
|
const { token, providerType, input, didFromPrivateKey, signDidAuthVp } = params;
|
|
@@ -2565,11 +2727,17 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
2565
2727
|
recoveryShare: plaintext.recoveryShare,
|
|
2566
2728
|
authShare,
|
|
2567
2729
|
rebindSessionToken: material.rebindSessionToken,
|
|
2730
|
+
fromEscrow: true,
|
|
2568
2731
|
};
|
|
2569
2732
|
// The OTP path uses exactly this pending state and atomic rebind operation.
|
|
2570
2733
|
// Without a provider session the caller completes it after signing in.
|
|
2571
2734
|
if (!token) return { privateKey, did: material.primaryDid };
|
|
2572
|
-
return this.completeIdentityRecovery!({
|
|
2735
|
+
return this.completeIdentityRecovery!({
|
|
2736
|
+
token,
|
|
2737
|
+
providerType,
|
|
2738
|
+
signDidAuthVp,
|
|
2739
|
+
options: params.options,
|
|
2740
|
+
});
|
|
2573
2741
|
}
|
|
2574
2742
|
|
|
2575
2743
|
let recoveryShare: string;
|
|
@@ -3295,6 +3463,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
3295
3463
|
recoveryShare,
|
|
3296
3464
|
authShare,
|
|
3297
3465
|
rebindSessionToken: material.rebindSessionToken,
|
|
3466
|
+
fromEscrow: false,
|
|
3298
3467
|
};
|
|
3299
3468
|
|
|
3300
3469
|
return { privateKey, did: material.primaryDid };
|
|
@@ -3465,22 +3634,41 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
3465
3634
|
})
|
|
3466
3635
|
);
|
|
3467
3636
|
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3637
|
+
// Other recovery methods leave enrollment to the caller's rollout gate.
|
|
3638
|
+
const enrolled =
|
|
3639
|
+
pending.fromEscrow &&
|
|
3640
|
+
(await tryEnrollEscrow(
|
|
3641
|
+
params.token,
|
|
3642
|
+
params.providerType,
|
|
3643
|
+
pending.privateKey,
|
|
3644
|
+
pending.primaryDid,
|
|
3645
|
+
shares,
|
|
3646
|
+
shareVersion,
|
|
3647
|
+
signDidAuthVp,
|
|
3648
|
+
undefined,
|
|
3649
|
+
undefined,
|
|
3650
|
+
true,
|
|
3651
|
+
storage.assertCurrent,
|
|
3652
|
+
params.options?.recoveryEmail,
|
|
3653
|
+
params.options?.recoveryLanguage
|
|
3654
|
+
));
|
|
3480
3655
|
if (activeStorageId !== storageId || storageGeneration !== generation) {
|
|
3481
3656
|
throw new Error('This recovery request was cancelled.');
|
|
3482
3657
|
}
|
|
3483
|
-
|
|
3658
|
+
if (!enrolled) {
|
|
3659
|
+
unenrolledRotation = {
|
|
3660
|
+
shares,
|
|
3661
|
+
shareVersion,
|
|
3662
|
+
primaryDid: pending.primaryDid,
|
|
3663
|
+
clearPin: true,
|
|
3664
|
+
};
|
|
3665
|
+
}
|
|
3666
|
+
return {
|
|
3667
|
+
privateKey: pending.privateKey,
|
|
3668
|
+
did: pending.primaryDid,
|
|
3669
|
+
escrowEnrolled: enrolled,
|
|
3670
|
+
escrowRecovery: pending.fromEscrow,
|
|
3671
|
+
};
|
|
3484
3672
|
},
|
|
3485
3673
|
|
|
3486
3674
|
// --- Contact method management ---
|