@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.
- package/dist/escrow-authority.d.ts +26 -0
- package/dist/escrow-authority.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/sss-key-manager.cjs.development.js +8231 -2064
- 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 +8232 -2065
- package/dist/sss-key-manager.esm.js.map +3 -3
- package/dist/sss-strategy.d.ts +33 -1
- package/dist/sss-strategy.d.ts.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/escrow-authority.test.ts +114 -0
- package/src/escrow-authority.ts +74 -0
- package/src/index.ts +7 -1
- package/src/sss-authority.test.ts +378 -2
- package/src/sss-strategy.test.ts +73 -2
- package/src/sss-strategy.ts +219 -26
- package/src/types.ts +5 -0
package/src/sss-strategy.ts
CHANGED
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
verifyEscrowAuthorityState,
|
|
60
60
|
verifyEscrowEnrollmentCommit,
|
|
61
61
|
verifyEscrowHoldRecord,
|
|
62
|
+
verifyEscrowCodeDelivery,
|
|
62
63
|
verifyEscrowHoldConfirmation,
|
|
63
64
|
escrowAuthorityMeasurements,
|
|
64
65
|
type EscrowHoldRecordLike,
|
|
@@ -107,6 +108,16 @@ const SSS_DB_NAME = 'lcb-sss-keys';
|
|
|
107
108
|
const DEFAULT_DEVICE_SHARE_ID = 'sss-device-share';
|
|
108
109
|
const MAX_RECONCILIATION_HISTORY = 5;
|
|
109
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
|
+
|
|
110
121
|
export interface SSSStorageFunctions {
|
|
111
122
|
storeDeviceShare: (share: string, id?: string) => Promise<void>;
|
|
112
123
|
getDeviceShare: (id?: string) => Promise<string | null>;
|
|
@@ -124,6 +135,22 @@ export interface SSSStrategyConfig {
|
|
|
124
135
|
/** Report enrollment failures without logging recovery material. */
|
|
125
136
|
onEscrowError?: (err: unknown) => void;
|
|
126
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
|
+
|
|
127
154
|
/** Server URL for key share operations */
|
|
128
155
|
serverUrl: string;
|
|
129
156
|
|
|
@@ -192,13 +219,25 @@ const describeHttpStatus = (response: Response): string =>
|
|
|
192
219
|
response.statusText ? `${response.status} ${response.statusText}` : `${response.status}`;
|
|
193
220
|
|
|
194
221
|
type EscrowEnrollmentResult =
|
|
195
|
-
| { enrolled: false; reason: 'disabled' | 'opted-out' }
|
|
222
|
+
| { enrolled: false; reason: 'disabled' | 'opted-out' | 'not-enrolled' | 'pin-reset-required' }
|
|
196
223
|
| { enrolled: true; changed: false }
|
|
197
|
-
| { 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
|
+
}
|
|
198
234
|
|
|
199
235
|
/** HTTP failure metadata without potentially sensitive server response text. */
|
|
200
236
|
export class EscrowRequestError extends Error {
|
|
201
|
-
constructor(
|
|
237
|
+
constructor(
|
|
238
|
+
public readonly status: number,
|
|
239
|
+
public readonly reason?: 'codeMismatch' | 'codeExpired' | 'codeLocked'
|
|
240
|
+
) {
|
|
202
241
|
super('Escrow request failed');
|
|
203
242
|
this.name = 'EscrowRequestError';
|
|
204
243
|
}
|
|
@@ -1061,6 +1100,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1061
1100
|
recoveryShare: string;
|
|
1062
1101
|
authShare: string;
|
|
1063
1102
|
rebindSessionToken: string;
|
|
1103
|
+
/** Recovered from an existing escrow enrollment (hold or PIN), not another method. */
|
|
1104
|
+
fromEscrow: boolean;
|
|
1064
1105
|
rebind?: { shares: SSSShares; storageId: string | undefined; attempted: boolean };
|
|
1065
1106
|
}
|
|
1066
1107
|
| undefined;
|
|
@@ -1099,6 +1140,22 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1099
1140
|
});
|
|
1100
1141
|
// Never propagate response bodies or status text that could contain secrets.
|
|
1101
1142
|
if (!response.ok) {
|
|
1143
|
+
if (path === '/confirm' && response.status === 403) {
|
|
1144
|
+
const body: unknown = await response.json().catch(() => undefined);
|
|
1145
|
+
const reason =
|
|
1146
|
+
body &&
|
|
1147
|
+
typeof body === 'object' &&
|
|
1148
|
+
'message' in body &&
|
|
1149
|
+
typeof body.message === 'string'
|
|
1150
|
+
? body.message
|
|
1151
|
+
: undefined;
|
|
1152
|
+
throw new EscrowRequestError(
|
|
1153
|
+
response.status,
|
|
1154
|
+
reason === 'codeMismatch' || reason === 'codeExpired' || reason === 'codeLocked'
|
|
1155
|
+
? reason
|
|
1156
|
+
: undefined
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1102
1159
|
if (!pinAttempt && path === '/recover' && response.status === 429) {
|
|
1103
1160
|
const body: unknown = await response.json().catch(() => undefined);
|
|
1104
1161
|
const message =
|
|
@@ -1401,10 +1458,11 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1401
1458
|
}
|
|
1402
1459
|
};
|
|
1403
1460
|
|
|
1404
|
-
const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<
|
|
1405
|
-
if (!config.escrow?.enabled) return;
|
|
1461
|
+
const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<boolean> => {
|
|
1462
|
+
if (!config.escrow?.enabled) return false;
|
|
1406
1463
|
try {
|
|
1407
1464
|
await enrollEscrow(...args);
|
|
1465
|
+
return true;
|
|
1408
1466
|
} catch (err) {
|
|
1409
1467
|
// A reporting callback must not turn a successful rotation into a failure.
|
|
1410
1468
|
try {
|
|
@@ -1413,9 +1471,30 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1413
1471
|
} catch {
|
|
1414
1472
|
console.warn('SSS: escrow error reporting failed');
|
|
1415
1473
|
}
|
|
1474
|
+
return false;
|
|
1416
1475
|
}
|
|
1417
1476
|
};
|
|
1418
1477
|
|
|
1478
|
+
/** Report a committed automatic PIN clear so the caller can prompt for a new PIN. */
|
|
1479
|
+
const withAutoClear = (
|
|
1480
|
+
shareVersion: number,
|
|
1481
|
+
autoClearedFrom: { authorityShareVersion: number | null } | undefined
|
|
1482
|
+
): EscrowEnrollmentResult => {
|
|
1483
|
+
if (!autoClearedFrom) return { enrolled: true, changed: true, shareVersion };
|
|
1484
|
+
try {
|
|
1485
|
+
const event: EscrowPinAutoClearedEvent = {
|
|
1486
|
+
type: 'escrow.pin.auto_cleared',
|
|
1487
|
+
shareVersion,
|
|
1488
|
+
authorityShareVersion: autoClearedFrom.authorityShareVersion,
|
|
1489
|
+
};
|
|
1490
|
+
if (config.onEscrowPinAutoCleared) config.onEscrowPinAutoCleared(event);
|
|
1491
|
+
else console.warn('SSS: escrow PIN cleared automatically', event);
|
|
1492
|
+
} catch {
|
|
1493
|
+
console.warn('SSS: escrow event reporting failed');
|
|
1494
|
+
}
|
|
1495
|
+
return { enrolled: true, changed: true, shareVersion, pinCleared: 'automatic' };
|
|
1496
|
+
};
|
|
1497
|
+
|
|
1419
1498
|
const ensureEscrowEnrollmentUnguarded = async (
|
|
1420
1499
|
self: SSSKeyDerivationStrategy,
|
|
1421
1500
|
params: Parameters<NonNullable<SSSKeyDerivationStrategy['ensureEscrowEnrollment']>>[0],
|
|
@@ -1423,7 +1502,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1423
1502
|
forceRotate = false,
|
|
1424
1503
|
allowDisabledEnrollment = false
|
|
1425
1504
|
): Promise<EscrowEnrollmentResult> => {
|
|
1426
|
-
let clearPinForEnrollment = forceRotate;
|
|
1505
|
+
let clearPinForEnrollment = forceRotate || params.options?.clearPin === true;
|
|
1506
|
+
let autoClearedFrom: { authorityShareVersion: number | null } | undefined;
|
|
1427
1507
|
if (!config.escrow?.enabled) return { enrolled: false, reason: 'disabled' };
|
|
1428
1508
|
const pin = params.options?.pin;
|
|
1429
1509
|
const recoveryEmail = params.options?.recoveryEmail ?? '';
|
|
@@ -1450,7 +1530,15 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1450
1530
|
('confirmationStatus' in method &&
|
|
1451
1531
|
method.confirmationStatus === 'confirmed'))
|
|
1452
1532
|
);
|
|
1533
|
+
if (
|
|
1534
|
+
params.options?.requirePriorEnrollment &&
|
|
1535
|
+
config.escrow.attestation.mode !== 'nitro' &&
|
|
1536
|
+
!status.recoveryMethods.some(method => method.type === 'escrow')
|
|
1537
|
+
)
|
|
1538
|
+
return { enrolled: false, reason: 'not-enrolled' };
|
|
1453
1539
|
let verifiedKey: VerifiedEnclaveIdentity | undefined;
|
|
1540
|
+
// Locked, stale or unknown PIN state never satisfies a clear request.
|
|
1541
|
+
let hasPin = status.escrowPin?.state !== 'none';
|
|
1454
1542
|
if (config.escrow.attestation.mode === 'nitro') {
|
|
1455
1543
|
if (!status.primaryDid || !params.signDidAuthVp)
|
|
1456
1544
|
throw new Error('Owner proof signing is required for escrow enrollment.');
|
|
@@ -1464,6 +1552,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1464
1552
|
verifiedKey,
|
|
1465
1553
|
storage.assertCurrent
|
|
1466
1554
|
);
|
|
1555
|
+
// The signed authority survives the identity rebind that drops Mongo's blob.
|
|
1556
|
+
if (params.options?.requirePriorEnrollment && current.status === 'empty')
|
|
1557
|
+
return { enrolled: false, reason: 'not-enrolled' };
|
|
1467
1558
|
if (current.status === 'disabled') {
|
|
1468
1559
|
verifiedEscrowEnvelope = undefined;
|
|
1469
1560
|
if (!allowDisabledEnrollment) return { enrolled: false, reason: 'opted-out' };
|
|
@@ -1476,7 +1567,51 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1476
1567
|
current.shareVersion === status.shareVersion &&
|
|
1477
1568
|
current.envelope?.keyId === verifiedKey.keyId;
|
|
1478
1569
|
if (currentlyEnrolled) verifiedEscrowEnvelope = current.envelope ?? undefined;
|
|
1570
|
+
hasPin = current.hasPin;
|
|
1571
|
+
// After an identity-recovery rebind lca-api has no PIN left to carry, so an
|
|
1572
|
+
// automatic carry/keep can only fail.
|
|
1573
|
+
const uncarryablePin =
|
|
1574
|
+
!clearPinForEnrollment &&
|
|
1575
|
+
current.status === 'active' &&
|
|
1576
|
+
current.hasPin &&
|
|
1577
|
+
pin === undefined &&
|
|
1578
|
+
!currentlyEnrolled &&
|
|
1579
|
+
status.escrowPin?.state === 'none';
|
|
1580
|
+
// Opt-in only (design doc Q1): sign a clear instead. Rebind evidence: the
|
|
1581
|
+
// signed authority is still active at an older share version, and lca-api
|
|
1582
|
+
// lists no confirmed escrow method at any version. All but the authority
|
|
1583
|
+
// are lca-api claims, so this is forgeable by a compromised API.
|
|
1584
|
+
if (
|
|
1585
|
+
uncarryablePin &&
|
|
1586
|
+
(config.autoClearUncarryablePin ?? DEFAULT_AUTO_CLEAR_UNCARRYABLE_PIN) &&
|
|
1587
|
+
current.shareVersion !== null &&
|
|
1588
|
+
status.shareVersion !== null &&
|
|
1589
|
+
current.shareVersion < status.shareVersion &&
|
|
1590
|
+
!status.recoveryMethods.some(
|
|
1591
|
+
method =>
|
|
1592
|
+
method.type === 'escrow' &&
|
|
1593
|
+
(Boolean(method.confirmedAt) ||
|
|
1594
|
+
('confirmationStatus' in method &&
|
|
1595
|
+
method.confirmationStatus === 'confirmed'))
|
|
1596
|
+
)
|
|
1597
|
+
) {
|
|
1598
|
+
clearPinForEnrollment = true;
|
|
1599
|
+
autoClearedFrom = { authorityShareVersion: current.shareVersion };
|
|
1600
|
+
} else if (uncarryablePin) {
|
|
1601
|
+
// Never rotate or sign here: only a user-chosen PIN (`set`) resolves it.
|
|
1602
|
+
return { enrolled: false, reason: 'pin-reset-required' };
|
|
1603
|
+
}
|
|
1479
1604
|
}
|
|
1605
|
+
// A current, PIN-free enrollment already satisfies a clear request
|
|
1606
|
+
// (e.g. post-recovery repair after direct enrollment succeeded).
|
|
1607
|
+
if (
|
|
1608
|
+
currentlyEnrolled &&
|
|
1609
|
+
!forceRotate &&
|
|
1610
|
+
pin === undefined &&
|
|
1611
|
+
!hasPin &&
|
|
1612
|
+
!status.escrowStale
|
|
1613
|
+
)
|
|
1614
|
+
clearPinForEnrollment = false;
|
|
1480
1615
|
// A confirmed enrollment whose sealed blob no longer matches the active
|
|
1481
1616
|
// enclave (P6 mode migration/rollback, or a rotated enclave key) still
|
|
1482
1617
|
// needs the same rotation a missing enrollment would get — but only one
|
|
@@ -1499,7 +1634,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1499
1634
|
}
|
|
1500
1635
|
try {
|
|
1501
1636
|
if (
|
|
1502
|
-
!
|
|
1637
|
+
!forceRotate &&
|
|
1503
1638
|
pin === undefined &&
|
|
1504
1639
|
unenrolledRotation &&
|
|
1505
1640
|
unenrolledRotation.shareVersion === status.shareVersion &&
|
|
@@ -1516,13 +1651,13 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1516
1651
|
params.signDidAuthVp,
|
|
1517
1652
|
undefined,
|
|
1518
1653
|
undefined,
|
|
1519
|
-
retry.clearPin,
|
|
1654
|
+
clearPinForEnrollment || retry.clearPin,
|
|
1520
1655
|
storage.assertCurrent,
|
|
1521
1656
|
recoveryEmail,
|
|
1522
1657
|
recoveryLanguage
|
|
1523
1658
|
);
|
|
1524
1659
|
unenrolledRotation = undefined;
|
|
1525
|
-
return
|
|
1660
|
+
return withAutoClear(retry.shareVersion, autoClearedFrom);
|
|
1526
1661
|
}
|
|
1527
1662
|
// Verify the enclave before rotating: a bad attestation must not burn
|
|
1528
1663
|
// a share version (and, repeated, the retained auth-share history).
|
|
@@ -1581,7 +1716,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1581
1716
|
recoveryLanguage
|
|
1582
1717
|
);
|
|
1583
1718
|
unenrolledRotation = undefined;
|
|
1584
|
-
return
|
|
1719
|
+
return withAutoClear(shareVersion, autoClearedFrom);
|
|
1585
1720
|
} catch (err) {
|
|
1586
1721
|
// A background repair the caller never asked for must not surface as
|
|
1587
1722
|
// a hard failure (mirrors tryEnrollEscrow's existing swallow-and-report
|
|
@@ -2016,6 +2151,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
2016
2151
|
escrowPin: data.escrowPin,
|
|
2017
2152
|
escrowStale: data.escrowStale,
|
|
2018
2153
|
sssActivationState: data.sssActivationState ?? 'active',
|
|
2154
|
+
...(typeof data.priorEscrowEnrollment === 'boolean'
|
|
2155
|
+
? { priorEscrowEnrollment: data.priorEscrowEnrollment }
|
|
2156
|
+
: {}),
|
|
2019
2157
|
};
|
|
2020
2158
|
},
|
|
2021
2159
|
|
|
@@ -2103,7 +2241,19 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
2103
2241
|
escrowPin: status.escrowPin,
|
|
2104
2242
|
staleReason: status.escrowStale,
|
|
2105
2243
|
};
|
|
2106
|
-
return { state:
|
|
2244
|
+
if (enrolled) return { state: 'enrolled', escrowPin: status.escrowPin };
|
|
2245
|
+
// A rebind leaves escrow methods `pending`, which /keys/auth-share omits but
|
|
2246
|
+
// reports as priorEscrowEnrollment; flag them so the caller can request a
|
|
2247
|
+
// prior-enrollment repair instead of a first-time enrollment. Older servers
|
|
2248
|
+
// lack the field, so fall back to the (confirmed-only) method list.
|
|
2249
|
+
const priorEscrowMethod =
|
|
2250
|
+
status.priorEscrowEnrollment ??
|
|
2251
|
+
status.recoveryMethods.some(method => method.type === 'escrow');
|
|
2252
|
+
return {
|
|
2253
|
+
state: 'not-enrolled',
|
|
2254
|
+
escrowPin: status.escrowPin,
|
|
2255
|
+
...(priorEscrowMethod ? { priorEscrowMethod: true } : {}),
|
|
2256
|
+
};
|
|
2107
2257
|
},
|
|
2108
2258
|
|
|
2109
2259
|
async disableEscrowRecovery(params): Promise<void> {
|
|
@@ -2200,7 +2350,20 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
2200
2350
|
providerType: params.providerType,
|
|
2201
2351
|
}),
|
|
2202
2352
|
});
|
|
2203
|
-
|
|
2353
|
+
const result = await ensureEscrowEnrollmentUnguarded(
|
|
2354
|
+
this,
|
|
2355
|
+
params,
|
|
2356
|
+
storage,
|
|
2357
|
+
false,
|
|
2358
|
+
true
|
|
2359
|
+
);
|
|
2360
|
+
// Explicit enable never requests prior-enrollment-only repair.
|
|
2361
|
+
if (result.enrolled) return result;
|
|
2362
|
+
if (result.reason === 'not-enrolled')
|
|
2363
|
+
throw new Error('Escrow enrollment did not complete');
|
|
2364
|
+
if (result.reason === 'pin-reset-required')
|
|
2365
|
+
throw new Error('Set a new recovery PIN to turn on automatic recovery.');
|
|
2366
|
+
return { enrolled: false as const, reason: result.reason };
|
|
2204
2367
|
});
|
|
2205
2368
|
},
|
|
2206
2369
|
|
|
@@ -2334,6 +2497,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
2334
2497
|
ledgerPublicKey: identity.authority.ledgerPublicKey,
|
|
2335
2498
|
keyId: verified.receipt.keyId,
|
|
2336
2499
|
};
|
|
2500
|
+
result.codeSent = result.delivery
|
|
2501
|
+
? await verifyEscrowCodeDelivery(result.delivery, expectations, identity)
|
|
2502
|
+
: undefined;
|
|
2337
2503
|
}
|
|
2338
2504
|
// Never accept a server-supplied expectations object, even in software mode.
|
|
2339
2505
|
return { ...result, clientEphemeralPrivateKey: pair.privateKey, expectations };
|
|
@@ -2455,6 +2621,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
2455
2621
|
input: RecoveryInput;
|
|
2456
2622
|
didFromPrivateKey?: (privateKey: string) => Promise<string>;
|
|
2457
2623
|
signDidAuthVp?: DidAuthVpSigner;
|
|
2624
|
+
options?: { recoveryEmail?: string; recoveryLanguage?: string };
|
|
2458
2625
|
}): Promise<RecoveryResult> {
|
|
2459
2626
|
const storage = guardedStorage();
|
|
2460
2627
|
const { token, providerType, input, didFromPrivateKey, signDidAuthVp } = params;
|
|
@@ -2542,11 +2709,17 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
2542
2709
|
recoveryShare: plaintext.recoveryShare,
|
|
2543
2710
|
authShare,
|
|
2544
2711
|
rebindSessionToken: material.rebindSessionToken,
|
|
2712
|
+
fromEscrow: true,
|
|
2545
2713
|
};
|
|
2546
2714
|
// The OTP path uses exactly this pending state and atomic rebind operation.
|
|
2547
2715
|
// Without a provider session the caller completes it after signing in.
|
|
2548
2716
|
if (!token) return { privateKey, did: material.primaryDid };
|
|
2549
|
-
return this.completeIdentityRecovery!({
|
|
2717
|
+
return this.completeIdentityRecovery!({
|
|
2718
|
+
token,
|
|
2719
|
+
providerType,
|
|
2720
|
+
signDidAuthVp,
|
|
2721
|
+
options: params.options,
|
|
2722
|
+
});
|
|
2550
2723
|
}
|
|
2551
2724
|
|
|
2552
2725
|
let recoveryShare: string;
|
|
@@ -3272,6 +3445,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
3272
3445
|
recoveryShare,
|
|
3273
3446
|
authShare,
|
|
3274
3447
|
rebindSessionToken: material.rebindSessionToken,
|
|
3448
|
+
fromEscrow: false,
|
|
3275
3449
|
};
|
|
3276
3450
|
|
|
3277
3451
|
return { privateKey, did: material.primaryDid };
|
|
@@ -3442,22 +3616,41 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
3442
3616
|
})
|
|
3443
3617
|
);
|
|
3444
3618
|
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3619
|
+
// Other recovery methods leave enrollment to the caller's rollout gate.
|
|
3620
|
+
const enrolled =
|
|
3621
|
+
pending.fromEscrow &&
|
|
3622
|
+
(await tryEnrollEscrow(
|
|
3623
|
+
params.token,
|
|
3624
|
+
params.providerType,
|
|
3625
|
+
pending.privateKey,
|
|
3626
|
+
pending.primaryDid,
|
|
3627
|
+
shares,
|
|
3628
|
+
shareVersion,
|
|
3629
|
+
signDidAuthVp,
|
|
3630
|
+
undefined,
|
|
3631
|
+
undefined,
|
|
3632
|
+
true,
|
|
3633
|
+
storage.assertCurrent,
|
|
3634
|
+
params.options?.recoveryEmail,
|
|
3635
|
+
params.options?.recoveryLanguage
|
|
3636
|
+
));
|
|
3457
3637
|
if (activeStorageId !== storageId || storageGeneration !== generation) {
|
|
3458
3638
|
throw new Error('This recovery request was cancelled.');
|
|
3459
3639
|
}
|
|
3460
|
-
|
|
3640
|
+
if (!enrolled) {
|
|
3641
|
+
unenrolledRotation = {
|
|
3642
|
+
shares,
|
|
3643
|
+
shareVersion,
|
|
3644
|
+
primaryDid: pending.primaryDid,
|
|
3645
|
+
clearPin: true,
|
|
3646
|
+
};
|
|
3647
|
+
}
|
|
3648
|
+
return {
|
|
3649
|
+
privateKey: pending.privateKey,
|
|
3650
|
+
did: pending.primaryDid,
|
|
3651
|
+
escrowEnrolled: enrolled,
|
|
3652
|
+
escrowRecovery: pending.fromEscrow,
|
|
3653
|
+
};
|
|
3461
3654
|
},
|
|
3462
3655
|
|
|
3463
3656
|
// --- Contact method management ---
|
package/src/types.ts
CHANGED
|
@@ -104,6 +104,11 @@ export interface EscrowHoldStatus {
|
|
|
104
104
|
|
|
105
105
|
/** Persist these secrets securely until completion; a null token means an existing hold. */
|
|
106
106
|
export type EscrowRecoveryStart = EscrowHoldStatus & {
|
|
107
|
+
codeSent?: boolean;
|
|
108
|
+
delivery?: {
|
|
109
|
+
codeSent: boolean;
|
|
110
|
+
proof: import('./escrow-authority.types').EscrowAuthorityRecord;
|
|
111
|
+
};
|
|
107
112
|
/** Persist with the secrets, never rebuild from server status. Required for Nitro resume. */
|
|
108
113
|
expectations?: import('@learncard/types').EscrowRecoveryExpectations;
|
|
109
114
|
resumeToken: string | null;
|