@learncard/sss-key-manager 0.2.5 → 0.2.7
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 +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/sss-key-manager.cjs.development.js +603 -134
- package/dist/sss-key-manager.cjs.development.js.map +3 -3
- package/dist/sss-key-manager.cjs.production.min.js +32 -32
- package/dist/sss-key-manager.cjs.production.min.js.map +3 -3
- package/dist/sss-key-manager.esm.js +603 -134
- package/dist/sss-key-manager.esm.js.map +3 -3
- package/dist/sss-strategy.d.ts.map +1 -1
- package/dist/types.d.ts +39 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +8 -1
- package/src/sss-authority.test.ts +294 -1
- package/src/sss-strategy.test.ts +875 -8
- package/src/sss-strategy.ts +869 -159
- package/src/types.ts +49 -1
package/src/sss-strategy.ts
CHANGED
|
@@ -29,8 +29,10 @@ import type {
|
|
|
29
29
|
DidAuthVpSigner,
|
|
30
30
|
EscrowAttestationPolicy,
|
|
31
31
|
EscrowHoldStatus,
|
|
32
|
+
EscrowPinAttemptContext,
|
|
32
33
|
EscrowRecoveryStart,
|
|
33
34
|
EscrowEnrollmentState,
|
|
35
|
+
RevokeOtherDevicesResult,
|
|
34
36
|
} from './types';
|
|
35
37
|
import {
|
|
36
38
|
EscrowPinLockedError,
|
|
@@ -38,6 +40,7 @@ import {
|
|
|
38
40
|
EscrowPinThrottledError,
|
|
39
41
|
EscrowHoldRestartThrottledError,
|
|
40
42
|
EscrowPinUnavailableError,
|
|
43
|
+
PinSignInStaleError,
|
|
41
44
|
} from './types';
|
|
42
45
|
import {
|
|
43
46
|
ESCROW_PIN_LOCKED_MESSAGE,
|
|
@@ -64,6 +67,7 @@ import {
|
|
|
64
67
|
escrowAuthorityMeasurements,
|
|
65
68
|
type EscrowHoldRecordLike,
|
|
66
69
|
} from './escrow-authority';
|
|
70
|
+
import { getEscrowBlobIdentity } from './escrow-enrollment-intent';
|
|
67
71
|
import type { VerifiedEnclaveIdentity, EscrowAuthorityState } from './escrow-authority.types';
|
|
68
72
|
import { EscrowEvidenceError } from './escrow-authority.types';
|
|
69
73
|
import type { EscrowEnvelope } from './escrow-crypto';
|
|
@@ -243,6 +247,10 @@ export class EscrowRequestError extends Error {
|
|
|
243
247
|
}
|
|
244
248
|
}
|
|
245
249
|
|
|
250
|
+
/** lca-api refused a write because escrow state changed after the caller's reads (409). */
|
|
251
|
+
const isConflictRefusal = (error: unknown): boolean =>
|
|
252
|
+
error instanceof EscrowRequestError && error.status === 409;
|
|
253
|
+
|
|
246
254
|
/** Independently generated splits can reuse a share's final-byte x-coordinate. */
|
|
247
255
|
export class DuplicateShareCoordinateError extends Error {
|
|
248
256
|
constructor() {
|
|
@@ -1110,6 +1118,11 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1110
1118
|
rebindSessionToken: string;
|
|
1111
1119
|
/** Recovered from an existing escrow enrollment (hold or PIN), not another method. */
|
|
1112
1120
|
fromEscrow: boolean;
|
|
1121
|
+
/**
|
|
1122
|
+
* PIN sign-in awaiting a new login: completion only links that login
|
|
1123
|
+
* (no re-split, no history or recovery-method change) at this version.
|
|
1124
|
+
*/
|
|
1125
|
+
pinKeep?: { shareVersion: number; pinAttempts?: EscrowPinAttemptContext };
|
|
1113
1126
|
rebind?: { shares: SSSShares; storageId: string | undefined; attempted: boolean };
|
|
1114
1127
|
}
|
|
1115
1128
|
| undefined;
|
|
@@ -1357,7 +1370,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1357
1370
|
providerType: AuthProviderType,
|
|
1358
1371
|
privateKey: string,
|
|
1359
1372
|
primaryDid: string,
|
|
1360
|
-
|
|
1373
|
+
// Only the recovery share is sealed; a PIN sign-in re-set has no other share.
|
|
1374
|
+
shares: Pick<SSSShares, 'recoveryShare'>,
|
|
1361
1375
|
shareVersion: number,
|
|
1362
1376
|
signDidAuthVp?: DidAuthVpSigner,
|
|
1363
1377
|
verifiedKey?: VerifiedEnclaveIdentity,
|
|
@@ -1374,7 +1388,10 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1374
1388
|
// target blob whose plaintext already carries either field, since
|
|
1375
1389
|
// the enclave always fills them in from the source blob instead.
|
|
1376
1390
|
recoveryEmail = '',
|
|
1377
|
-
recoveryLanguage = ''
|
|
1391
|
+
recoveryLanguage = '',
|
|
1392
|
+
// Same-PIN refresh after a PIN sign-in (E): the server writes only while the
|
|
1393
|
+
// current PIN still shows exactly these charges.
|
|
1394
|
+
expectedPinCharges?: { attemptsRemaining: number; wrongAttempts: number }
|
|
1378
1395
|
): Promise<void> => {
|
|
1379
1396
|
if (!config.escrow?.enabled) throw new Error('Escrow enrollment is disabled');
|
|
1380
1397
|
if (!signDidAuthVp) throw new Error('DID proof signing is required for escrow enrollment');
|
|
@@ -1453,6 +1470,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1453
1470
|
...(signedIntent
|
|
1454
1471
|
? { signedIntent, sourceEnvelope: authority?.envelope ?? null }
|
|
1455
1472
|
: {}),
|
|
1473
|
+
...(expectedPinCharges ? { expectedPinCharges } : {}),
|
|
1456
1474
|
}),
|
|
1457
1475
|
});
|
|
1458
1476
|
if (signedIntent) {
|
|
@@ -1466,11 +1484,14 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1466
1484
|
}
|
|
1467
1485
|
};
|
|
1468
1486
|
|
|
1469
|
-
|
|
1470
|
-
|
|
1487
|
+
/** `tryEnrollEscrow` that also returns a failed attempt's error, for callers that retry. */
|
|
1488
|
+
const attemptEnrollEscrow = async (
|
|
1489
|
+
...args: Parameters<typeof enrollEscrow>
|
|
1490
|
+
): Promise<{ enrolled: true } | { enrolled: false; error?: unknown }> => {
|
|
1491
|
+
if (!config.escrow?.enabled) return { enrolled: false };
|
|
1471
1492
|
try {
|
|
1472
1493
|
await enrollEscrow(...args);
|
|
1473
|
-
return true;
|
|
1494
|
+
return { enrolled: true };
|
|
1474
1495
|
} catch (err) {
|
|
1475
1496
|
// A reporting callback must not turn a successful rotation into a failure.
|
|
1476
1497
|
try {
|
|
@@ -1479,10 +1500,13 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1479
1500
|
} catch {
|
|
1480
1501
|
console.warn('SSS: escrow error reporting failed');
|
|
1481
1502
|
}
|
|
1482
|
-
return false;
|
|
1503
|
+
return { enrolled: false, error: err };
|
|
1483
1504
|
}
|
|
1484
1505
|
};
|
|
1485
1506
|
|
|
1507
|
+
const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<boolean> =>
|
|
1508
|
+
(await attemptEnrollEscrow(...args)).enrolled;
|
|
1509
|
+
|
|
1486
1510
|
/** Report a committed automatic PIN clear so the caller can prompt for a new PIN. */
|
|
1487
1511
|
const withAutoClear = (
|
|
1488
1512
|
shareVersion: number,
|
|
@@ -1574,7 +1598,35 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1574
1598
|
current.status === 'active' &&
|
|
1575
1599
|
current.shareVersion === status.shareVersion &&
|
|
1576
1600
|
current.envelope?.keyId === verifiedKey.keyId;
|
|
1601
|
+
// lca-api must serve the authority's copy: a PIN operation whose result never
|
|
1602
|
+
// reached Mongo leaves the old copy there, and every hold or PIN attempt
|
|
1603
|
+
// against it is refused. Older servers do not report the identity.
|
|
1604
|
+
let identityMismatch = false;
|
|
1605
|
+
if (currentlyEnrolled && current.envelope && status.escrowBlobIdentity !== undefined) {
|
|
1606
|
+
const authorityIdentity = await getEscrowBlobIdentity(current.envelope);
|
|
1607
|
+
if (authorityIdentity !== status.escrowBlobIdentity) {
|
|
1608
|
+
// The authority-state read settles a crashed operation server-side;
|
|
1609
|
+
// read lca-api's copy again before treating it as a mismatch.
|
|
1610
|
+
const reread = await self.fetchServerKeyStatus(
|
|
1611
|
+
params.token,
|
|
1612
|
+
params.providerType
|
|
1613
|
+
);
|
|
1614
|
+
storage.assertCurrent();
|
|
1615
|
+
// While a PIN operation holds the server's lease its copy may trail
|
|
1616
|
+
// the authority for a moment; lca-api converges on its own, so this
|
|
1617
|
+
// is not (yet) a stale copy and must not prompt a PIN reset.
|
|
1618
|
+
identityMismatch =
|
|
1619
|
+
reread.escrowBlobIdentity !== authorityIdentity &&
|
|
1620
|
+
reread.escrowUpdating !== true;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
if (identityMismatch) currentlyEnrolled = false;
|
|
1577
1624
|
if (currentlyEnrolled) verifiedEscrowEnvelope = current.envelope ?? undefined;
|
|
1625
|
+
// lca-api's PIN record (salt) belongs to the old copy, so neither a carry nor a
|
|
1626
|
+
// keep can repair a PIN-protected mismatch: only a user-chosen PIN (`set`) or an
|
|
1627
|
+
// explicit clear re-binds both. Never rotate or sign here.
|
|
1628
|
+
if (identityMismatch && current.hasPin && pin === undefined && !clearPinForEnrollment)
|
|
1629
|
+
return { enrolled: false, reason: 'pin-reset-required' };
|
|
1578
1630
|
hasPin = current.hasPin;
|
|
1579
1631
|
// After an identity-recovery rebind lca-api has no PIN left to carry, so an
|
|
1580
1632
|
// automatic carry/keep can only fail.
|
|
@@ -1766,6 +1818,608 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1766
1818
|
};
|
|
1767
1819
|
};
|
|
1768
1820
|
|
|
1821
|
+
/**
|
|
1822
|
+
* Verified wrong entries recorded against the current PIN, or undefined when the
|
|
1823
|
+
* server cannot prove the count (legacy record, older server, failed lookup). A
|
|
1824
|
+
* status with no PIN counts as zero.
|
|
1825
|
+
*/
|
|
1826
|
+
const recordedWrongPinAttempts = (escrowPin: unknown): number | undefined => {
|
|
1827
|
+
const status = escrowPin as { state?: unknown; wrongAttempts?: unknown } | undefined;
|
|
1828
|
+
if (status?.state === 'none') return 0;
|
|
1829
|
+
const count = status?.wrongAttempts;
|
|
1830
|
+
return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0
|
|
1831
|
+
? count
|
|
1832
|
+
: undefined;
|
|
1833
|
+
};
|
|
1834
|
+
|
|
1835
|
+
/** A valid, non-negative attempt count from the caller, or undefined. */
|
|
1836
|
+
const attemptCount = (value: unknown): number | undefined =>
|
|
1837
|
+
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
1838
|
+
|
|
1839
|
+
/**
|
|
1840
|
+
* Whether a PIN sign-in may treat every wrong entry recorded against the PIN as the
|
|
1841
|
+
* user's own typing (own-typos rule, E; approved 2026-11-03). Requires:
|
|
1842
|
+
* - zero wrong entries when the flow started (`priorWrongAttempts`; unknown counts
|
|
1843
|
+
* as "some"), and
|
|
1844
|
+
* - every status read during this release showing exactly the wrong entries this
|
|
1845
|
+
* client submitted in the flow (`ownWrongAttempts`), so a guess from anywhere
|
|
1846
|
+
* else (more entries) or an unexplained count (fewer, unknown) is never excused.
|
|
1847
|
+
* Without `attempts` (older callers), or when a status shows a different PIN
|
|
1848
|
+
* generation than the flow's (`pinSalt`), no own entries are excused and the prior
|
|
1849
|
+
* count is the one read just before this release: the original #1905 rule.
|
|
1850
|
+
*/
|
|
1851
|
+
const pinSignInClean = (
|
|
1852
|
+
observed: unknown[],
|
|
1853
|
+
flowAttempts: EscrowPinAttemptContext | undefined
|
|
1854
|
+
): { clean: boolean; ownWrongAttempts: number } => {
|
|
1855
|
+
const flowSalt = flowAttempts?.pinSalt;
|
|
1856
|
+
const sameGeneration =
|
|
1857
|
+
typeof flowSalt !== 'string' ||
|
|
1858
|
+
observed.every(status => {
|
|
1859
|
+
const salt = (status as { salt?: unknown } | undefined)?.salt;
|
|
1860
|
+
return typeof salt !== 'string' || salt === flowSalt;
|
|
1861
|
+
});
|
|
1862
|
+
const attempts = sameGeneration ? flowAttempts : undefined;
|
|
1863
|
+
const own = attempts ? attemptCount(attempts.ownWrongAttempts) : 0;
|
|
1864
|
+
if (own === undefined) return { clean: false, ownWrongAttempts: 0 };
|
|
1865
|
+
const prior = attempts
|
|
1866
|
+
? attemptCount(attempts.priorWrongAttempts)
|
|
1867
|
+
: recordedWrongPinAttempts(observed[0]);
|
|
1868
|
+
const clean =
|
|
1869
|
+
prior === 0 &&
|
|
1870
|
+
observed.length > 0 &&
|
|
1871
|
+
observed.every(status => recordedWrongPinAttempts(status) === own);
|
|
1872
|
+
return { clean, ownWrongAttempts: own };
|
|
1873
|
+
};
|
|
1874
|
+
|
|
1875
|
+
/**
|
|
1876
|
+
* The charges a same-PIN refresh may replace, or undefined when it must not run:
|
|
1877
|
+
* the sign-in is clean (see pinSignInClean) and exactly one charge landed between
|
|
1878
|
+
* the two reads (this release). Any other charge may be someone else's guess still
|
|
1879
|
+
* in flight, so the PIN keeps its reduced budget. The server re-checks these
|
|
1880
|
+
* charges in the write itself (`expectedPinCharges`), so a guess landing after
|
|
1881
|
+
* these reads still blocks the refresh.
|
|
1882
|
+
*/
|
|
1883
|
+
const refreshablePinCharges = (
|
|
1884
|
+
before: unknown,
|
|
1885
|
+
after: unknown,
|
|
1886
|
+
ownWrongAttempts: number
|
|
1887
|
+
): { attemptsRemaining: number; wrongAttempts: number } | undefined => {
|
|
1888
|
+
const pre = before as { state?: unknown; attemptsRemaining?: unknown } | undefined;
|
|
1889
|
+
const post = after as { state?: unknown; attemptsRemaining?: unknown } | undefined;
|
|
1890
|
+
if (pre?.state !== 'enabled' || post?.state !== 'enabled') return undefined;
|
|
1891
|
+
if (typeof pre.attemptsRemaining !== 'number' || typeof post.attemptsRemaining !== 'number')
|
|
1892
|
+
return undefined;
|
|
1893
|
+
if (post.attemptsRemaining !== pre.attemptsRemaining - 1) return undefined;
|
|
1894
|
+
return { attemptsRemaining: post.attemptsRemaining, wrongAttempts: ownWrongAttempts };
|
|
1895
|
+
};
|
|
1896
|
+
|
|
1897
|
+
/** New salt and verifier for a PIN just proven; undefined when it fails the PIN rules. */
|
|
1898
|
+
const freshPinMaterial = async (
|
|
1899
|
+
pin: string
|
|
1900
|
+
): Promise<{ pinSalt: string; pinVerifier: string } | undefined> => {
|
|
1901
|
+
if (!validatePin(pin).ok) return undefined;
|
|
1902
|
+
try {
|
|
1903
|
+
const pinSalt = generatePinSalt();
|
|
1904
|
+
return { pinSalt, pinVerifier: await derivePinProof(pin, pinSalt) };
|
|
1905
|
+
} catch {
|
|
1906
|
+
return undefined;
|
|
1907
|
+
}
|
|
1908
|
+
};
|
|
1909
|
+
|
|
1910
|
+
/**
|
|
1911
|
+
* Store a PIN-released recovery share as this device's share at its own version.
|
|
1912
|
+
* Only the main slot is written: the recovery share is never staged as a pending
|
|
1913
|
+
* candidate, and unrelated candidates are left for the usual reconciliation, because
|
|
1914
|
+
* a PIN sign-in changes nothing on the server.
|
|
1915
|
+
*/
|
|
1916
|
+
const adoptReleasedShare = async (
|
|
1917
|
+
storage: SSSStorageFunctions & { assertCurrent: () => void },
|
|
1918
|
+
storageId: string | undefined,
|
|
1919
|
+
released: { primaryDid: string; recoveryShare: string; authShare: string },
|
|
1920
|
+
shareVersion: number,
|
|
1921
|
+
currentVersion: number
|
|
1922
|
+
): Promise<void> => {
|
|
1923
|
+
await storage.storeDeviceShare(released.recoveryShare, storageId);
|
|
1924
|
+
await storage.storeShareVersion(shareVersion, storageId);
|
|
1925
|
+
// The released split's email share is not known on this device.
|
|
1926
|
+
lastEmailShare = undefined;
|
|
1927
|
+
lastShareVersion = shareVersion;
|
|
1928
|
+
lastServerSnapshot = {
|
|
1929
|
+
currentVersion,
|
|
1930
|
+
resolvedVersion: shareVersion,
|
|
1931
|
+
authShare: released.authShare,
|
|
1932
|
+
primaryDid: released.primaryDid,
|
|
1933
|
+
};
|
|
1934
|
+
};
|
|
1935
|
+
|
|
1936
|
+
/**
|
|
1937
|
+
* PIN sign-in with the login already bound to the account (the default "keep"
|
|
1938
|
+
* path). Nothing is re-split and no server state changes apart from re-setting the
|
|
1939
|
+
* same PIN for a fresh attempt budget. That re-set runs only when the server shows
|
|
1940
|
+
* zero wrong entries against the current PIN at release time; otherwise the PIN
|
|
1941
|
+
* keeps its reduced budget and `newPinRequired` asks the user for a different PIN,
|
|
1942
|
+
* so a guesser gets at most one budget per PIN in total. `pin` is used in this
|
|
1943
|
+
* call only: never stored in pending/retry state, logged or reported.
|
|
1944
|
+
*
|
|
1945
|
+
* Version race: if another device rotated after the release, the device is stored
|
|
1946
|
+
* at the released version as long as the server still holds it in history
|
|
1947
|
+
* (`deferred-rotation`; the next start-up's reconcileShares advances it without
|
|
1948
|
+
* wiping). If that version is gone (a revoke or rebind), it fails closed with
|
|
1949
|
+
* PinSignInStaleError and stores nothing; it never falls back to a rotation.
|
|
1950
|
+
*/
|
|
1951
|
+
const completePinSignInKeep = async (
|
|
1952
|
+
params: {
|
|
1953
|
+
token: string;
|
|
1954
|
+
providerType: AuthProviderType;
|
|
1955
|
+
signDidAuthVp?: DidAuthVpSigner;
|
|
1956
|
+
options?: { recoveryEmail?: string; recoveryLanguage?: string };
|
|
1957
|
+
},
|
|
1958
|
+
released: {
|
|
1959
|
+
privateKey: string;
|
|
1960
|
+
primaryDid: string;
|
|
1961
|
+
recoveryShare: string;
|
|
1962
|
+
authShare: string;
|
|
1963
|
+
shareVersion: number;
|
|
1964
|
+
},
|
|
1965
|
+
pin: string,
|
|
1966
|
+
/** PIN status read before this release; undefined when that read failed. */
|
|
1967
|
+
pinBefore: unknown,
|
|
1968
|
+
/** Own-typos context from the caller's PIN sign-in flow (E). */
|
|
1969
|
+
pinAttempts?: EscrowPinAttemptContext
|
|
1970
|
+
): Promise<RecoveryResult> => {
|
|
1971
|
+
const storage = guardedStorage();
|
|
1972
|
+
const generation = storageGeneration;
|
|
1973
|
+
const storageId = activeStorageId;
|
|
1974
|
+
const assertActive = (): void => {
|
|
1975
|
+
if (activeStorageId !== storageId || storageGeneration !== generation) {
|
|
1976
|
+
throw new Error('This recovery request was cancelled.');
|
|
1977
|
+
}
|
|
1978
|
+
};
|
|
1979
|
+
|
|
1980
|
+
const {
|
|
1981
|
+
mode,
|
|
1982
|
+
newPinRequired: newPinRequiredAtRelease,
|
|
1983
|
+
refreshCharges,
|
|
1984
|
+
} = await withShareUpdateLock(storage, storageId, () =>
|
|
1985
|
+
withRotationLock(async () => {
|
|
1986
|
+
assertActive();
|
|
1987
|
+
const current = await fetchAuthShareRaw(
|
|
1988
|
+
serverUrl,
|
|
1989
|
+
params.token,
|
|
1990
|
+
params.providerType,
|
|
1991
|
+
undefined,
|
|
1992
|
+
tenantId
|
|
1993
|
+
);
|
|
1994
|
+
assertActive();
|
|
1995
|
+
if (
|
|
1996
|
+
!current ||
|
|
1997
|
+
current.primaryDid !== released.primaryDid ||
|
|
1998
|
+
typeof current.shareVersion !== 'number'
|
|
1999
|
+
) {
|
|
2000
|
+
throw new PinSignInStaleError();
|
|
2001
|
+
}
|
|
2002
|
+
let mode: 'kept' | 'deferred-rotation';
|
|
2003
|
+
if (current.shareVersion === released.shareVersion) {
|
|
2004
|
+
if (authShareToString(current.authShare) !== released.authShare) {
|
|
2005
|
+
throw new PinSignInStaleError();
|
|
2006
|
+
}
|
|
2007
|
+
mode = 'kept';
|
|
2008
|
+
} else {
|
|
2009
|
+
const historical =
|
|
2010
|
+
current.shareVersion > released.shareVersion
|
|
2011
|
+
? await fetchAuthShareRaw(
|
|
2012
|
+
serverUrl,
|
|
2013
|
+
params.token,
|
|
2014
|
+
params.providerType,
|
|
2015
|
+
released.shareVersion,
|
|
2016
|
+
tenantId
|
|
2017
|
+
)
|
|
2018
|
+
: null;
|
|
2019
|
+
if (authShareToString(historical?.authShare) !== released.authShare) {
|
|
2020
|
+
throw new PinSignInStaleError();
|
|
2021
|
+
}
|
|
2022
|
+
mode = 'deferred-rotation';
|
|
2023
|
+
}
|
|
2024
|
+
assertActive();
|
|
2025
|
+
await adoptReleasedShare(
|
|
2026
|
+
storage,
|
|
2027
|
+
storageId,
|
|
2028
|
+
released,
|
|
2029
|
+
released.shareVersion,
|
|
2030
|
+
current.shareVersion
|
|
2031
|
+
);
|
|
2032
|
+
pendingIdentityRecovery = undefined;
|
|
2033
|
+
// Both reads count: the one after this release (a success never resets the
|
|
2034
|
+
// count) and the one before it (a legacy record without a verified count
|
|
2035
|
+
// reports 0 once any release has been reserved, so only the earlier read
|
|
2036
|
+
// shows that its history is unknown). Each must show exactly this flow's
|
|
2037
|
+
// own wrong entries (zero for callers without a flow context).
|
|
2038
|
+
const { clean, ownWrongAttempts } = pinSignInClean(
|
|
2039
|
+
[pinBefore, current.escrowPin],
|
|
2040
|
+
pinAttempts
|
|
2041
|
+
);
|
|
2042
|
+
return {
|
|
2043
|
+
mode,
|
|
2044
|
+
newPinRequired: !clean,
|
|
2045
|
+
refreshCharges: clean
|
|
2046
|
+
? refreshablePinCharges(pinBefore, current.escrowPin, ownWrongAttempts)
|
|
2047
|
+
: undefined,
|
|
2048
|
+
};
|
|
2049
|
+
})
|
|
2050
|
+
);
|
|
2051
|
+
|
|
2052
|
+
// Same PIN, new salt and verifier, enrolled at the same version: a fresh
|
|
2053
|
+
// attempt budget without a rotation. Skipped after a race (the other device's
|
|
2054
|
+
// rotation already re-enrolled escrow) and whenever wrong entries are recorded
|
|
2055
|
+
// against this PIN (the user must pick a different PIN instead). A failure
|
|
2056
|
+
// leaves the PIN at this version valid, so nothing is cleared and no repair is
|
|
2057
|
+
// scheduled.
|
|
2058
|
+
let pinRefreshed = false;
|
|
2059
|
+
let pinRefreshFailed = false;
|
|
2060
|
+
let newPinRequired = newPinRequiredAtRelease;
|
|
2061
|
+
if (mode === 'kept' && !newPinRequired && refreshCharges && params.signDidAuthVp) {
|
|
2062
|
+
const pinMaterial = await freshPinMaterial(pin);
|
|
2063
|
+
const refresh =
|
|
2064
|
+
pinMaterial === undefined
|
|
2065
|
+
? undefined
|
|
2066
|
+
: await attemptEnrollEscrow(
|
|
2067
|
+
params.token,
|
|
2068
|
+
params.providerType,
|
|
2069
|
+
released.privateKey,
|
|
2070
|
+
released.primaryDid,
|
|
2071
|
+
{ recoveryShare: released.recoveryShare },
|
|
2072
|
+
released.shareVersion,
|
|
2073
|
+
params.signDidAuthVp,
|
|
2074
|
+
undefined,
|
|
2075
|
+
pinMaterial,
|
|
2076
|
+
false,
|
|
2077
|
+
storage.assertCurrent,
|
|
2078
|
+
params.options?.recoveryEmail,
|
|
2079
|
+
params.options?.recoveryLanguage,
|
|
2080
|
+
refreshCharges
|
|
2081
|
+
);
|
|
2082
|
+
pinRefreshed = refresh?.enrolled === true;
|
|
2083
|
+
// Reported, not swallowed: the PIN keeps (at most) its reduced budget. A commit
|
|
2084
|
+
// whose receipt was lost is not confirmed here, so it reports failed too (an
|
|
2085
|
+
// under-claim: the budget is in fact full). lca-api either
|
|
2086
|
+
// refused before the authority (both copies unchanged) or converges to the
|
|
2087
|
+
// authority's commit; a copy left behind is caught by ensureEscrowEnrollment's
|
|
2088
|
+
// identity check, which never clears the PIN on its own.
|
|
2089
|
+
pinRefreshFailed = pinMaterial !== undefined && !pinRefreshed;
|
|
2090
|
+
// A refused refresh usually means the charges moved after our reads, most
|
|
2091
|
+
// likely someone else's entry: re-check before showing the calm sheet. If the
|
|
2092
|
+
// status can't be read, a 409 (charges changed) still counts as a warning.
|
|
2093
|
+
if (pinRefreshFailed) {
|
|
2094
|
+
const latest = await fetchAuthShareRaw(
|
|
2095
|
+
serverUrl,
|
|
2096
|
+
params.token,
|
|
2097
|
+
params.providerType,
|
|
2098
|
+
undefined,
|
|
2099
|
+
tenantId
|
|
2100
|
+
).catch(() => undefined);
|
|
2101
|
+
newPinRequired = latest
|
|
2102
|
+
? !pinSignInClean([pinBefore, latest.escrowPin], pinAttempts).clean
|
|
2103
|
+
: isConflictRefusal(refresh && !refresh.enrolled ? refresh.error : undefined);
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
assertActive();
|
|
2107
|
+
return {
|
|
2108
|
+
privateKey: released.privateKey,
|
|
2109
|
+
did: released.primaryDid,
|
|
2110
|
+
escrowEnrolled: true,
|
|
2111
|
+
escrowRecovery: true,
|
|
2112
|
+
pinSignIn: {
|
|
2113
|
+
mode,
|
|
2114
|
+
pinRefreshed,
|
|
2115
|
+
newPinRequired,
|
|
2116
|
+
...(pinRefreshFailed ? { pinRefreshFailed: true } : {}),
|
|
2117
|
+
},
|
|
2118
|
+
};
|
|
2119
|
+
};
|
|
2120
|
+
|
|
2121
|
+
/**
|
|
2122
|
+
* PIN sign-in completed with a login that is not yet bound (lost-login flow). The
|
|
2123
|
+
* PIN-release rebind token links the login on the server without touching shares,
|
|
2124
|
+
* history, recovery methods or escrow. No PIN is held across the new-login step, so
|
|
2125
|
+
* the attempt budget is not refreshed here.
|
|
2126
|
+
*/
|
|
2127
|
+
const completePinSignInRelink = async (
|
|
2128
|
+
params: Parameters<NonNullable<SSSKeyDerivationStrategy['completeIdentityRecovery']>>[0],
|
|
2129
|
+
pending: NonNullable<typeof pendingIdentityRecovery>,
|
|
2130
|
+
signDidAuthVp: DidAuthVpSigner
|
|
2131
|
+
): Promise<RecoveryResult> => {
|
|
2132
|
+
const storage = guardedStorage();
|
|
2133
|
+
const generation = storageGeneration;
|
|
2134
|
+
const storageId = activeStorageId;
|
|
2135
|
+
const shareVersion = pending.pinKeep!.shareVersion;
|
|
2136
|
+
const assertActive = (): void => {
|
|
2137
|
+
if (
|
|
2138
|
+
activeStorageId !== storageId ||
|
|
2139
|
+
pendingIdentityRecovery !== pending ||
|
|
2140
|
+
storageGeneration !== generation
|
|
2141
|
+
) {
|
|
2142
|
+
throw new Error('This recovery request was cancelled.');
|
|
2143
|
+
}
|
|
2144
|
+
};
|
|
2145
|
+
|
|
2146
|
+
await withShareUpdateLock(storage, storageId, () =>
|
|
2147
|
+
withRotationLock(async () => {
|
|
2148
|
+
assertActive();
|
|
2149
|
+
const didAuthVp = await requestFreshDidAuthVp(
|
|
2150
|
+
serverUrl,
|
|
2151
|
+
pending.privateKey,
|
|
2152
|
+
pending.primaryDid,
|
|
2153
|
+
signDidAuthVp,
|
|
2154
|
+
tenantId
|
|
2155
|
+
);
|
|
2156
|
+
assertActive();
|
|
2157
|
+
try {
|
|
2158
|
+
const response = await postJson<{
|
|
2159
|
+
success: true;
|
|
2160
|
+
shareVersion: number;
|
|
2161
|
+
linked: boolean;
|
|
2162
|
+
}>(
|
|
2163
|
+
`${serverUrl}/keys/recovery-session/pin-sign-in`,
|
|
2164
|
+
{
|
|
2165
|
+
recoverySessionToken: pending.rebindSessionToken,
|
|
2166
|
+
providerType: params.providerType,
|
|
2167
|
+
primaryDid: pending.primaryDid,
|
|
2168
|
+
},
|
|
2169
|
+
{
|
|
2170
|
+
...buildHeaders('', didAuthVp, tenantId),
|
|
2171
|
+
'X-Auth-Token': params.token,
|
|
2172
|
+
}
|
|
2173
|
+
);
|
|
2174
|
+
if (response.shareVersion !== shareVersion) throw new PinSignInStaleError();
|
|
2175
|
+
} catch (error) {
|
|
2176
|
+
// The single-use token may have been consumed before the response was
|
|
2177
|
+
// lost (or by an earlier attempt). The link is proven only when this
|
|
2178
|
+
// login now resolves to the same account, version and auth share.
|
|
2179
|
+
const current = await fetchAuthShareRaw(
|
|
2180
|
+
serverUrl,
|
|
2181
|
+
params.token,
|
|
2182
|
+
params.providerType,
|
|
2183
|
+
undefined,
|
|
2184
|
+
tenantId
|
|
2185
|
+
).catch(() => null);
|
|
2186
|
+
if (
|
|
2187
|
+
!current ||
|
|
2188
|
+
current.primaryDid !== pending.primaryDid ||
|
|
2189
|
+
current.shareVersion !== shareVersion ||
|
|
2190
|
+
authShareToString(current.authShare) !== pending.authShare
|
|
2191
|
+
) {
|
|
2192
|
+
throw error;
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
assertActive();
|
|
2196
|
+
await adoptReleasedShare(storage, storageId, pending, shareVersion, shareVersion);
|
|
2197
|
+
pendingIdentityRecovery = undefined;
|
|
2198
|
+
})
|
|
2199
|
+
);
|
|
2200
|
+
|
|
2201
|
+
if (activeStorageId !== storageId || storageGeneration !== generation) {
|
|
2202
|
+
throw new Error('This recovery request was cancelled.');
|
|
2203
|
+
}
|
|
2204
|
+
// The budget is never refreshed here; this only tells the app whether the PIN
|
|
2205
|
+
// has recorded wrong entries (unknown counts as yes) so it asks for a new one.
|
|
2206
|
+
const status = await fetchAuthShareRaw(
|
|
2207
|
+
serverUrl,
|
|
2208
|
+
params.token,
|
|
2209
|
+
params.providerType,
|
|
2210
|
+
undefined,
|
|
2211
|
+
tenantId
|
|
2212
|
+
).catch(() => null);
|
|
2213
|
+
return {
|
|
2214
|
+
privateKey: pending.privateKey,
|
|
2215
|
+
did: pending.primaryDid,
|
|
2216
|
+
escrowEnrolled: true,
|
|
2217
|
+
escrowRecovery: true,
|
|
2218
|
+
pinSignIn: {
|
|
2219
|
+
mode: 'relinked',
|
|
2220
|
+
pinRefreshed: false,
|
|
2221
|
+
newPinRequired: !pinSignInClean([status?.escrowPin], pending.pinKeep?.pinAttempts)
|
|
2222
|
+
.clean,
|
|
2223
|
+
},
|
|
2224
|
+
};
|
|
2225
|
+
};
|
|
2226
|
+
|
|
2227
|
+
/**
|
|
2228
|
+
* Rebinds a pending identity recovery to the new sign-in and re-enrolls escrow.
|
|
2229
|
+
* A pending PIN sign-in (`pinKeep`) only links the login; see completePinSignInRelink.
|
|
2230
|
+
*/
|
|
2231
|
+
const completeIdentityRecoveryWith = async (
|
|
2232
|
+
params: Parameters<NonNullable<SSSKeyDerivationStrategy['completeIdentityRecovery']>>[0]
|
|
2233
|
+
): Promise<RecoveryResult> => {
|
|
2234
|
+
const storage = guardedStorage();
|
|
2235
|
+
const generation = storageGeneration;
|
|
2236
|
+
const pending = pendingIdentityRecovery;
|
|
2237
|
+
const signDidAuthVp = params.signDidAuthVp;
|
|
2238
|
+
|
|
2239
|
+
if (!pending) throw new Error('No identity recovery is waiting for a new sign-in');
|
|
2240
|
+
if (!signDidAuthVp) {
|
|
2241
|
+
throw new Error('DID proof signing is required to bind a new sign-in');
|
|
2242
|
+
}
|
|
2243
|
+
if (pending.pinKeep) return completePinSignInRelink(params, pending, signDidAuthVp);
|
|
2244
|
+
|
|
2245
|
+
const storageId = activeStorageId;
|
|
2246
|
+
const { shares, shareVersion } = await withShareUpdateLock(storage, storageId, () =>
|
|
2247
|
+
withRotationLock(async () => {
|
|
2248
|
+
const assertActive = (): void => {
|
|
2249
|
+
if (
|
|
2250
|
+
activeStorageId !== storageId ||
|
|
2251
|
+
pendingIdentityRecovery !== pending ||
|
|
2252
|
+
storageGeneration !== generation
|
|
2253
|
+
) {
|
|
2254
|
+
throw new Error('This recovery request was cancelled.');
|
|
2255
|
+
}
|
|
2256
|
+
};
|
|
2257
|
+
assertActive();
|
|
2258
|
+
const candidates = await readPendingShareCandidates(storage, storageId);
|
|
2259
|
+
if (pending.rebind && pending.rebind.storageId !== storageId) {
|
|
2260
|
+
throw new Error('Active account changed during identity recovery');
|
|
2261
|
+
}
|
|
2262
|
+
if (
|
|
2263
|
+
candidates.some(
|
|
2264
|
+
candidate => candidate.share !== pending.rebind?.shares.deviceShare
|
|
2265
|
+
)
|
|
2266
|
+
) {
|
|
2267
|
+
throw new Error('Reconcile the pending share update before binding a sign-in');
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
let shareVersion: number | undefined;
|
|
2271
|
+
const retry = pending.rebind?.attempted ?? false;
|
|
2272
|
+
if (retry && pending.rebind) {
|
|
2273
|
+
const current = await fetchAuthShareRaw(
|
|
2274
|
+
serverUrl,
|
|
2275
|
+
params.token,
|
|
2276
|
+
params.providerType,
|
|
2277
|
+
undefined,
|
|
2278
|
+
tenantId
|
|
2279
|
+
);
|
|
2280
|
+
const authShare = authShareToString(current?.authShare);
|
|
2281
|
+
// prepareIdentityRecovery already verified this key's DID.
|
|
2282
|
+
// Comparing the reconstructed key avoids trusting server DID metadata.
|
|
2283
|
+
if (authShare && current?.shareVersion != null) {
|
|
2284
|
+
const health = await verifyStoredShares(
|
|
2285
|
+
{
|
|
2286
|
+
getDevice: async () => pending.rebind!.shares.deviceShare,
|
|
2287
|
+
getAuth: async () => authShare,
|
|
2288
|
+
},
|
|
2289
|
+
pending.primaryDid,
|
|
2290
|
+
async key => (key === pending.privateKey ? pending.primaryDid : '')
|
|
2291
|
+
);
|
|
2292
|
+
if (health.healthy) shareVersion = current.shareVersion;
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
assertActive();
|
|
2297
|
+
if (shareVersion === undefined) {
|
|
2298
|
+
const didAuthVp = await requestFreshDidAuthVp(
|
|
2299
|
+
serverUrl,
|
|
2300
|
+
pending.privateKey,
|
|
2301
|
+
pending.primaryDid,
|
|
2302
|
+
signDidAuthVp,
|
|
2303
|
+
tenantId
|
|
2304
|
+
);
|
|
2305
|
+
assertActive();
|
|
2306
|
+
pending.rebind ??= {
|
|
2307
|
+
shares: (await splitAndVerify(pending.privateKey)).shares,
|
|
2308
|
+
storageId,
|
|
2309
|
+
attempted: false,
|
|
2310
|
+
};
|
|
2311
|
+
const rebind = pending.rebind;
|
|
2312
|
+
if (
|
|
2313
|
+
!candidates.some(candidate => candidate.share === rebind.shares.deviceShare)
|
|
2314
|
+
) {
|
|
2315
|
+
await addPendingShareCandidate(
|
|
2316
|
+
storage,
|
|
2317
|
+
{ share: rebind.shares.deviceShare, createdAt: Date.now() },
|
|
2318
|
+
storageId
|
|
2319
|
+
);
|
|
2320
|
+
}
|
|
2321
|
+
assertActive();
|
|
2322
|
+
rebind.attempted = true;
|
|
2323
|
+
try {
|
|
2324
|
+
const response = await postJson<{
|
|
2325
|
+
shareVersion: number;
|
|
2326
|
+
recoveryMethodsRequireConfirmation: string[];
|
|
2327
|
+
}>(
|
|
2328
|
+
`${serverUrl}/keys/recovery-session/rebind`,
|
|
2329
|
+
{
|
|
2330
|
+
recoverySessionToken: pending.rebindSessionToken,
|
|
2331
|
+
providerType: params.providerType,
|
|
2332
|
+
primaryDid: pending.primaryDid,
|
|
2333
|
+
authShare: {
|
|
2334
|
+
encryptedData: rebind.shares.authShare,
|
|
2335
|
+
encryptedDek: '',
|
|
2336
|
+
iv: '',
|
|
2337
|
+
},
|
|
2338
|
+
},
|
|
2339
|
+
{
|
|
2340
|
+
...buildHeaders('', didAuthVp, tenantId),
|
|
2341
|
+
'X-Auth-Token': params.token,
|
|
2342
|
+
}
|
|
2343
|
+
);
|
|
2344
|
+
|
|
2345
|
+
shareVersion = response.shareVersion;
|
|
2346
|
+
} catch (error) {
|
|
2347
|
+
// A retry's rejection does not disprove an earlier commit
|
|
2348
|
+
// (the first request may still have been in flight at the read).
|
|
2349
|
+
if (error instanceof ShareWriteRejectedError && !retry) {
|
|
2350
|
+
await removePendingShareCandidate(
|
|
2351
|
+
storage,
|
|
2352
|
+
rebind.shares.deviceShare,
|
|
2353
|
+
storageId
|
|
2354
|
+
);
|
|
2355
|
+
pending.rebind = undefined;
|
|
2356
|
+
}
|
|
2357
|
+
throw error;
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
if (shareVersion === undefined || !pending.rebind) {
|
|
2361
|
+
throw new Error('Server did not confirm the new share');
|
|
2362
|
+
}
|
|
2363
|
+
assertActive();
|
|
2364
|
+
const shares = pending.rebind.shares;
|
|
2365
|
+
await storage.storeDeviceShare(shares.deviceShare, storageId);
|
|
2366
|
+
await storage.storeShareVersion(shareVersion, storageId);
|
|
2367
|
+
await clearPendingShareCandidates(storage, storageId);
|
|
2368
|
+
assertActive();
|
|
2369
|
+
lastEmailShare = shares.emailShare;
|
|
2370
|
+
lastShareVersion = shareVersion;
|
|
2371
|
+
lastServerSnapshot = {
|
|
2372
|
+
currentVersion: shareVersion,
|
|
2373
|
+
resolvedVersion: shareVersion,
|
|
2374
|
+
authShare: shares.authShare,
|
|
2375
|
+
primaryDid: pending.primaryDid,
|
|
2376
|
+
};
|
|
2377
|
+
pendingIdentityRecovery = undefined;
|
|
2378
|
+
return { shares, shareVersion };
|
|
2379
|
+
})
|
|
2380
|
+
);
|
|
2381
|
+
|
|
2382
|
+
// The rebind wiped lca-api's PIN record, so the old PIN cannot be carried and
|
|
2383
|
+
// is cleared. Only a hold (7-day) release reaches this rebind: a PIN release
|
|
2384
|
+
// keeps the account instead (completePinSignInKeep / completePinSignInRelink).
|
|
2385
|
+
// Other recovery methods leave enrollment to the caller's rollout gate.
|
|
2386
|
+
const enrolled =
|
|
2387
|
+
pending.fromEscrow &&
|
|
2388
|
+
(await tryEnrollEscrow(
|
|
2389
|
+
params.token,
|
|
2390
|
+
params.providerType,
|
|
2391
|
+
pending.privateKey,
|
|
2392
|
+
pending.primaryDid,
|
|
2393
|
+
shares,
|
|
2394
|
+
shareVersion,
|
|
2395
|
+
signDidAuthVp,
|
|
2396
|
+
undefined,
|
|
2397
|
+
undefined,
|
|
2398
|
+
true,
|
|
2399
|
+
storage.assertCurrent,
|
|
2400
|
+
params.options?.recoveryEmail,
|
|
2401
|
+
params.options?.recoveryLanguage
|
|
2402
|
+
));
|
|
2403
|
+
if (activeStorageId !== storageId || storageGeneration !== generation) {
|
|
2404
|
+
throw new Error('This recovery request was cancelled.');
|
|
2405
|
+
}
|
|
2406
|
+
if (!enrolled) {
|
|
2407
|
+
// The repair clears: PIN material (an offline-guessable verifier) is never kept.
|
|
2408
|
+
unenrolledRotation = {
|
|
2409
|
+
shares,
|
|
2410
|
+
shareVersion,
|
|
2411
|
+
primaryDid: pending.primaryDid,
|
|
2412
|
+
clearPin: true,
|
|
2413
|
+
};
|
|
2414
|
+
}
|
|
2415
|
+
return {
|
|
2416
|
+
privateKey: pending.privateKey,
|
|
2417
|
+
did: pending.primaryDid,
|
|
2418
|
+
escrowEnrolled: enrolled,
|
|
2419
|
+
escrowRecovery: pending.fromEscrow,
|
|
2420
|
+
};
|
|
2421
|
+
};
|
|
2422
|
+
|
|
1769
2423
|
return {
|
|
1770
2424
|
name: 'sss',
|
|
1771
2425
|
|
|
@@ -2168,6 +2822,10 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
2168
2822
|
escrowOptedOut: data.escrowOptedOut === true,
|
|
2169
2823
|
escrowPin: data.escrowPin,
|
|
2170
2824
|
escrowStale: data.escrowStale,
|
|
2825
|
+
...(typeof data.escrowBlobIdentity === 'string'
|
|
2826
|
+
? { escrowBlobIdentity: data.escrowBlobIdentity }
|
|
2827
|
+
: {}),
|
|
2828
|
+
...(data.escrowUpdating === true ? { escrowUpdating: true } : {}),
|
|
2171
2829
|
sssActivationState: data.sssActivationState ?? 'active',
|
|
2172
2830
|
...(typeof data.priorEscrowEnrollment === 'boolean'
|
|
2173
2831
|
? { priorEscrowEnrollment: data.priorEscrowEnrollment }
|
|
@@ -2649,11 +3307,31 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
2649
3307
|
throw new Error('DID verification is required for escrow recovery');
|
|
2650
3308
|
// Each PIN call owns a fresh, single-use hold and in-memory key pair.
|
|
2651
3309
|
// Never persist this key or retry completion after a burned attempt.
|
|
3310
|
+
// Without a bound login, the PIN start is proven by the verified
|
|
3311
|
+
// recovery-email session instead (lost-login sign-in).
|
|
3312
|
+
if (input.method === 'escrow-pin' && !token && !input.recoverySessionToken)
|
|
3313
|
+
throw new Error('PIN recovery requires signing in first.');
|
|
3314
|
+
// (E) The PIN status before this release, for the same-PIN refresh
|
|
3315
|
+
// decision in completePinSignInKeep. A failed read counts as unknown.
|
|
3316
|
+
const pinBefore =
|
|
3317
|
+
input.method === 'escrow-pin' && token
|
|
3318
|
+
? await fetchAuthShareRaw(
|
|
3319
|
+
serverUrl,
|
|
3320
|
+
token,
|
|
3321
|
+
providerType,
|
|
3322
|
+
undefined,
|
|
3323
|
+
tenantId
|
|
3324
|
+
).then(
|
|
3325
|
+
(current: { escrowPin?: unknown } | null) => current?.escrowPin,
|
|
3326
|
+
() => undefined
|
|
3327
|
+
)
|
|
3328
|
+
: undefined;
|
|
2652
3329
|
const start =
|
|
2653
3330
|
input.method === 'escrow-pin'
|
|
2654
3331
|
? await this.startEscrowRecovery!({
|
|
2655
|
-
token
|
|
2656
|
-
|
|
3332
|
+
...(token
|
|
3333
|
+
? { token, providerType }
|
|
3334
|
+
: { recoverySessionToken: input.recoverySessionToken }),
|
|
2657
3335
|
expectedDid: input.expectedDid,
|
|
2658
3336
|
options: { releasePolicy: 'pin' },
|
|
2659
3337
|
})
|
|
@@ -2721,6 +3399,23 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
2721
3399
|
'Recovery produced an incorrect key. Try another recovery method.'
|
|
2722
3400
|
);
|
|
2723
3401
|
}
|
|
3402
|
+
// PIN sign-in keeps the account by default: no re-split, no rebind.
|
|
3403
|
+
// Reached only after this call's PIN release succeeded and the DID matched.
|
|
3404
|
+
if (input.method === 'escrow-pin' && token) {
|
|
3405
|
+
return completePinSignInKeep(
|
|
3406
|
+
{ token, providerType, signDidAuthVp, options: params.options },
|
|
3407
|
+
{
|
|
3408
|
+
privateKey,
|
|
3409
|
+
primaryDid: material.primaryDid,
|
|
3410
|
+
recoveryShare: plaintext.recoveryShare,
|
|
3411
|
+
authShare,
|
|
3412
|
+
shareVersion: material.shareVersion,
|
|
3413
|
+
},
|
|
3414
|
+
input.pin,
|
|
3415
|
+
pinBefore,
|
|
3416
|
+
input.pinAttempts
|
|
3417
|
+
);
|
|
3418
|
+
}
|
|
2724
3419
|
pendingIdentityRecovery = {
|
|
2725
3420
|
privateKey,
|
|
2726
3421
|
primaryDid: material.primaryDid,
|
|
@@ -2728,11 +3423,23 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
2728
3423
|
authShare,
|
|
2729
3424
|
rebindSessionToken: material.rebindSessionToken,
|
|
2730
3425
|
fromEscrow: true,
|
|
3426
|
+
// The PIN itself is not kept across the new-login step.
|
|
3427
|
+
...(input.method === 'escrow-pin'
|
|
3428
|
+
? {
|
|
3429
|
+
pinKeep: {
|
|
3430
|
+
shareVersion: material.shareVersion,
|
|
3431
|
+
...(input.pinAttempts
|
|
3432
|
+
? { pinAttempts: { ...input.pinAttempts } }
|
|
3433
|
+
: {}),
|
|
3434
|
+
},
|
|
3435
|
+
}
|
|
3436
|
+
: {}),
|
|
2731
3437
|
};
|
|
2732
|
-
// The OTP path uses exactly this pending state
|
|
2733
|
-
//
|
|
3438
|
+
// The OTP path uses exactly this pending state. Without a provider session
|
|
3439
|
+
// the caller completes it after signing in: a hold release with the
|
|
3440
|
+
// atomic rebind, a PIN release by linking the new login only.
|
|
2734
3441
|
if (!token) return { privateKey, did: material.primaryDid };
|
|
2735
|
-
return
|
|
3442
|
+
return completeIdentityRecoveryWith({
|
|
2736
3443
|
token,
|
|
2737
3444
|
providerType,
|
|
2738
3445
|
signDidAuthVp,
|
|
@@ -3483,143 +4190,146 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
3483
4190
|
},
|
|
3484
4191
|
|
|
3485
4192
|
async completeIdentityRecovery(params): Promise<RecoveryResult> {
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
4193
|
+
// Public entry (sign-in completion, retry). A pending PIN sign-in only links
|
|
4194
|
+
// the login; any other pending recovery rebinds with a full rotation.
|
|
4195
|
+
return completeIdentityRecoveryWith(params);
|
|
4196
|
+
},
|
|
3490
4197
|
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
4198
|
+
/**
|
|
4199
|
+
* "Secure my account": one atomic server update re-splits the key and drops all
|
|
4200
|
+
* older auth share versions, signing out other devices and turning off the old
|
|
4201
|
+
* phrase/backup/passkey/email key. Staged through the pending-candidate slot, so
|
|
4202
|
+
* a lost acknowledgement is promoted by the usual reconciliation. Escrow is then
|
|
4203
|
+
* re-enrolled with `pin` (fresh salt) or, for `null`, without a PIN. The PIN is
|
|
4204
|
+
* used in this call only and never kept in retry state.
|
|
4205
|
+
*/
|
|
4206
|
+
async revokeOtherDevices(params): Promise<RevokeOtherDevicesResult> {
|
|
4207
|
+
const { token, providerType, privateKey, did, signDidAuthVp, pin } = params;
|
|
4208
|
+
if (!signDidAuthVp) throw new Error('DID proof signing is required');
|
|
4209
|
+
if (pin !== null) {
|
|
4210
|
+
const validation = validatePin(pin);
|
|
4211
|
+
if (!validation.ok)
|
|
4212
|
+
throw new Error(
|
|
4213
|
+
validation.reason === 'length'
|
|
4214
|
+
? 'PIN must contain 6–12 digits.'
|
|
4215
|
+
: 'Choose a PIN without trivial or sequential patterns.'
|
|
4216
|
+
);
|
|
3494
4217
|
}
|
|
3495
|
-
|
|
4218
|
+
const storage = guardedStorage();
|
|
3496
4219
|
const storageId = activeStorageId;
|
|
4220
|
+
const assertUser = captureActiveUserGuard();
|
|
4221
|
+
const assertActive = (): void => {
|
|
4222
|
+
assertUser();
|
|
4223
|
+
storage.assertCurrent();
|
|
4224
|
+
};
|
|
4225
|
+
const before = await fetchAuthShareRaw(
|
|
4226
|
+
serverUrl,
|
|
4227
|
+
token,
|
|
4228
|
+
providerType,
|
|
4229
|
+
undefined,
|
|
4230
|
+
tenantId
|
|
4231
|
+
);
|
|
4232
|
+
if (!before || before.primaryDid !== did) {
|
|
4233
|
+
throw new Error('This account could not be found. Sign in again.');
|
|
4234
|
+
}
|
|
4235
|
+
// Re-enroll only an account that had escrow and has not opted out.
|
|
4236
|
+
const hadEscrow =
|
|
4237
|
+
!before.escrowOptedOut &&
|
|
4238
|
+
(before.priorEscrowEnrollment === true ||
|
|
4239
|
+
(Array.isArray(before.recoveryMethods) &&
|
|
4240
|
+
before.recoveryMethods.some(
|
|
4241
|
+
(method: { type?: string }) => method?.type === 'escrow'
|
|
4242
|
+
)));
|
|
4243
|
+
const didFromKey = async (key: string): Promise<string> =>
|
|
4244
|
+
key === privateKey ? did : `${did}:mismatch`;
|
|
4245
|
+
|
|
3497
4246
|
const { shares, shareVersion } = await withShareUpdateLock(storage, storageId, () =>
|
|
3498
4247
|
withRotationLock(async () => {
|
|
3499
|
-
const
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
const candidates = await readPendingShareCandidates(storage, storageId);
|
|
3510
|
-
if (pending.rebind && pending.rebind.storageId !== storageId) {
|
|
3511
|
-
throw new Error('Active account changed during identity recovery');
|
|
3512
|
-
}
|
|
3513
|
-
if (
|
|
3514
|
-
candidates.some(
|
|
3515
|
-
candidate => candidate.share !== pending.rebind?.shares.deviceShare
|
|
3516
|
-
)
|
|
3517
|
-
) {
|
|
3518
|
-
throw new Error(
|
|
3519
|
-
'Reconcile the pending share update before binding a sign-in'
|
|
3520
|
-
);
|
|
3521
|
-
}
|
|
3522
|
-
|
|
3523
|
-
let shareVersion: number | undefined;
|
|
3524
|
-
const retry = pending.rebind?.attempted ?? false;
|
|
3525
|
-
if (retry && pending.rebind) {
|
|
3526
|
-
const current = await fetchAuthShareRaw(
|
|
3527
|
-
serverUrl,
|
|
3528
|
-
params.token,
|
|
3529
|
-
params.providerType,
|
|
3530
|
-
undefined,
|
|
3531
|
-
tenantId
|
|
3532
|
-
);
|
|
3533
|
-
const authShare = authShareToString(current?.authShare);
|
|
3534
|
-
// prepareIdentityRecovery already verified this key's DID.
|
|
3535
|
-
// Comparing the reconstructed key avoids trusting server DID metadata.
|
|
3536
|
-
if (authShare && current?.shareVersion != null) {
|
|
3537
|
-
const health = await verifyStoredShares(
|
|
3538
|
-
{
|
|
3539
|
-
getDevice: async () => pending.rebind!.shares.deviceShare,
|
|
3540
|
-
getAuth: async () => authShare,
|
|
3541
|
-
},
|
|
3542
|
-
pending.primaryDid,
|
|
3543
|
-
async key => (key === pending.privateKey ? pending.primaryDid : '')
|
|
4248
|
+
const { snapshot } = await resolvePendingShareCandidates(
|
|
4249
|
+
storage,
|
|
4250
|
+
storageId,
|
|
4251
|
+
async () => {
|
|
4252
|
+
const data = await fetchAuthShareRaw(
|
|
4253
|
+
serverUrl,
|
|
4254
|
+
token,
|
|
4255
|
+
providerType,
|
|
4256
|
+
undefined,
|
|
4257
|
+
tenantId
|
|
3544
4258
|
);
|
|
3545
|
-
|
|
3546
|
-
|
|
4259
|
+
return {
|
|
4260
|
+
currentVersion: data?.shareVersion ?? null,
|
|
4261
|
+
authShare: authShareToString(data?.authShare),
|
|
4262
|
+
};
|
|
4263
|
+
},
|
|
4264
|
+
did,
|
|
4265
|
+
didFromKey,
|
|
4266
|
+
assertActive,
|
|
4267
|
+
recordPromotion(did),
|
|
4268
|
+
async version =>
|
|
4269
|
+
authShareToString(
|
|
4270
|
+
(
|
|
4271
|
+
await fetchAuthShareRaw(
|
|
4272
|
+
serverUrl,
|
|
4273
|
+
token,
|
|
4274
|
+
providerType,
|
|
4275
|
+
version,
|
|
4276
|
+
tenantId
|
|
4277
|
+
)
|
|
4278
|
+
)?.authShare
|
|
4279
|
+
)
|
|
4280
|
+
);
|
|
4281
|
+
if (!snapshot.currentVersion) {
|
|
4282
|
+
throw new Error('This account could not be found. Sign in again.');
|
|
3547
4283
|
}
|
|
3548
|
-
|
|
4284
|
+
const expectedShareVersion = snapshot.currentVersion;
|
|
4285
|
+
const didAuthVp = await requestFreshDidAuthVp(
|
|
4286
|
+
serverUrl,
|
|
4287
|
+
privateKey,
|
|
4288
|
+
did,
|
|
4289
|
+
signDidAuthVp,
|
|
4290
|
+
tenantId
|
|
4291
|
+
);
|
|
3549
4292
|
assertActive();
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
tenantId
|
|
3557
|
-
);
|
|
3558
|
-
assertActive();
|
|
3559
|
-
pending.rebind ??= {
|
|
3560
|
-
shares: (await splitAndVerify(pending.privateKey)).shares,
|
|
3561
|
-
storageId,
|
|
3562
|
-
attempted: false,
|
|
3563
|
-
};
|
|
3564
|
-
const rebind = pending.rebind;
|
|
3565
|
-
if (
|
|
3566
|
-
!candidates.some(
|
|
3567
|
-
candidate => candidate.share === rebind.shares.deviceShare
|
|
3568
|
-
)
|
|
3569
|
-
) {
|
|
4293
|
+
let shareVersion: number | undefined;
|
|
4294
|
+
let stagedShare = '';
|
|
4295
|
+
const shares = await atomicShareUpdate(privateKey, {
|
|
4296
|
+
storeDevice: async share => {
|
|
4297
|
+
assertActive();
|
|
4298
|
+
stagedShare = share;
|
|
3570
4299
|
await addPendingShareCandidate(
|
|
3571
4300
|
storage,
|
|
3572
|
-
{ share
|
|
4301
|
+
{ share, createdAt: Date.now() },
|
|
3573
4302
|
storageId
|
|
3574
4303
|
);
|
|
3575
|
-
}
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
4304
|
+
},
|
|
4305
|
+
clearDevice: () =>
|
|
4306
|
+
removePendingShareCandidate(storage, stagedShare, storageId),
|
|
4307
|
+
storeAuth: async share => {
|
|
4308
|
+
assertActive();
|
|
3579
4309
|
const response = await postJson<{
|
|
4310
|
+
success: true;
|
|
3580
4311
|
shareVersion: number;
|
|
3581
4312
|
recoveryMethodsRequireConfirmation: string[];
|
|
3582
4313
|
}>(
|
|
3583
|
-
`${serverUrl}/keys/
|
|
4314
|
+
`${serverUrl}/keys/revoke-other-devices`,
|
|
3584
4315
|
{
|
|
3585
|
-
|
|
3586
|
-
providerType
|
|
3587
|
-
primaryDid:
|
|
3588
|
-
authShare: {
|
|
3589
|
-
|
|
3590
|
-
encryptedDek: '',
|
|
3591
|
-
iv: '',
|
|
3592
|
-
},
|
|
4316
|
+
authToken: token,
|
|
4317
|
+
providerType,
|
|
4318
|
+
primaryDid: did,
|
|
4319
|
+
authShare: { encryptedData: share, encryptedDek: '', iv: '' },
|
|
4320
|
+
expectedShareVersion,
|
|
3593
4321
|
},
|
|
3594
|
-
|
|
3595
|
-
...buildHeaders('', didAuthVp, tenantId),
|
|
3596
|
-
'X-Auth-Token': params.token,
|
|
3597
|
-
}
|
|
4322
|
+
buildHeaders(token, didAuthVp, tenantId)
|
|
3598
4323
|
);
|
|
3599
|
-
|
|
3600
4324
|
shareVersion = response.shareVersion;
|
|
3601
|
-
}
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
await removePendingShareCandidate(
|
|
3606
|
-
storage,
|
|
3607
|
-
rebind.shares.deviceShare,
|
|
3608
|
-
storageId
|
|
3609
|
-
);
|
|
3610
|
-
pending.rebind = undefined;
|
|
3611
|
-
}
|
|
3612
|
-
throw error;
|
|
3613
|
-
}
|
|
3614
|
-
}
|
|
3615
|
-
if (shareVersion === undefined || !pending.rebind) {
|
|
3616
|
-
throw new Error('Server did not confirm the new share');
|
|
4325
|
+
},
|
|
4326
|
+
});
|
|
4327
|
+
if (shareVersion === undefined) {
|
|
4328
|
+
throw new Error('Server did not return a share version');
|
|
3617
4329
|
}
|
|
3618
|
-
assertActive();
|
|
3619
|
-
const shares = pending.rebind.shares;
|
|
3620
4330
|
await storage.storeDeviceShare(shares.deviceShare, storageId);
|
|
3621
4331
|
await storage.storeShareVersion(shareVersion, storageId);
|
|
3622
|
-
await
|
|
4332
|
+
await removePendingShareCandidate(storage, shares.deviceShare, storageId);
|
|
3623
4333
|
assertActive();
|
|
3624
4334
|
lastEmailShare = shares.emailShare;
|
|
3625
4335
|
lastShareVersion = shareVersion;
|
|
@@ -3627,48 +4337,48 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
3627
4337
|
currentVersion: shareVersion,
|
|
3628
4338
|
resolvedVersion: shareVersion,
|
|
3629
4339
|
authShare: shares.authShare,
|
|
3630
|
-
primaryDid:
|
|
4340
|
+
primaryDid: did,
|
|
3631
4341
|
};
|
|
3632
|
-
pendingIdentityRecovery = undefined;
|
|
3633
4342
|
return { shares, shareVersion };
|
|
3634
4343
|
})
|
|
3635
4344
|
);
|
|
3636
4345
|
|
|
3637
|
-
//
|
|
3638
|
-
|
|
3639
|
-
|
|
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
|
-
));
|
|
3655
|
-
if (activeStorageId !== storageId || storageGeneration !== generation) {
|
|
3656
|
-
throw new Error('This recovery request was cancelled.');
|
|
4346
|
+
// lca-api unset the escrow blob and PIN with the old versions; re-enroll now.
|
|
4347
|
+
if (!hadEscrow || !config.escrow?.enabled) {
|
|
4348
|
+
return { shareVersion, escrowEnrolled: false, pinKept: false };
|
|
3657
4349
|
}
|
|
4350
|
+
const pinMaterial = pin === null ? undefined : await freshPinMaterial(pin);
|
|
4351
|
+
const enrollArgs: Parameters<typeof enrollEscrow> = [
|
|
4352
|
+
token,
|
|
4353
|
+
providerType,
|
|
4354
|
+
privateKey,
|
|
4355
|
+
did,
|
|
4356
|
+
shares,
|
|
4357
|
+
shareVersion,
|
|
4358
|
+
signDidAuthVp,
|
|
4359
|
+
undefined,
|
|
4360
|
+
pinMaterial,
|
|
4361
|
+
!pinMaterial,
|
|
4362
|
+
storage.assertCurrent,
|
|
4363
|
+
params.options?.recoveryEmail,
|
|
4364
|
+
params.options?.recoveryLanguage,
|
|
4365
|
+
];
|
|
4366
|
+
const attempt = await attemptEnrollEscrow(...enrollArgs);
|
|
4367
|
+
// A definite authority refusal (403): an old-copy PIN compare still in flight
|
|
4368
|
+
// at the revoke can commit while this intent is evaluated, and the authority
|
|
4369
|
+
// then refuses it on its operation revision. Nothing committed and lca-api
|
|
4370
|
+
// released the lease, so one retry (re-derived from the authority's current
|
|
4371
|
+
// state) keeps the PIN just chosen instead of the PIN-clearing repair.
|
|
4372
|
+
const enrolled =
|
|
4373
|
+
attempt.enrolled ||
|
|
4374
|
+
(attempt.error instanceof EscrowRequestError &&
|
|
4375
|
+
attempt.error.status === 403 &&
|
|
4376
|
+
(await tryEnrollEscrow(...enrollArgs)));
|
|
3658
4377
|
if (!enrolled) {
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
shareVersion,
|
|
3662
|
-
primaryDid: pending.primaryDid,
|
|
3663
|
-
clearPin: true,
|
|
3664
|
-
};
|
|
4378
|
+
// The repair clears: PIN material (an offline-guessable verifier) is never kept.
|
|
4379
|
+
unenrolledRotation = { shares, shareVersion, primaryDid: did, clearPin: true };
|
|
3665
4380
|
}
|
|
3666
|
-
return {
|
|
3667
|
-
privateKey: pending.privateKey,
|
|
3668
|
-
did: pending.primaryDid,
|
|
3669
|
-
escrowEnrolled: enrolled,
|
|
3670
|
-
escrowRecovery: pending.fromEscrow,
|
|
3671
|
-
};
|
|
4381
|
+
return { shareVersion, escrowEnrolled: enrolled, pinKept: enrolled && !!pinMaterial };
|
|
3672
4382
|
},
|
|
3673
4383
|
|
|
3674
4384
|
// --- Contact method management ---
|