@learncard/sss-key-manager 0.2.6 → 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.
@@ -29,6 +29,7 @@ import type {
29
29
  DidAuthVpSigner,
30
30
  EscrowAttestationPolicy,
31
31
  EscrowHoldStatus,
32
+ EscrowPinAttemptContext,
32
33
  EscrowRecoveryStart,
33
34
  EscrowEnrollmentState,
34
35
  RevokeOtherDevicesResult,
@@ -246,6 +247,10 @@ export class EscrowRequestError extends Error {
246
247
  }
247
248
  }
248
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
+
249
254
  /** Independently generated splits can reuse a share's final-byte x-coordinate. */
250
255
  export class DuplicateShareCoordinateError extends Error {
251
256
  constructor() {
@@ -1117,7 +1122,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1117
1122
  * PIN sign-in awaiting a new login: completion only links that login
1118
1123
  * (no re-split, no history or recovery-method change) at this version.
1119
1124
  */
1120
- pinKeep?: { shareVersion: number };
1125
+ pinKeep?: { shareVersion: number; pinAttempts?: EscrowPinAttemptContext };
1121
1126
  rebind?: { shares: SSSShares; storageId: string | undefined; attempted: boolean };
1122
1127
  }
1123
1128
  | undefined;
@@ -1814,36 +1819,79 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1814
1819
  };
1815
1820
 
1816
1821
  /**
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.
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.
1820
1825
  */
1821
- const pinHasRecordedWrongAttempts = (escrowPin: unknown): boolean => {
1826
+ const recordedWrongPinAttempts = (escrowPin: unknown): number | undefined => {
1822
1827
  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;
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 };
1825
1873
  };
1826
1874
 
1827
1875
  /**
1828
1876
  * 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
1877
+ * the sign-in is clean (see pinSignInClean) and exactly one charge landed between
1830
1878
  * the two reads (this release). Any other charge may be someone else's guess still
1831
1879
  * in flight, so the PIN keeps its reduced budget. The server re-checks these
1832
- * charges in the write itself (`expectedPinCharges`).
1880
+ * charges in the write itself (`expectedPinCharges`), so a guess landing after
1881
+ * these reads still blocks the refresh.
1833
1882
  */
1834
1883
  const refreshablePinCharges = (
1835
1884
  before: unknown,
1836
- after: unknown
1885
+ after: unknown,
1886
+ ownWrongAttempts: number
1837
1887
  ): { attemptsRemaining: number; wrongAttempts: number } | undefined => {
1838
1888
  const pre = before as { state?: unknown; attemptsRemaining?: unknown } | undefined;
1839
1889
  const post = after as { state?: unknown; attemptsRemaining?: unknown } | undefined;
1840
- if (pinHasRecordedWrongAttempts(before) || pinHasRecordedWrongAttempts(after))
1841
- return undefined;
1842
1890
  if (pre?.state !== 'enabled' || post?.state !== 'enabled') return undefined;
1843
1891
  if (typeof pre.attemptsRemaining !== 'number' || typeof post.attemptsRemaining !== 'number')
1844
1892
  return undefined;
1845
1893
  if (post.attemptsRemaining !== pre.attemptsRemaining - 1) return undefined;
1846
- return { attemptsRemaining: post.attemptsRemaining, wrongAttempts: 0 };
1894
+ return { attemptsRemaining: post.attemptsRemaining, wrongAttempts: ownWrongAttempts };
1847
1895
  };
1848
1896
 
1849
1897
  /** New salt and verifier for a PIN just proven; undefined when it fails the PIN rules. */
@@ -1916,7 +1964,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1916
1964
  },
1917
1965
  pin: string,
1918
1966
  /** PIN status read before this release; undefined when that read failed. */
1919
- pinBefore: unknown
1967
+ pinBefore: unknown,
1968
+ /** Own-typos context from the caller's PIN sign-in flow (E). */
1969
+ pinAttempts?: EscrowPinAttemptContext
1920
1970
  ): Promise<RecoveryResult> => {
1921
1971
  const storage = guardedStorage();
1922
1972
  const generation = storageGeneration;
@@ -1927,70 +1977,76 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1927
1977
  }
1928
1978
  };
1929
1979
 
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
- ) {
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) {
1949
2005
  throw new PinSignInStaleError();
1950
2006
  }
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';
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();
1972
2021
  }
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
- })
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
+ })
1994
2050
  );
1995
2051
 
1996
2052
  // Same PIN, new salt and verifier, enrolled at the same version: a fresh
@@ -2001,26 +2057,29 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2001
2057
  // scheduled.
2002
2058
  let pinRefreshed = false;
2003
2059
  let pinRefreshFailed = false;
2060
+ let newPinRequired = newPinRequiredAtRelease;
2004
2061
  if (mode === 'kept' && !newPinRequired && refreshCharges && params.signDidAuthVp) {
2005
2062
  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
- ));
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;
2024
2083
  // Reported, not swallowed: the PIN keeps (at most) its reduced budget. A commit
2025
2084
  // whose receipt was lost is not confirmed here, so it reports failed too (an
2026
2085
  // under-claim: the budget is in fact full). lca-api either
@@ -2028,6 +2087,21 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2028
2087
  // authority's commit; a copy left behind is caught by ensureEscrowEnrollment's
2029
2088
  // identity check, which never clears the PIN on its own.
2030
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
+ }
2031
2105
  }
2032
2106
  assertActive();
2033
2107
  return {
@@ -2144,7 +2218,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2144
2218
  pinSignIn: {
2145
2219
  mode: 'relinked',
2146
2220
  pinRefreshed: false,
2147
- newPinRequired: pinHasRecordedWrongAttempts(status?.escrowPin),
2221
+ newPinRequired: !pinSignInClean([status?.escrowPin], pending.pinKeep?.pinAttempts)
2222
+ .clean,
2148
2223
  },
2149
2224
  };
2150
2225
  };
@@ -3337,7 +3412,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
3337
3412
  shareVersion: material.shareVersion,
3338
3413
  },
3339
3414
  input.pin,
3340
- pinBefore
3415
+ pinBefore,
3416
+ input.pinAttempts
3341
3417
  );
3342
3418
  }
3343
3419
  pendingIdentityRecovery = {
@@ -3349,7 +3425,14 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
3349
3425
  fromEscrow: true,
3350
3426
  // The PIN itself is not kept across the new-login step.
3351
3427
  ...(input.method === 'escrow-pin'
3352
- ? { pinKeep: { shareVersion: material.shareVersion } }
3428
+ ? {
3429
+ pinKeep: {
3430
+ shareVersion: material.shareVersion,
3431
+ ...(input.pinAttempts
3432
+ ? { pinAttempts: { ...input.pinAttempts } }
3433
+ : {}),
3434
+ },
3435
+ }
3353
3436
  : {}),
3354
3437
  };
3355
3438
  // The OTP path uses exactly this pending state. Without a provider session
package/src/types.ts CHANGED
@@ -125,6 +125,29 @@ export class EscrowPinUnavailableError extends Error {
125
125
  }
126
126
  }
127
127
 
128
+ /**
129
+ * What this client saw during one PIN sign-in flow (own-typos rule, E). A flow starts
130
+ * with the first PIN status read before the first attempt and ends when the sign-in
131
+ * completes; a new modal or app start begins a new flow.
132
+ */
133
+ export interface EscrowPinAttemptContext {
134
+ /**
135
+ * Verified wrong entries recorded against the PIN when the flow started, before
136
+ * any attempt here. Undefined when the server could not say (legacy record, failed
137
+ * read): treated as "wrong entries recorded".
138
+ */
139
+ priorWrongAttempts?: number;
140
+ /** Wrong entries this client itself submitted in this flow (mismatch responses). */
141
+ ownWrongAttempts: number;
142
+ /**
143
+ * Salt of the PIN generation the counts belong to (from the flow's first status).
144
+ * A status read during the release that shows another salt (a new PIN, or a
145
+ * same-PIN re-set elsewhere) means the counts describe a different PIN, so they
146
+ * are ignored and the stricter #1905 rule applies.
147
+ */
148
+ pinSalt?: string;
149
+ }
150
+
128
151
  /** A failed PIN attempt consumes its hold; another attempt must start a fresh hold. */
129
152
  export class EscrowPinMismatchError extends Error {
130
153
  constructor(public readonly attemptsRemaining: number) {
@@ -199,6 +222,12 @@ export type RecoveryInput =
199
222
  expectedDid?: string;
200
223
  /** Verified recovery-email session; set only when no login is bound yet. */
201
224
  recoverySessionToken?: string;
225
+ /**
226
+ * Own-typos rule (E): wrong entries this client observed in the current PIN
227
+ * sign-in flow. Omitted by older callers, which keeps the stricter rule (any
228
+ * recorded wrong entry blocks the same-PIN re-set).
229
+ */
230
+ pinAttempts?: EscrowPinAttemptContext;
202
231
  }
203
232
  | {
204
233
  method: 'escrow';