@learncard/sss-key-manager 0.2.4 → 0.2.6

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.
@@ -31,6 +31,7 @@ import type {
31
31
  EscrowHoldStatus,
32
32
  EscrowRecoveryStart,
33
33
  EscrowEnrollmentState,
34
+ RevokeOtherDevicesResult,
34
35
  } from './types';
35
36
  import {
36
37
  EscrowPinLockedError,
@@ -38,6 +39,7 @@ import {
38
39
  EscrowPinThrottledError,
39
40
  EscrowHoldRestartThrottledError,
40
41
  EscrowPinUnavailableError,
42
+ PinSignInStaleError,
41
43
  } from './types';
42
44
  import {
43
45
  ESCROW_PIN_LOCKED_MESSAGE,
@@ -64,6 +66,7 @@ import {
64
66
  escrowAuthorityMeasurements,
65
67
  type EscrowHoldRecordLike,
66
68
  } from './escrow-authority';
69
+ import { getEscrowBlobIdentity } from './escrow-enrollment-intent';
67
70
  import type { VerifiedEnclaveIdentity, EscrowAuthorityState } from './escrow-authority.types';
68
71
  import { EscrowEvidenceError } from './escrow-authority.types';
69
72
  import type { EscrowEnvelope } from './escrow-crypto';
@@ -243,6 +246,14 @@ export class EscrowRequestError extends Error {
243
246
  }
244
247
  }
245
248
 
249
+ /** Independently generated splits can reuse a share's final-byte x-coordinate. */
250
+ export class DuplicateShareCoordinateError extends Error {
251
+ constructor() {
252
+ super('Stored shares have the same coordinate and cannot be combined');
253
+ this.name = 'DuplicateShareCoordinateError';
254
+ }
255
+ }
256
+
246
257
  const fetchAuthShareRaw = async (
247
258
  serverUrl: string,
248
259
  token: string,
@@ -1102,6 +1113,11 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1102
1113
  rebindSessionToken: string;
1103
1114
  /** Recovered from an existing escrow enrollment (hold or PIN), not another method. */
1104
1115
  fromEscrow: boolean;
1116
+ /**
1117
+ * PIN sign-in awaiting a new login: completion only links that login
1118
+ * (no re-split, no history or recovery-method change) at this version.
1119
+ */
1120
+ pinKeep?: { shareVersion: number };
1105
1121
  rebind?: { shares: SSSShares; storageId: string | undefined; attempted: boolean };
1106
1122
  }
1107
1123
  | undefined;
@@ -1349,7 +1365,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1349
1365
  providerType: AuthProviderType,
1350
1366
  privateKey: string,
1351
1367
  primaryDid: string,
1352
- shares: SSSShares,
1368
+ // Only the recovery share is sealed; a PIN sign-in re-set has no other share.
1369
+ shares: Pick<SSSShares, 'recoveryShare'>,
1353
1370
  shareVersion: number,
1354
1371
  signDidAuthVp?: DidAuthVpSigner,
1355
1372
  verifiedKey?: VerifiedEnclaveIdentity,
@@ -1366,7 +1383,10 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1366
1383
  // target blob whose plaintext already carries either field, since
1367
1384
  // the enclave always fills them in from the source blob instead.
1368
1385
  recoveryEmail = '',
1369
- recoveryLanguage = ''
1386
+ recoveryLanguage = '',
1387
+ // Same-PIN refresh after a PIN sign-in (E): the server writes only while the
1388
+ // current PIN still shows exactly these charges.
1389
+ expectedPinCharges?: { attemptsRemaining: number; wrongAttempts: number }
1370
1390
  ): Promise<void> => {
1371
1391
  if (!config.escrow?.enabled) throw new Error('Escrow enrollment is disabled');
1372
1392
  if (!signDidAuthVp) throw new Error('DID proof signing is required for escrow enrollment');
@@ -1445,6 +1465,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1445
1465
  ...(signedIntent
1446
1466
  ? { signedIntent, sourceEnvelope: authority?.envelope ?? null }
1447
1467
  : {}),
1468
+ ...(expectedPinCharges ? { expectedPinCharges } : {}),
1448
1469
  }),
1449
1470
  });
1450
1471
  if (signedIntent) {
@@ -1458,11 +1479,14 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1458
1479
  }
1459
1480
  };
1460
1481
 
1461
- const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<boolean> => {
1462
- if (!config.escrow?.enabled) return false;
1482
+ /** `tryEnrollEscrow` that also returns a failed attempt's error, for callers that retry. */
1483
+ const attemptEnrollEscrow = async (
1484
+ ...args: Parameters<typeof enrollEscrow>
1485
+ ): Promise<{ enrolled: true } | { enrolled: false; error?: unknown }> => {
1486
+ if (!config.escrow?.enabled) return { enrolled: false };
1463
1487
  try {
1464
1488
  await enrollEscrow(...args);
1465
- return true;
1489
+ return { enrolled: true };
1466
1490
  } catch (err) {
1467
1491
  // A reporting callback must not turn a successful rotation into a failure.
1468
1492
  try {
@@ -1471,10 +1495,13 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1471
1495
  } catch {
1472
1496
  console.warn('SSS: escrow error reporting failed');
1473
1497
  }
1474
- return false;
1498
+ return { enrolled: false, error: err };
1475
1499
  }
1476
1500
  };
1477
1501
 
1502
+ const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<boolean> =>
1503
+ (await attemptEnrollEscrow(...args)).enrolled;
1504
+
1478
1505
  /** Report a committed automatic PIN clear so the caller can prompt for a new PIN. */
1479
1506
  const withAutoClear = (
1480
1507
  shareVersion: number,
@@ -1566,7 +1593,35 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1566
1593
  current.status === 'active' &&
1567
1594
  current.shareVersion === status.shareVersion &&
1568
1595
  current.envelope?.keyId === verifiedKey.keyId;
1596
+ // lca-api must serve the authority's copy: a PIN operation whose result never
1597
+ // reached Mongo leaves the old copy there, and every hold or PIN attempt
1598
+ // against it is refused. Older servers do not report the identity.
1599
+ let identityMismatch = false;
1600
+ if (currentlyEnrolled && current.envelope && status.escrowBlobIdentity !== undefined) {
1601
+ const authorityIdentity = await getEscrowBlobIdentity(current.envelope);
1602
+ if (authorityIdentity !== status.escrowBlobIdentity) {
1603
+ // The authority-state read settles a crashed operation server-side;
1604
+ // read lca-api's copy again before treating it as a mismatch.
1605
+ const reread = await self.fetchServerKeyStatus(
1606
+ params.token,
1607
+ params.providerType
1608
+ );
1609
+ storage.assertCurrent();
1610
+ // While a PIN operation holds the server's lease its copy may trail
1611
+ // the authority for a moment; lca-api converges on its own, so this
1612
+ // is not (yet) a stale copy and must not prompt a PIN reset.
1613
+ identityMismatch =
1614
+ reread.escrowBlobIdentity !== authorityIdentity &&
1615
+ reread.escrowUpdating !== true;
1616
+ }
1617
+ }
1618
+ if (identityMismatch) currentlyEnrolled = false;
1569
1619
  if (currentlyEnrolled) verifiedEscrowEnvelope = current.envelope ?? undefined;
1620
+ // lca-api's PIN record (salt) belongs to the old copy, so neither a carry nor a
1621
+ // keep can repair a PIN-protected mismatch: only a user-chosen PIN (`set`) or an
1622
+ // explicit clear re-binds both. Never rotate or sign here.
1623
+ if (identityMismatch && current.hasPin && pin === undefined && !clearPinForEnrollment)
1624
+ return { enrolled: false, reason: 'pin-reset-required' };
1570
1625
  hasPin = current.hasPin;
1571
1626
  // After an identity-recovery rebind lca-api has no PIN left to carry, so an
1572
1627
  // automatic carry/keep can only fail.
@@ -1758,6 +1813,538 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1758
1813
  };
1759
1814
  };
1760
1815
 
1816
+ /**
1817
+ * True unless the server proves no wrong entry is recorded against the current PIN.
1818
+ * A missing count (legacy record, older server, failed lookup) is treated as "wrong
1819
+ * entries recorded", so the same PIN never gets a fresh budget on a guess.
1820
+ */
1821
+ const pinHasRecordedWrongAttempts = (escrowPin: unknown): boolean => {
1822
+ const status = escrowPin as { state?: unknown; wrongAttempts?: unknown } | undefined;
1823
+ if (status?.state === 'none') return false;
1824
+ return typeof status?.wrongAttempts !== 'number' || status.wrongAttempts > 0;
1825
+ };
1826
+
1827
+ /**
1828
+ * The charges a same-PIN refresh may replace, or undefined when it must not run:
1829
+ * zero wrong entries before AND after this release, and exactly one charge between
1830
+ * the two reads (this release). Any other charge may be someone else's guess still
1831
+ * in flight, so the PIN keeps its reduced budget. The server re-checks these
1832
+ * charges in the write itself (`expectedPinCharges`).
1833
+ */
1834
+ const refreshablePinCharges = (
1835
+ before: unknown,
1836
+ after: unknown
1837
+ ): { attemptsRemaining: number; wrongAttempts: number } | undefined => {
1838
+ const pre = before as { state?: unknown; attemptsRemaining?: unknown } | undefined;
1839
+ const post = after as { state?: unknown; attemptsRemaining?: unknown } | undefined;
1840
+ if (pinHasRecordedWrongAttempts(before) || pinHasRecordedWrongAttempts(after))
1841
+ return undefined;
1842
+ if (pre?.state !== 'enabled' || post?.state !== 'enabled') return undefined;
1843
+ if (typeof pre.attemptsRemaining !== 'number' || typeof post.attemptsRemaining !== 'number')
1844
+ return undefined;
1845
+ if (post.attemptsRemaining !== pre.attemptsRemaining - 1) return undefined;
1846
+ return { attemptsRemaining: post.attemptsRemaining, wrongAttempts: 0 };
1847
+ };
1848
+
1849
+ /** New salt and verifier for a PIN just proven; undefined when it fails the PIN rules. */
1850
+ const freshPinMaterial = async (
1851
+ pin: string
1852
+ ): Promise<{ pinSalt: string; pinVerifier: string } | undefined> => {
1853
+ if (!validatePin(pin).ok) return undefined;
1854
+ try {
1855
+ const pinSalt = generatePinSalt();
1856
+ return { pinSalt, pinVerifier: await derivePinProof(pin, pinSalt) };
1857
+ } catch {
1858
+ return undefined;
1859
+ }
1860
+ };
1861
+
1862
+ /**
1863
+ * Store a PIN-released recovery share as this device's share at its own version.
1864
+ * Only the main slot is written: the recovery share is never staged as a pending
1865
+ * candidate, and unrelated candidates are left for the usual reconciliation, because
1866
+ * a PIN sign-in changes nothing on the server.
1867
+ */
1868
+ const adoptReleasedShare = async (
1869
+ storage: SSSStorageFunctions & { assertCurrent: () => void },
1870
+ storageId: string | undefined,
1871
+ released: { primaryDid: string; recoveryShare: string; authShare: string },
1872
+ shareVersion: number,
1873
+ currentVersion: number
1874
+ ): Promise<void> => {
1875
+ await storage.storeDeviceShare(released.recoveryShare, storageId);
1876
+ await storage.storeShareVersion(shareVersion, storageId);
1877
+ // The released split's email share is not known on this device.
1878
+ lastEmailShare = undefined;
1879
+ lastShareVersion = shareVersion;
1880
+ lastServerSnapshot = {
1881
+ currentVersion,
1882
+ resolvedVersion: shareVersion,
1883
+ authShare: released.authShare,
1884
+ primaryDid: released.primaryDid,
1885
+ };
1886
+ };
1887
+
1888
+ /**
1889
+ * PIN sign-in with the login already bound to the account (the default "keep"
1890
+ * path). Nothing is re-split and no server state changes apart from re-setting the
1891
+ * same PIN for a fresh attempt budget. That re-set runs only when the server shows
1892
+ * zero wrong entries against the current PIN at release time; otherwise the PIN
1893
+ * keeps its reduced budget and `newPinRequired` asks the user for a different PIN,
1894
+ * so a guesser gets at most one budget per PIN in total. `pin` is used in this
1895
+ * call only: never stored in pending/retry state, logged or reported.
1896
+ *
1897
+ * Version race: if another device rotated after the release, the device is stored
1898
+ * at the released version as long as the server still holds it in history
1899
+ * (`deferred-rotation`; the next start-up's reconcileShares advances it without
1900
+ * wiping). If that version is gone (a revoke or rebind), it fails closed with
1901
+ * PinSignInStaleError and stores nothing; it never falls back to a rotation.
1902
+ */
1903
+ const completePinSignInKeep = async (
1904
+ params: {
1905
+ token: string;
1906
+ providerType: AuthProviderType;
1907
+ signDidAuthVp?: DidAuthVpSigner;
1908
+ options?: { recoveryEmail?: string; recoveryLanguage?: string };
1909
+ },
1910
+ released: {
1911
+ privateKey: string;
1912
+ primaryDid: string;
1913
+ recoveryShare: string;
1914
+ authShare: string;
1915
+ shareVersion: number;
1916
+ },
1917
+ pin: string,
1918
+ /** PIN status read before this release; undefined when that read failed. */
1919
+ pinBefore: unknown
1920
+ ): Promise<RecoveryResult> => {
1921
+ const storage = guardedStorage();
1922
+ const generation = storageGeneration;
1923
+ const storageId = activeStorageId;
1924
+ const assertActive = (): void => {
1925
+ if (activeStorageId !== storageId || storageGeneration !== generation) {
1926
+ throw new Error('This recovery request was cancelled.');
1927
+ }
1928
+ };
1929
+
1930
+ const { mode, newPinRequired, refreshCharges } = await withShareUpdateLock(
1931
+ storage,
1932
+ storageId,
1933
+ () =>
1934
+ withRotationLock(async () => {
1935
+ assertActive();
1936
+ const current = await fetchAuthShareRaw(
1937
+ serverUrl,
1938
+ params.token,
1939
+ params.providerType,
1940
+ undefined,
1941
+ tenantId
1942
+ );
1943
+ assertActive();
1944
+ if (
1945
+ !current ||
1946
+ current.primaryDid !== released.primaryDid ||
1947
+ typeof current.shareVersion !== 'number'
1948
+ ) {
1949
+ throw new PinSignInStaleError();
1950
+ }
1951
+ let mode: 'kept' | 'deferred-rotation';
1952
+ if (current.shareVersion === released.shareVersion) {
1953
+ if (authShareToString(current.authShare) !== released.authShare) {
1954
+ throw new PinSignInStaleError();
1955
+ }
1956
+ mode = 'kept';
1957
+ } else {
1958
+ const historical =
1959
+ current.shareVersion > released.shareVersion
1960
+ ? await fetchAuthShareRaw(
1961
+ serverUrl,
1962
+ params.token,
1963
+ params.providerType,
1964
+ released.shareVersion,
1965
+ tenantId
1966
+ )
1967
+ : null;
1968
+ if (authShareToString(historical?.authShare) !== released.authShare) {
1969
+ throw new PinSignInStaleError();
1970
+ }
1971
+ mode = 'deferred-rotation';
1972
+ }
1973
+ assertActive();
1974
+ await adoptReleasedShare(
1975
+ storage,
1976
+ storageId,
1977
+ released,
1978
+ released.shareVersion,
1979
+ current.shareVersion
1980
+ );
1981
+ pendingIdentityRecovery = undefined;
1982
+ // Both reads count: the one after this release (a success never resets the
1983
+ // count) and the one before it (a legacy record without a verified count
1984
+ // reports 0 once any release has been reserved, so only the earlier read
1985
+ // shows that its history is unknown).
1986
+ return {
1987
+ mode,
1988
+ newPinRequired:
1989
+ pinHasRecordedWrongAttempts(pinBefore) ||
1990
+ pinHasRecordedWrongAttempts(current.escrowPin),
1991
+ refreshCharges: refreshablePinCharges(pinBefore, current.escrowPin),
1992
+ };
1993
+ })
1994
+ );
1995
+
1996
+ // Same PIN, new salt and verifier, enrolled at the same version: a fresh
1997
+ // attempt budget without a rotation. Skipped after a race (the other device's
1998
+ // rotation already re-enrolled escrow) and whenever wrong entries are recorded
1999
+ // against this PIN (the user must pick a different PIN instead). A failure
2000
+ // leaves the PIN at this version valid, so nothing is cleared and no repair is
2001
+ // scheduled.
2002
+ let pinRefreshed = false;
2003
+ let pinRefreshFailed = false;
2004
+ if (mode === 'kept' && !newPinRequired && refreshCharges && params.signDidAuthVp) {
2005
+ const pinMaterial = await freshPinMaterial(pin);
2006
+ pinRefreshed =
2007
+ pinMaterial !== undefined &&
2008
+ (await tryEnrollEscrow(
2009
+ params.token,
2010
+ params.providerType,
2011
+ released.privateKey,
2012
+ released.primaryDid,
2013
+ { recoveryShare: released.recoveryShare },
2014
+ released.shareVersion,
2015
+ params.signDidAuthVp,
2016
+ undefined,
2017
+ pinMaterial,
2018
+ false,
2019
+ storage.assertCurrent,
2020
+ params.options?.recoveryEmail,
2021
+ params.options?.recoveryLanguage,
2022
+ refreshCharges
2023
+ ));
2024
+ // Reported, not swallowed: the PIN keeps (at most) its reduced budget. A commit
2025
+ // whose receipt was lost is not confirmed here, so it reports failed too (an
2026
+ // under-claim: the budget is in fact full). lca-api either
2027
+ // refused before the authority (both copies unchanged) or converges to the
2028
+ // authority's commit; a copy left behind is caught by ensureEscrowEnrollment's
2029
+ // identity check, which never clears the PIN on its own.
2030
+ pinRefreshFailed = pinMaterial !== undefined && !pinRefreshed;
2031
+ }
2032
+ assertActive();
2033
+ return {
2034
+ privateKey: released.privateKey,
2035
+ did: released.primaryDid,
2036
+ escrowEnrolled: true,
2037
+ escrowRecovery: true,
2038
+ pinSignIn: {
2039
+ mode,
2040
+ pinRefreshed,
2041
+ newPinRequired,
2042
+ ...(pinRefreshFailed ? { pinRefreshFailed: true } : {}),
2043
+ },
2044
+ };
2045
+ };
2046
+
2047
+ /**
2048
+ * PIN sign-in completed with a login that is not yet bound (lost-login flow). The
2049
+ * PIN-release rebind token links the login on the server without touching shares,
2050
+ * history, recovery methods or escrow. No PIN is held across the new-login step, so
2051
+ * the attempt budget is not refreshed here.
2052
+ */
2053
+ const completePinSignInRelink = async (
2054
+ params: Parameters<NonNullable<SSSKeyDerivationStrategy['completeIdentityRecovery']>>[0],
2055
+ pending: NonNullable<typeof pendingIdentityRecovery>,
2056
+ signDidAuthVp: DidAuthVpSigner
2057
+ ): Promise<RecoveryResult> => {
2058
+ const storage = guardedStorage();
2059
+ const generation = storageGeneration;
2060
+ const storageId = activeStorageId;
2061
+ const shareVersion = pending.pinKeep!.shareVersion;
2062
+ const assertActive = (): void => {
2063
+ if (
2064
+ activeStorageId !== storageId ||
2065
+ pendingIdentityRecovery !== pending ||
2066
+ storageGeneration !== generation
2067
+ ) {
2068
+ throw new Error('This recovery request was cancelled.');
2069
+ }
2070
+ };
2071
+
2072
+ await withShareUpdateLock(storage, storageId, () =>
2073
+ withRotationLock(async () => {
2074
+ assertActive();
2075
+ const didAuthVp = await requestFreshDidAuthVp(
2076
+ serverUrl,
2077
+ pending.privateKey,
2078
+ pending.primaryDid,
2079
+ signDidAuthVp,
2080
+ tenantId
2081
+ );
2082
+ assertActive();
2083
+ try {
2084
+ const response = await postJson<{
2085
+ success: true;
2086
+ shareVersion: number;
2087
+ linked: boolean;
2088
+ }>(
2089
+ `${serverUrl}/keys/recovery-session/pin-sign-in`,
2090
+ {
2091
+ recoverySessionToken: pending.rebindSessionToken,
2092
+ providerType: params.providerType,
2093
+ primaryDid: pending.primaryDid,
2094
+ },
2095
+ {
2096
+ ...buildHeaders('', didAuthVp, tenantId),
2097
+ 'X-Auth-Token': params.token,
2098
+ }
2099
+ );
2100
+ if (response.shareVersion !== shareVersion) throw new PinSignInStaleError();
2101
+ } catch (error) {
2102
+ // The single-use token may have been consumed before the response was
2103
+ // lost (or by an earlier attempt). The link is proven only when this
2104
+ // login now resolves to the same account, version and auth share.
2105
+ const current = await fetchAuthShareRaw(
2106
+ serverUrl,
2107
+ params.token,
2108
+ params.providerType,
2109
+ undefined,
2110
+ tenantId
2111
+ ).catch(() => null);
2112
+ if (
2113
+ !current ||
2114
+ current.primaryDid !== pending.primaryDid ||
2115
+ current.shareVersion !== shareVersion ||
2116
+ authShareToString(current.authShare) !== pending.authShare
2117
+ ) {
2118
+ throw error;
2119
+ }
2120
+ }
2121
+ assertActive();
2122
+ await adoptReleasedShare(storage, storageId, pending, shareVersion, shareVersion);
2123
+ pendingIdentityRecovery = undefined;
2124
+ })
2125
+ );
2126
+
2127
+ if (activeStorageId !== storageId || storageGeneration !== generation) {
2128
+ throw new Error('This recovery request was cancelled.');
2129
+ }
2130
+ // The budget is never refreshed here; this only tells the app whether the PIN
2131
+ // has recorded wrong entries (unknown counts as yes) so it asks for a new one.
2132
+ const status = await fetchAuthShareRaw(
2133
+ serverUrl,
2134
+ params.token,
2135
+ params.providerType,
2136
+ undefined,
2137
+ tenantId
2138
+ ).catch(() => null);
2139
+ return {
2140
+ privateKey: pending.privateKey,
2141
+ did: pending.primaryDid,
2142
+ escrowEnrolled: true,
2143
+ escrowRecovery: true,
2144
+ pinSignIn: {
2145
+ mode: 'relinked',
2146
+ pinRefreshed: false,
2147
+ newPinRequired: pinHasRecordedWrongAttempts(status?.escrowPin),
2148
+ },
2149
+ };
2150
+ };
2151
+
2152
+ /**
2153
+ * Rebinds a pending identity recovery to the new sign-in and re-enrolls escrow.
2154
+ * A pending PIN sign-in (`pinKeep`) only links the login; see completePinSignInRelink.
2155
+ */
2156
+ const completeIdentityRecoveryWith = async (
2157
+ params: Parameters<NonNullable<SSSKeyDerivationStrategy['completeIdentityRecovery']>>[0]
2158
+ ): Promise<RecoveryResult> => {
2159
+ const storage = guardedStorage();
2160
+ const generation = storageGeneration;
2161
+ const pending = pendingIdentityRecovery;
2162
+ const signDidAuthVp = params.signDidAuthVp;
2163
+
2164
+ if (!pending) throw new Error('No identity recovery is waiting for a new sign-in');
2165
+ if (!signDidAuthVp) {
2166
+ throw new Error('DID proof signing is required to bind a new sign-in');
2167
+ }
2168
+ if (pending.pinKeep) return completePinSignInRelink(params, pending, signDidAuthVp);
2169
+
2170
+ const storageId = activeStorageId;
2171
+ const { shares, shareVersion } = await withShareUpdateLock(storage, storageId, () =>
2172
+ withRotationLock(async () => {
2173
+ const assertActive = (): void => {
2174
+ if (
2175
+ activeStorageId !== storageId ||
2176
+ pendingIdentityRecovery !== pending ||
2177
+ storageGeneration !== generation
2178
+ ) {
2179
+ throw new Error('This recovery request was cancelled.');
2180
+ }
2181
+ };
2182
+ assertActive();
2183
+ const candidates = await readPendingShareCandidates(storage, storageId);
2184
+ if (pending.rebind && pending.rebind.storageId !== storageId) {
2185
+ throw new Error('Active account changed during identity recovery');
2186
+ }
2187
+ if (
2188
+ candidates.some(
2189
+ candidate => candidate.share !== pending.rebind?.shares.deviceShare
2190
+ )
2191
+ ) {
2192
+ throw new Error('Reconcile the pending share update before binding a sign-in');
2193
+ }
2194
+
2195
+ let shareVersion: number | undefined;
2196
+ const retry = pending.rebind?.attempted ?? false;
2197
+ if (retry && pending.rebind) {
2198
+ const current = await fetchAuthShareRaw(
2199
+ serverUrl,
2200
+ params.token,
2201
+ params.providerType,
2202
+ undefined,
2203
+ tenantId
2204
+ );
2205
+ const authShare = authShareToString(current?.authShare);
2206
+ // prepareIdentityRecovery already verified this key's DID.
2207
+ // Comparing the reconstructed key avoids trusting server DID metadata.
2208
+ if (authShare && current?.shareVersion != null) {
2209
+ const health = await verifyStoredShares(
2210
+ {
2211
+ getDevice: async () => pending.rebind!.shares.deviceShare,
2212
+ getAuth: async () => authShare,
2213
+ },
2214
+ pending.primaryDid,
2215
+ async key => (key === pending.privateKey ? pending.primaryDid : '')
2216
+ );
2217
+ if (health.healthy) shareVersion = current.shareVersion;
2218
+ }
2219
+ }
2220
+
2221
+ assertActive();
2222
+ if (shareVersion === undefined) {
2223
+ const didAuthVp = await requestFreshDidAuthVp(
2224
+ serverUrl,
2225
+ pending.privateKey,
2226
+ pending.primaryDid,
2227
+ signDidAuthVp,
2228
+ tenantId
2229
+ );
2230
+ assertActive();
2231
+ pending.rebind ??= {
2232
+ shares: (await splitAndVerify(pending.privateKey)).shares,
2233
+ storageId,
2234
+ attempted: false,
2235
+ };
2236
+ const rebind = pending.rebind;
2237
+ if (
2238
+ !candidates.some(candidate => candidate.share === rebind.shares.deviceShare)
2239
+ ) {
2240
+ await addPendingShareCandidate(
2241
+ storage,
2242
+ { share: rebind.shares.deviceShare, createdAt: Date.now() },
2243
+ storageId
2244
+ );
2245
+ }
2246
+ assertActive();
2247
+ rebind.attempted = true;
2248
+ try {
2249
+ const response = await postJson<{
2250
+ shareVersion: number;
2251
+ recoveryMethodsRequireConfirmation: string[];
2252
+ }>(
2253
+ `${serverUrl}/keys/recovery-session/rebind`,
2254
+ {
2255
+ recoverySessionToken: pending.rebindSessionToken,
2256
+ providerType: params.providerType,
2257
+ primaryDid: pending.primaryDid,
2258
+ authShare: {
2259
+ encryptedData: rebind.shares.authShare,
2260
+ encryptedDek: '',
2261
+ iv: '',
2262
+ },
2263
+ },
2264
+ {
2265
+ ...buildHeaders('', didAuthVp, tenantId),
2266
+ 'X-Auth-Token': params.token,
2267
+ }
2268
+ );
2269
+
2270
+ shareVersion = response.shareVersion;
2271
+ } catch (error) {
2272
+ // A retry's rejection does not disprove an earlier commit
2273
+ // (the first request may still have been in flight at the read).
2274
+ if (error instanceof ShareWriteRejectedError && !retry) {
2275
+ await removePendingShareCandidate(
2276
+ storage,
2277
+ rebind.shares.deviceShare,
2278
+ storageId
2279
+ );
2280
+ pending.rebind = undefined;
2281
+ }
2282
+ throw error;
2283
+ }
2284
+ }
2285
+ if (shareVersion === undefined || !pending.rebind) {
2286
+ throw new Error('Server did not confirm the new share');
2287
+ }
2288
+ assertActive();
2289
+ const shares = pending.rebind.shares;
2290
+ await storage.storeDeviceShare(shares.deviceShare, storageId);
2291
+ await storage.storeShareVersion(shareVersion, storageId);
2292
+ await clearPendingShareCandidates(storage, storageId);
2293
+ assertActive();
2294
+ lastEmailShare = shares.emailShare;
2295
+ lastShareVersion = shareVersion;
2296
+ lastServerSnapshot = {
2297
+ currentVersion: shareVersion,
2298
+ resolvedVersion: shareVersion,
2299
+ authShare: shares.authShare,
2300
+ primaryDid: pending.primaryDid,
2301
+ };
2302
+ pendingIdentityRecovery = undefined;
2303
+ return { shares, shareVersion };
2304
+ })
2305
+ );
2306
+
2307
+ // The rebind wiped lca-api's PIN record, so the old PIN cannot be carried and
2308
+ // is cleared. Only a hold (7-day) release reaches this rebind: a PIN release
2309
+ // keeps the account instead (completePinSignInKeep / completePinSignInRelink).
2310
+ // Other recovery methods leave enrollment to the caller's rollout gate.
2311
+ const enrolled =
2312
+ pending.fromEscrow &&
2313
+ (await tryEnrollEscrow(
2314
+ params.token,
2315
+ params.providerType,
2316
+ pending.privateKey,
2317
+ pending.primaryDid,
2318
+ shares,
2319
+ shareVersion,
2320
+ signDidAuthVp,
2321
+ undefined,
2322
+ undefined,
2323
+ true,
2324
+ storage.assertCurrent,
2325
+ params.options?.recoveryEmail,
2326
+ params.options?.recoveryLanguage
2327
+ ));
2328
+ if (activeStorageId !== storageId || storageGeneration !== generation) {
2329
+ throw new Error('This recovery request was cancelled.');
2330
+ }
2331
+ if (!enrolled) {
2332
+ // The repair clears: PIN material (an offline-guessable verifier) is never kept.
2333
+ unenrolledRotation = {
2334
+ shares,
2335
+ shareVersion,
2336
+ primaryDid: pending.primaryDid,
2337
+ clearPin: true,
2338
+ };
2339
+ }
2340
+ return {
2341
+ privateKey: pending.privateKey,
2342
+ did: pending.primaryDid,
2343
+ escrowEnrolled: enrolled,
2344
+ escrowRecovery: pending.fromEscrow,
2345
+ };
2346
+ };
2347
+
1761
2348
  return {
1762
2349
  name: 'sss',
1763
2350
 
@@ -1852,6 +2439,16 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1852
2439
  },
1853
2440
 
1854
2441
  async reconstructKey(localKey: string, remoteKey: string): Promise<string> {
2442
+ const coordinate = localKey.slice(-2).toLowerCase();
2443
+ if (
2444
+ localKey.length === remoteKey.length &&
2445
+ /^(?:[0-9a-f]{2}){2,}$/i.test(localKey) &&
2446
+ /^(?:[0-9a-f]{2}){2,}$/i.test(remoteKey) &&
2447
+ coordinate !== '00' &&
2448
+ coordinate === remoteKey.slice(-2).toLowerCase()
2449
+ ) {
2450
+ throw new DuplicateShareCoordinateError();
2451
+ }
1855
2452
  return reconstructFromShares([localKey, remoteKey]);
1856
2453
  },
1857
2454
 
@@ -2150,6 +2747,10 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2150
2747
  escrowOptedOut: data.escrowOptedOut === true,
2151
2748
  escrowPin: data.escrowPin,
2152
2749
  escrowStale: data.escrowStale,
2750
+ ...(typeof data.escrowBlobIdentity === 'string'
2751
+ ? { escrowBlobIdentity: data.escrowBlobIdentity }
2752
+ : {}),
2753
+ ...(data.escrowUpdating === true ? { escrowUpdating: true } : {}),
2153
2754
  sssActivationState: data.sssActivationState ?? 'active',
2154
2755
  ...(typeof data.priorEscrowEnrollment === 'boolean'
2155
2756
  ? { priorEscrowEnrollment: data.priorEscrowEnrollment }
@@ -2631,11 +3232,31 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2631
3232
  throw new Error('DID verification is required for escrow recovery');
2632
3233
  // Each PIN call owns a fresh, single-use hold and in-memory key pair.
2633
3234
  // Never persist this key or retry completion after a burned attempt.
3235
+ // Without a bound login, the PIN start is proven by the verified
3236
+ // recovery-email session instead (lost-login sign-in).
3237
+ if (input.method === 'escrow-pin' && !token && !input.recoverySessionToken)
3238
+ throw new Error('PIN recovery requires signing in first.');
3239
+ // (E) The PIN status before this release, for the same-PIN refresh
3240
+ // decision in completePinSignInKeep. A failed read counts as unknown.
3241
+ const pinBefore =
3242
+ input.method === 'escrow-pin' && token
3243
+ ? await fetchAuthShareRaw(
3244
+ serverUrl,
3245
+ token,
3246
+ providerType,
3247
+ undefined,
3248
+ tenantId
3249
+ ).then(
3250
+ (current: { escrowPin?: unknown } | null) => current?.escrowPin,
3251
+ () => undefined
3252
+ )
3253
+ : undefined;
2634
3254
  const start =
2635
3255
  input.method === 'escrow-pin'
2636
3256
  ? await this.startEscrowRecovery!({
2637
- token,
2638
- providerType,
3257
+ ...(token
3258
+ ? { token, providerType }
3259
+ : { recoverySessionToken: input.recoverySessionToken }),
2639
3260
  expectedDid: input.expectedDid,
2640
3261
  options: { releasePolicy: 'pin' },
2641
3262
  })
@@ -2703,6 +3324,22 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2703
3324
  'Recovery produced an incorrect key. Try another recovery method.'
2704
3325
  );
2705
3326
  }
3327
+ // PIN sign-in keeps the account by default: no re-split, no rebind.
3328
+ // Reached only after this call's PIN release succeeded and the DID matched.
3329
+ if (input.method === 'escrow-pin' && token) {
3330
+ return completePinSignInKeep(
3331
+ { token, providerType, signDidAuthVp, options: params.options },
3332
+ {
3333
+ privateKey,
3334
+ primaryDid: material.primaryDid,
3335
+ recoveryShare: plaintext.recoveryShare,
3336
+ authShare,
3337
+ shareVersion: material.shareVersion,
3338
+ },
3339
+ input.pin,
3340
+ pinBefore
3341
+ );
3342
+ }
2706
3343
  pendingIdentityRecovery = {
2707
3344
  privateKey,
2708
3345
  primaryDid: material.primaryDid,
@@ -2710,11 +3347,16 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2710
3347
  authShare,
2711
3348
  rebindSessionToken: material.rebindSessionToken,
2712
3349
  fromEscrow: true,
3350
+ // The PIN itself is not kept across the new-login step.
3351
+ ...(input.method === 'escrow-pin'
3352
+ ? { pinKeep: { shareVersion: material.shareVersion } }
3353
+ : {}),
2713
3354
  };
2714
- // The OTP path uses exactly this pending state and atomic rebind operation.
2715
- // Without a provider session the caller completes it after signing in.
3355
+ // The OTP path uses exactly this pending state. Without a provider session
3356
+ // the caller completes it after signing in: a hold release with the
3357
+ // atomic rebind, a PIN release by linking the new login only.
2716
3358
  if (!token) return { privateKey, did: material.primaryDid };
2717
- return this.completeIdentityRecovery!({
3359
+ return completeIdentityRecoveryWith({
2718
3360
  token,
2719
3361
  providerType,
2720
3362
  signDidAuthVp,
@@ -3465,143 +4107,146 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
3465
4107
  },
3466
4108
 
3467
4109
  async completeIdentityRecovery(params): Promise<RecoveryResult> {
3468
- const storage = guardedStorage();
3469
- const generation = storageGeneration;
3470
- const pending = pendingIdentityRecovery;
3471
- const signDidAuthVp = params.signDidAuthVp;
4110
+ // Public entry (sign-in completion, retry). A pending PIN sign-in only links
4111
+ // the login; any other pending recovery rebinds with a full rotation.
4112
+ return completeIdentityRecoveryWith(params);
4113
+ },
3472
4114
 
3473
- if (!pending) throw new Error('No identity recovery is waiting for a new sign-in');
3474
- if (!signDidAuthVp) {
3475
- throw new Error('DID proof signing is required to bind a new sign-in');
4115
+ /**
4116
+ * "Secure my account": one atomic server update re-splits the key and drops all
4117
+ * older auth share versions, signing out other devices and turning off the old
4118
+ * phrase/backup/passkey/email key. Staged through the pending-candidate slot, so
4119
+ * a lost acknowledgement is promoted by the usual reconciliation. Escrow is then
4120
+ * re-enrolled with `pin` (fresh salt) or, for `null`, without a PIN. The PIN is
4121
+ * used in this call only and never kept in retry state.
4122
+ */
4123
+ async revokeOtherDevices(params): Promise<RevokeOtherDevicesResult> {
4124
+ const { token, providerType, privateKey, did, signDidAuthVp, pin } = params;
4125
+ if (!signDidAuthVp) throw new Error('DID proof signing is required');
4126
+ if (pin !== null) {
4127
+ const validation = validatePin(pin);
4128
+ if (!validation.ok)
4129
+ throw new Error(
4130
+ validation.reason === 'length'
4131
+ ? 'PIN must contain 6–12 digits.'
4132
+ : 'Choose a PIN without trivial or sequential patterns.'
4133
+ );
3476
4134
  }
3477
-
4135
+ const storage = guardedStorage();
3478
4136
  const storageId = activeStorageId;
4137
+ const assertUser = captureActiveUserGuard();
4138
+ const assertActive = (): void => {
4139
+ assertUser();
4140
+ storage.assertCurrent();
4141
+ };
4142
+ const before = await fetchAuthShareRaw(
4143
+ serverUrl,
4144
+ token,
4145
+ providerType,
4146
+ undefined,
4147
+ tenantId
4148
+ );
4149
+ if (!before || before.primaryDid !== did) {
4150
+ throw new Error('This account could not be found. Sign in again.');
4151
+ }
4152
+ // Re-enroll only an account that had escrow and has not opted out.
4153
+ const hadEscrow =
4154
+ !before.escrowOptedOut &&
4155
+ (before.priorEscrowEnrollment === true ||
4156
+ (Array.isArray(before.recoveryMethods) &&
4157
+ before.recoveryMethods.some(
4158
+ (method: { type?: string }) => method?.type === 'escrow'
4159
+ )));
4160
+ const didFromKey = async (key: string): Promise<string> =>
4161
+ key === privateKey ? did : `${did}:mismatch`;
4162
+
3479
4163
  const { shares, shareVersion } = await withShareUpdateLock(storage, storageId, () =>
3480
4164
  withRotationLock(async () => {
3481
- const assertActive = (): void => {
3482
- if (
3483
- activeStorageId !== storageId ||
3484
- pendingIdentityRecovery !== pending ||
3485
- storageGeneration !== generation
3486
- ) {
3487
- throw new Error('This recovery request was cancelled.');
3488
- }
3489
- };
3490
- assertActive();
3491
- const candidates = await readPendingShareCandidates(storage, storageId);
3492
- if (pending.rebind && pending.rebind.storageId !== storageId) {
3493
- throw new Error('Active account changed during identity recovery');
3494
- }
3495
- if (
3496
- candidates.some(
3497
- candidate => candidate.share !== pending.rebind?.shares.deviceShare
3498
- )
3499
- ) {
3500
- throw new Error(
3501
- 'Reconcile the pending share update before binding a sign-in'
3502
- );
3503
- }
3504
-
3505
- let shareVersion: number | undefined;
3506
- const retry = pending.rebind?.attempted ?? false;
3507
- if (retry && pending.rebind) {
3508
- const current = await fetchAuthShareRaw(
3509
- serverUrl,
3510
- params.token,
3511
- params.providerType,
3512
- undefined,
3513
- tenantId
3514
- );
3515
- const authShare = authShareToString(current?.authShare);
3516
- // prepareIdentityRecovery already verified this key's DID.
3517
- // Comparing the reconstructed key avoids trusting server DID metadata.
3518
- if (authShare && current?.shareVersion != null) {
3519
- const health = await verifyStoredShares(
3520
- {
3521
- getDevice: async () => pending.rebind!.shares.deviceShare,
3522
- getAuth: async () => authShare,
3523
- },
3524
- pending.primaryDid,
3525
- async key => (key === pending.privateKey ? pending.primaryDid : '')
4165
+ const { snapshot } = await resolvePendingShareCandidates(
4166
+ storage,
4167
+ storageId,
4168
+ async () => {
4169
+ const data = await fetchAuthShareRaw(
4170
+ serverUrl,
4171
+ token,
4172
+ providerType,
4173
+ undefined,
4174
+ tenantId
3526
4175
  );
3527
- if (health.healthy) shareVersion = current.shareVersion;
3528
- }
4176
+ return {
4177
+ currentVersion: data?.shareVersion ?? null,
4178
+ authShare: authShareToString(data?.authShare),
4179
+ };
4180
+ },
4181
+ did,
4182
+ didFromKey,
4183
+ assertActive,
4184
+ recordPromotion(did),
4185
+ async version =>
4186
+ authShareToString(
4187
+ (
4188
+ await fetchAuthShareRaw(
4189
+ serverUrl,
4190
+ token,
4191
+ providerType,
4192
+ version,
4193
+ tenantId
4194
+ )
4195
+ )?.authShare
4196
+ )
4197
+ );
4198
+ if (!snapshot.currentVersion) {
4199
+ throw new Error('This account could not be found. Sign in again.');
3529
4200
  }
3530
-
4201
+ const expectedShareVersion = snapshot.currentVersion;
4202
+ const didAuthVp = await requestFreshDidAuthVp(
4203
+ serverUrl,
4204
+ privateKey,
4205
+ did,
4206
+ signDidAuthVp,
4207
+ tenantId
4208
+ );
3531
4209
  assertActive();
3532
- if (shareVersion === undefined) {
3533
- const didAuthVp = await requestFreshDidAuthVp(
3534
- serverUrl,
3535
- pending.privateKey,
3536
- pending.primaryDid,
3537
- signDidAuthVp,
3538
- tenantId
3539
- );
3540
- assertActive();
3541
- pending.rebind ??= {
3542
- shares: (await splitAndVerify(pending.privateKey)).shares,
3543
- storageId,
3544
- attempted: false,
3545
- };
3546
- const rebind = pending.rebind;
3547
- if (
3548
- !candidates.some(
3549
- candidate => candidate.share === rebind.shares.deviceShare
3550
- )
3551
- ) {
4210
+ let shareVersion: number | undefined;
4211
+ let stagedShare = '';
4212
+ const shares = await atomicShareUpdate(privateKey, {
4213
+ storeDevice: async share => {
4214
+ assertActive();
4215
+ stagedShare = share;
3552
4216
  await addPendingShareCandidate(
3553
4217
  storage,
3554
- { share: rebind.shares.deviceShare, createdAt: Date.now() },
4218
+ { share, createdAt: Date.now() },
3555
4219
  storageId
3556
4220
  );
3557
- }
3558
- assertActive();
3559
- rebind.attempted = true;
3560
- try {
4221
+ },
4222
+ clearDevice: () =>
4223
+ removePendingShareCandidate(storage, stagedShare, storageId),
4224
+ storeAuth: async share => {
4225
+ assertActive();
3561
4226
  const response = await postJson<{
4227
+ success: true;
3562
4228
  shareVersion: number;
3563
4229
  recoveryMethodsRequireConfirmation: string[];
3564
4230
  }>(
3565
- `${serverUrl}/keys/recovery-session/rebind`,
4231
+ `${serverUrl}/keys/revoke-other-devices`,
3566
4232
  {
3567
- recoverySessionToken: pending.rebindSessionToken,
3568
- providerType: params.providerType,
3569
- primaryDid: pending.primaryDid,
3570
- authShare: {
3571
- encryptedData: rebind.shares.authShare,
3572
- encryptedDek: '',
3573
- iv: '',
3574
- },
4233
+ authToken: token,
4234
+ providerType,
4235
+ primaryDid: did,
4236
+ authShare: { encryptedData: share, encryptedDek: '', iv: '' },
4237
+ expectedShareVersion,
3575
4238
  },
3576
- {
3577
- ...buildHeaders('', didAuthVp, tenantId),
3578
- 'X-Auth-Token': params.token,
3579
- }
4239
+ buildHeaders(token, didAuthVp, tenantId)
3580
4240
  );
3581
-
3582
4241
  shareVersion = response.shareVersion;
3583
- } catch (error) {
3584
- // A retry's rejection does not disprove an earlier commit
3585
- // (the first request may still have been in flight at the read).
3586
- if (error instanceof ShareWriteRejectedError && !retry) {
3587
- await removePendingShareCandidate(
3588
- storage,
3589
- rebind.shares.deviceShare,
3590
- storageId
3591
- );
3592
- pending.rebind = undefined;
3593
- }
3594
- throw error;
3595
- }
3596
- }
3597
- if (shareVersion === undefined || !pending.rebind) {
3598
- throw new Error('Server did not confirm the new share');
4242
+ },
4243
+ });
4244
+ if (shareVersion === undefined) {
4245
+ throw new Error('Server did not return a share version');
3599
4246
  }
3600
- assertActive();
3601
- const shares = pending.rebind.shares;
3602
4247
  await storage.storeDeviceShare(shares.deviceShare, storageId);
3603
4248
  await storage.storeShareVersion(shareVersion, storageId);
3604
- await clearPendingShareCandidates(storage, storageId);
4249
+ await removePendingShareCandidate(storage, shares.deviceShare, storageId);
3605
4250
  assertActive();
3606
4251
  lastEmailShare = shares.emailShare;
3607
4252
  lastShareVersion = shareVersion;
@@ -3609,48 +4254,48 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
3609
4254
  currentVersion: shareVersion,
3610
4255
  resolvedVersion: shareVersion,
3611
4256
  authShare: shares.authShare,
3612
- primaryDid: pending.primaryDid,
4257
+ primaryDid: did,
3613
4258
  };
3614
- pendingIdentityRecovery = undefined;
3615
4259
  return { shares, shareVersion };
3616
4260
  })
3617
4261
  );
3618
4262
 
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
- ));
3637
- if (activeStorageId !== storageId || storageGeneration !== generation) {
3638
- throw new Error('This recovery request was cancelled.');
4263
+ // lca-api unset the escrow blob and PIN with the old versions; re-enroll now.
4264
+ if (!hadEscrow || !config.escrow?.enabled) {
4265
+ return { shareVersion, escrowEnrolled: false, pinKept: false };
3639
4266
  }
4267
+ const pinMaterial = pin === null ? undefined : await freshPinMaterial(pin);
4268
+ const enrollArgs: Parameters<typeof enrollEscrow> = [
4269
+ token,
4270
+ providerType,
4271
+ privateKey,
4272
+ did,
4273
+ shares,
4274
+ shareVersion,
4275
+ signDidAuthVp,
4276
+ undefined,
4277
+ pinMaterial,
4278
+ !pinMaterial,
4279
+ storage.assertCurrent,
4280
+ params.options?.recoveryEmail,
4281
+ params.options?.recoveryLanguage,
4282
+ ];
4283
+ const attempt = await attemptEnrollEscrow(...enrollArgs);
4284
+ // A definite authority refusal (403): an old-copy PIN compare still in flight
4285
+ // at the revoke can commit while this intent is evaluated, and the authority
4286
+ // then refuses it on its operation revision. Nothing committed and lca-api
4287
+ // released the lease, so one retry (re-derived from the authority's current
4288
+ // state) keeps the PIN just chosen instead of the PIN-clearing repair.
4289
+ const enrolled =
4290
+ attempt.enrolled ||
4291
+ (attempt.error instanceof EscrowRequestError &&
4292
+ attempt.error.status === 403 &&
4293
+ (await tryEnrollEscrow(...enrollArgs)));
3640
4294
  if (!enrolled) {
3641
- unenrolledRotation = {
3642
- shares,
3643
- shareVersion,
3644
- primaryDid: pending.primaryDid,
3645
- clearPin: true,
3646
- };
4295
+ // The repair clears: PIN material (an offline-guessable verifier) is never kept.
4296
+ unenrolledRotation = { shares, shareVersion, primaryDid: did, clearPin: true };
3647
4297
  }
3648
- return {
3649
- privateKey: pending.privateKey,
3650
- did: pending.primaryDid,
3651
- escrowEnrolled: enrolled,
3652
- escrowRecovery: pending.fromEscrow,
3653
- };
4298
+ return { shareVersion, escrowEnrolled: enrolled, pinKept: enrolled && !!pinMaterial };
3654
4299
  },
3655
4300
 
3656
4301
  // --- Contact method management ---