@learncard/sss-key-manager 0.2.6 → 0.3.0

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.
Files changed (35) hide show
  1. package/README.md +9 -0
  2. package/dist/escrow-authority.d.ts +4 -3
  3. package/dist/escrow-authority.d.ts.map +1 -1
  4. package/dist/escrow-authority.types.d.ts +15 -0
  5. package/dist/escrow-authority.types.d.ts.map +1 -1
  6. package/dist/escrow-crypto.d.ts +19 -1
  7. package/dist/escrow-crypto.d.ts.map +1 -1
  8. package/dist/escrow-enrollment-intent.d.ts.map +1 -1
  9. package/dist/index.d.ts +3 -3
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/sss-key-manager.cjs.development.js +776 -89
  12. package/dist/sss-key-manager.cjs.development.js.map +3 -3
  13. package/dist/sss-key-manager.cjs.production.min.js +30 -30
  14. package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
  15. package/dist/sss-key-manager.esm.js +778 -89
  16. package/dist/sss-key-manager.esm.js.map +3 -3
  17. package/dist/sss-strategy.d.ts +26 -0
  18. package/dist/sss-strategy.d.ts.map +1 -1
  19. package/dist/types.d.ts +28 -0
  20. package/dist/types.d.ts.map +1 -1
  21. package/package.json +2 -2
  22. package/src/__fixtures__/escrow-authority-brand-names-v1.json +29 -0
  23. package/src/__fixtures__/escrow-carry-contact-contract-v1.json +25 -1
  24. package/src/__fixtures__/escrow-email-brand-contract-v1.json +225 -0
  25. package/src/escrow-authority.test.ts +2 -0
  26. package/src/escrow-authority.ts +24 -7
  27. package/src/escrow-authority.types.ts +27 -0
  28. package/src/escrow-crypto.test.ts +75 -0
  29. package/src/escrow-crypto.ts +43 -1
  30. package/src/escrow-enrollment-intent.ts +2 -1
  31. package/src/index.ts +14 -2
  32. package/src/sss-authority.test.ts +392 -32
  33. package/src/sss-strategy.test.ts +238 -0
  34. package/src/sss-strategy.ts +245 -104
  35. package/src/types.ts +29 -0
@@ -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,
@@ -53,7 +54,12 @@ import {
53
54
  derivePinProof,
54
55
  ESCROW_PIN_MAX_ATTEMPTS,
55
56
  } from './escrow-pin';
56
- import { encryptEscrowBlob, generateEscrowKeyPair, openEscrowRelease } from './escrow-crypto';
57
+ import {
58
+ canonicalEscrowEmailBrand,
59
+ encryptEscrowBlob,
60
+ generateEscrowKeyPair,
61
+ openEscrowRelease,
62
+ } from './escrow-crypto';
57
63
  import { verifyEnclaveAttestation } from './escrow-attestation';
58
64
  import {
59
65
  createEscrowEnrollmentIntent,
@@ -68,7 +74,7 @@ import {
68
74
  } from './escrow-authority';
69
75
  import { getEscrowBlobIdentity } from './escrow-enrollment-intent';
70
76
  import type { VerifiedEnclaveIdentity, EscrowAuthorityState } from './escrow-authority.types';
71
- import { EscrowEvidenceError } from './escrow-authority.types';
77
+ import { ESCROW_AUTHORITY_TENANT_PATTERN, EscrowEvidenceError } from './escrow-authority.types';
72
78
  import type { EscrowEnvelope } from './escrow-crypto';
73
79
  import type { EmailRelayBranding } from './email-relay-crypto';
74
80
 
@@ -133,7 +139,36 @@ export interface SSSStorageFunctions {
133
139
 
134
140
  export interface SSSStrategyConfig {
135
141
  /** Enable automatic enrollment under an explicit enclave trust policy. */
136
- escrow?: { enabled: boolean; attestation: EscrowAttestationPolicy };
142
+ escrow?: {
143
+ enabled: boolean;
144
+ attestation: EscrowAttestationPolicy;
145
+ /**
146
+ * The escrow authority's tenant label: the enclave's measured
147
+ * `ESCROW_AUTHORITY_TENANT`, which it signs into every authority record and
148
+ * requires in every signed enrollment intent. Used for everything the
149
+ * enclave verifies (intent tenant, authority state, hold, code-delivery and
150
+ * persisted recovery expectations). Everything else (X-Tenant-Id, email
151
+ * branding, same-tenant guards) keeps using {@link SSSStrategyConfig.tenantId}.
152
+ *
153
+ * Defaults to `tenantId` (then `'learncard'`). Set it explicitly when this app
154
+ * shares another tenant's single-tenant enclave, e.g. VetPass on LearnCard's
155
+ * (`'learncard'`). Must match `/^[A-Za-z0-9_-]{1,128}$/`.
156
+ */
157
+ authorityTenant?: string;
158
+ /**
159
+ * Display name sealed into the escrow blob for enclave-sent emails
160
+ * (D21, `recoveryBrand`), e.g. `'VetPass'`. Sealed only when the
161
+ * recovery email and language are (nitro enroll/replace), never on a
162
+ * carry. Unset: the enclave brands emails from its measured tenant label.
163
+ *
164
+ * Set only after every live enclave host runs the brand-aware image: an
165
+ * older image drops the field whenever it re-encrypts the blob.
166
+ * Must pass {@link canonicalEscrowEmailBrand} (ASCII letters, digits,
167
+ * space, `-`, `'`, `&`; 1-32 characters); `createSSSStrategy` throws
168
+ * otherwise.
169
+ */
170
+ emailBrand?: string;
171
+ };
137
172
 
138
173
  /** Report enrollment failures without logging recovery material. */
139
174
  onEscrowError?: (err: unknown) => void;
@@ -246,6 +281,10 @@ export class EscrowRequestError extends Error {
246
281
  }
247
282
  }
248
283
 
284
+ /** lca-api refused a write because escrow state changed after the caller's reads (409). */
285
+ const isConflictRefusal = (error: unknown): boolean =>
286
+ error instanceof EscrowRequestError && error.status === 409;
287
+
249
288
  /** Independently generated splits can reuse a share's final-byte x-coordinate. */
250
289
  export class DuplicateShareCoordinateError extends Error {
251
290
  constructor() {
@@ -1032,6 +1071,23 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1032
1071
  emailBranding,
1033
1072
  tenantId,
1034
1073
  } = config;
1074
+ // Enclave-verified tenant (see SSSStrategyConfig.escrow.authorityTenant); never
1075
+ // sent as X-Tenant-Id. Defaults keep LearnCard/Scouts behaviour unchanged.
1076
+ const authorityTenant = config.escrow?.authorityTenant ?? tenantId ?? 'learncard';
1077
+ if (config.escrow && !ESCROW_AUTHORITY_TENANT_PATTERN.test(authorityTenant))
1078
+ throw new Error(
1079
+ 'Invalid escrow authority tenant: expected 1-128 letters, digits, "_" or "-".'
1080
+ );
1081
+ // D21: strict on write (the enclave and parsers are lenient on read), and
1082
+ // canonicalized once here so every enrollment path seals the same value.
1083
+ const emailBrand =
1084
+ config.escrow?.emailBrand === undefined
1085
+ ? undefined
1086
+ : canonicalEscrowEmailBrand(config.escrow.emailBrand);
1087
+ if (config.escrow?.emailBrand !== undefined && emailBrand === undefined)
1088
+ throw new Error(
1089
+ 'Invalid escrow email brand: expected 1-32 ASCII letters, digits, spaces, "-", "\'" or "&", starting with a letter.'
1090
+ );
1035
1091
  const storage = config.storage || defaultStorage;
1036
1092
  let storageGeneration = 0;
1037
1093
  const localWrites = new Set<Promise<void>>();
@@ -1117,7 +1173,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1117
1173
  * PIN sign-in awaiting a new login: completion only links that login
1118
1174
  * (no re-split, no history or recovery-method change) at this version.
1119
1175
  */
1120
- pinKeep?: { shareVersion: number };
1176
+ pinKeep?: { shareVersion: number; pinAttempts?: EscrowPinAttemptContext };
1121
1177
  rebind?: { shares: SSSShares; storageId: string | undefined; attempted: boolean };
1122
1178
  }
1123
1179
  | undefined;
@@ -1284,7 +1340,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1284
1340
  if (
1285
1341
  !expected ||
1286
1342
  expected.holdId !== holdId ||
1287
- expected.tenant !== (tenantId ?? 'learncard') ||
1343
+ expected.tenant !== authorityTenant ||
1288
1344
  !expected.did ||
1289
1345
  !expected.clientEphemeralPublicKey ||
1290
1346
  !expected.blobHash ||
@@ -1352,7 +1408,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1352
1408
  const result = await verifyEscrowAuthorityState(
1353
1409
  response,
1354
1410
  nonce,
1355
- tenantId ?? 'learncard',
1411
+ authorityTenant,
1356
1412
  did,
1357
1413
  identity
1358
1414
  );
@@ -1422,8 +1478,15 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1422
1478
  did: primaryDid,
1423
1479
  shareVersion,
1424
1480
  ...(pinMaterial ? { pinVerifier: pinMaterial.pinVerifier } : {}),
1481
+ // D21: the brand is sealed exactly when the contact is, so a
1482
+ // carry (whose target the enclave refuses if it claims one)
1483
+ // never sends it; the enclave inherits the source's brand.
1425
1484
  ...(authority && recoveryEmail && bindsRecoveryContact
1426
- ? { recoveryEmail, recoveryLanguage }
1485
+ ? {
1486
+ recoveryEmail,
1487
+ recoveryLanguage,
1488
+ ...(emailBrand ? { recoveryBrand: emailBrand } : {}),
1489
+ }
1427
1490
  : {}),
1428
1491
  },
1429
1492
  publicKey,
@@ -1814,36 +1877,79 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1814
1877
  };
1815
1878
 
1816
1879
  /**
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.
1880
+ * Verified wrong entries recorded against the current PIN, or undefined when the
1881
+ * server cannot prove the count (legacy record, older server, failed lookup). A
1882
+ * status with no PIN counts as zero.
1820
1883
  */
1821
- const pinHasRecordedWrongAttempts = (escrowPin: unknown): boolean => {
1884
+ const recordedWrongPinAttempts = (escrowPin: unknown): number | undefined => {
1822
1885
  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;
1886
+ if (status?.state === 'none') return 0;
1887
+ const count = status?.wrongAttempts;
1888
+ return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0
1889
+ ? count
1890
+ : undefined;
1891
+ };
1892
+
1893
+ /** A valid, non-negative attempt count from the caller, or undefined. */
1894
+ const attemptCount = (value: unknown): number | undefined =>
1895
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
1896
+
1897
+ /**
1898
+ * Whether a PIN sign-in may treat every wrong entry recorded against the PIN as the
1899
+ * user's own typing (own-typos rule, E; approved 2026-11-03). Requires:
1900
+ * - zero wrong entries when the flow started (`priorWrongAttempts`; unknown counts
1901
+ * as "some"), and
1902
+ * - every status read during this release showing exactly the wrong entries this
1903
+ * client submitted in the flow (`ownWrongAttempts`), so a guess from anywhere
1904
+ * else (more entries) or an unexplained count (fewer, unknown) is never excused.
1905
+ * Without `attempts` (older callers), or when a status shows a different PIN
1906
+ * generation than the flow's (`pinSalt`), no own entries are excused and the prior
1907
+ * count is the one read just before this release: the original #1905 rule.
1908
+ */
1909
+ const pinSignInClean = (
1910
+ observed: unknown[],
1911
+ flowAttempts: EscrowPinAttemptContext | undefined
1912
+ ): { clean: boolean; ownWrongAttempts: number } => {
1913
+ const flowSalt = flowAttempts?.pinSalt;
1914
+ const sameGeneration =
1915
+ typeof flowSalt !== 'string' ||
1916
+ observed.every(status => {
1917
+ const salt = (status as { salt?: unknown } | undefined)?.salt;
1918
+ return typeof salt !== 'string' || salt === flowSalt;
1919
+ });
1920
+ const attempts = sameGeneration ? flowAttempts : undefined;
1921
+ const own = attempts ? attemptCount(attempts.ownWrongAttempts) : 0;
1922
+ if (own === undefined) return { clean: false, ownWrongAttempts: 0 };
1923
+ const prior = attempts
1924
+ ? attemptCount(attempts.priorWrongAttempts)
1925
+ : recordedWrongPinAttempts(observed[0]);
1926
+ const clean =
1927
+ prior === 0 &&
1928
+ observed.length > 0 &&
1929
+ observed.every(status => recordedWrongPinAttempts(status) === own);
1930
+ return { clean, ownWrongAttempts: own };
1825
1931
  };
1826
1932
 
1827
1933
  /**
1828
1934
  * 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
1935
+ * the sign-in is clean (see pinSignInClean) and exactly one charge landed between
1830
1936
  * the two reads (this release). Any other charge may be someone else's guess still
1831
1937
  * in flight, so the PIN keeps its reduced budget. The server re-checks these
1832
- * charges in the write itself (`expectedPinCharges`).
1938
+ * charges in the write itself (`expectedPinCharges`), so a guess landing after
1939
+ * these reads still blocks the refresh.
1833
1940
  */
1834
1941
  const refreshablePinCharges = (
1835
1942
  before: unknown,
1836
- after: unknown
1943
+ after: unknown,
1944
+ ownWrongAttempts: number
1837
1945
  ): { attemptsRemaining: number; wrongAttempts: number } | undefined => {
1838
1946
  const pre = before as { state?: unknown; attemptsRemaining?: unknown } | undefined;
1839
1947
  const post = after as { state?: unknown; attemptsRemaining?: unknown } | undefined;
1840
- if (pinHasRecordedWrongAttempts(before) || pinHasRecordedWrongAttempts(after))
1841
- return undefined;
1842
1948
  if (pre?.state !== 'enabled' || post?.state !== 'enabled') return undefined;
1843
1949
  if (typeof pre.attemptsRemaining !== 'number' || typeof post.attemptsRemaining !== 'number')
1844
1950
  return undefined;
1845
1951
  if (post.attemptsRemaining !== pre.attemptsRemaining - 1) return undefined;
1846
- return { attemptsRemaining: post.attemptsRemaining, wrongAttempts: 0 };
1952
+ return { attemptsRemaining: post.attemptsRemaining, wrongAttempts: ownWrongAttempts };
1847
1953
  };
1848
1954
 
1849
1955
  /** New salt and verifier for a PIN just proven; undefined when it fails the PIN rules. */
@@ -1916,7 +2022,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1916
2022
  },
1917
2023
  pin: string,
1918
2024
  /** PIN status read before this release; undefined when that read failed. */
1919
- pinBefore: unknown
2025
+ pinBefore: unknown,
2026
+ /** Own-typos context from the caller's PIN sign-in flow (E). */
2027
+ pinAttempts?: EscrowPinAttemptContext
1920
2028
  ): Promise<RecoveryResult> => {
1921
2029
  const storage = guardedStorage();
1922
2030
  const generation = storageGeneration;
@@ -1927,70 +2035,76 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1927
2035
  }
1928
2036
  };
1929
2037
 
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
- ) {
2038
+ const {
2039
+ mode,
2040
+ newPinRequired: newPinRequiredAtRelease,
2041
+ refreshCharges,
2042
+ } = await withShareUpdateLock(storage, storageId, () =>
2043
+ withRotationLock(async () => {
2044
+ assertActive();
2045
+ const current = await fetchAuthShareRaw(
2046
+ serverUrl,
2047
+ params.token,
2048
+ params.providerType,
2049
+ undefined,
2050
+ tenantId
2051
+ );
2052
+ assertActive();
2053
+ if (
2054
+ !current ||
2055
+ current.primaryDid !== released.primaryDid ||
2056
+ typeof current.shareVersion !== 'number'
2057
+ ) {
2058
+ throw new PinSignInStaleError();
2059
+ }
2060
+ let mode: 'kept' | 'deferred-rotation';
2061
+ if (current.shareVersion === released.shareVersion) {
2062
+ if (authShareToString(current.authShare) !== released.authShare) {
1949
2063
  throw new PinSignInStaleError();
1950
2064
  }
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';
2065
+ mode = 'kept';
2066
+ } else {
2067
+ const historical =
2068
+ current.shareVersion > released.shareVersion
2069
+ ? await fetchAuthShareRaw(
2070
+ serverUrl,
2071
+ params.token,
2072
+ params.providerType,
2073
+ released.shareVersion,
2074
+ tenantId
2075
+ )
2076
+ : null;
2077
+ if (authShareToString(historical?.authShare) !== released.authShare) {
2078
+ throw new PinSignInStaleError();
1972
2079
  }
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
- })
2080
+ mode = 'deferred-rotation';
2081
+ }
2082
+ assertActive();
2083
+ await adoptReleasedShare(
2084
+ storage,
2085
+ storageId,
2086
+ released,
2087
+ released.shareVersion,
2088
+ current.shareVersion
2089
+ );
2090
+ pendingIdentityRecovery = undefined;
2091
+ // Both reads count: the one after this release (a success never resets the
2092
+ // count) and the one before it (a legacy record without a verified count
2093
+ // reports 0 once any release has been reserved, so only the earlier read
2094
+ // shows that its history is unknown). Each must show exactly this flow's
2095
+ // own wrong entries (zero for callers without a flow context).
2096
+ const { clean, ownWrongAttempts } = pinSignInClean(
2097
+ [pinBefore, current.escrowPin],
2098
+ pinAttempts
2099
+ );
2100
+ return {
2101
+ mode,
2102
+ newPinRequired: !clean,
2103
+ refreshCharges: clean
2104
+ ? refreshablePinCharges(pinBefore, current.escrowPin, ownWrongAttempts)
2105
+ : undefined,
2106
+ };
2107
+ })
1994
2108
  );
1995
2109
 
1996
2110
  // Same PIN, new salt and verifier, enrolled at the same version: a fresh
@@ -2001,26 +2115,29 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2001
2115
  // scheduled.
2002
2116
  let pinRefreshed = false;
2003
2117
  let pinRefreshFailed = false;
2118
+ let newPinRequired = newPinRequiredAtRelease;
2004
2119
  if (mode === 'kept' && !newPinRequired && refreshCharges && params.signDidAuthVp) {
2005
2120
  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
- ));
2121
+ const refresh =
2122
+ pinMaterial === undefined
2123
+ ? undefined
2124
+ : await attemptEnrollEscrow(
2125
+ params.token,
2126
+ params.providerType,
2127
+ released.privateKey,
2128
+ released.primaryDid,
2129
+ { recoveryShare: released.recoveryShare },
2130
+ released.shareVersion,
2131
+ params.signDidAuthVp,
2132
+ undefined,
2133
+ pinMaterial,
2134
+ false,
2135
+ storage.assertCurrent,
2136
+ params.options?.recoveryEmail,
2137
+ params.options?.recoveryLanguage,
2138
+ refreshCharges
2139
+ );
2140
+ pinRefreshed = refresh?.enrolled === true;
2024
2141
  // Reported, not swallowed: the PIN keeps (at most) its reduced budget. A commit
2025
2142
  // whose receipt was lost is not confirmed here, so it reports failed too (an
2026
2143
  // under-claim: the budget is in fact full). lca-api either
@@ -2028,6 +2145,21 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2028
2145
  // authority's commit; a copy left behind is caught by ensureEscrowEnrollment's
2029
2146
  // identity check, which never clears the PIN on its own.
2030
2147
  pinRefreshFailed = pinMaterial !== undefined && !pinRefreshed;
2148
+ // A refused refresh usually means the charges moved after our reads, most
2149
+ // likely someone else's entry: re-check before showing the calm sheet. If the
2150
+ // status can't be read, a 409 (charges changed) still counts as a warning.
2151
+ if (pinRefreshFailed) {
2152
+ const latest = await fetchAuthShareRaw(
2153
+ serverUrl,
2154
+ params.token,
2155
+ params.providerType,
2156
+ undefined,
2157
+ tenantId
2158
+ ).catch(() => undefined);
2159
+ newPinRequired = latest
2160
+ ? !pinSignInClean([pinBefore, latest.escrowPin], pinAttempts).clean
2161
+ : isConflictRefusal(refresh && !refresh.enrolled ? refresh.error : undefined);
2162
+ }
2031
2163
  }
2032
2164
  assertActive();
2033
2165
  return {
@@ -2144,7 +2276,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2144
2276
  pinSignIn: {
2145
2277
  mode: 'relinked',
2146
2278
  pinRefreshed: false,
2147
- newPinRequired: pinHasRecordedWrongAttempts(status?.escrowPin),
2279
+ newPinRequired: !pinSignInClean([status?.escrowPin], pending.pinKeep?.pinAttempts)
2280
+ .clean,
2148
2281
  },
2149
2282
  };
2150
2283
  };
@@ -3079,7 +3212,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
3079
3212
  const verified = await verifyEscrowHoldRecord(
3080
3213
  result.holdRecord,
3081
3214
  {
3082
- tenant: tenantId ?? 'learncard',
3215
+ tenant: authorityTenant,
3083
3216
  did: expectedDid,
3084
3217
  holdId: result.holdId,
3085
3218
  clientEphemeralPublicKey: pair.publicKey,
@@ -3088,7 +3221,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
3088
3221
  identity
3089
3222
  );
3090
3223
  expectations = {
3091
- tenant: tenantId ?? 'learncard',
3224
+ tenant: authorityTenant,
3092
3225
  did: expectedDid,
3093
3226
  holdId: result.holdId,
3094
3227
  clientEphemeralPublicKey: pair.publicKey,
@@ -3337,7 +3470,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
3337
3470
  shareVersion: material.shareVersion,
3338
3471
  },
3339
3472
  input.pin,
3340
- pinBefore
3473
+ pinBefore,
3474
+ input.pinAttempts
3341
3475
  );
3342
3476
  }
3343
3477
  pendingIdentityRecovery = {
@@ -3349,7 +3483,14 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
3349
3483
  fromEscrow: true,
3350
3484
  // The PIN itself is not kept across the new-login step.
3351
3485
  ...(input.method === 'escrow-pin'
3352
- ? { pinKeep: { shareVersion: material.shareVersion } }
3486
+ ? {
3487
+ pinKeep: {
3488
+ shareVersion: material.shareVersion,
3489
+ ...(input.pinAttempts
3490
+ ? { pinAttempts: { ...input.pinAttempts } }
3491
+ : {}),
3492
+ },
3493
+ }
3353
3494
  : {}),
3354
3495
  };
3355
3496
  // 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';