@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.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/sss-key-manager.cjs.development.js +56 -16
- package/dist/sss-key-manager.cjs.development.js.map +2 -2
- package/dist/sss-key-manager.cjs.production.min.js +23 -23
- package/dist/sss-key-manager.cjs.production.min.js.map +3 -3
- package/dist/sss-key-manager.esm.js +56 -16
- package/dist/sss-key-manager.esm.js.map +2 -2
- package/dist/sss-strategy.d.ts.map +1 -1
- package/dist/types.d.ts +28 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +6 -1
- package/src/sss-strategy.test.ts +238 -0
- package/src/sss-strategy.ts +179 -96
- package/src/types.ts +29 -0
package/src/sss-strategy.ts
CHANGED
|
@@ -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
|
-
*
|
|
1818
|
-
*
|
|
1819
|
-
*
|
|
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
|
|
1826
|
+
const recordedWrongPinAttempts = (escrowPin: unknown): number | undefined => {
|
|
1822
1827
|
const status = escrowPin as { state?: unknown; wrongAttempts?: unknown } | undefined;
|
|
1823
|
-
if (status?.state === 'none') return
|
|
1824
|
-
|
|
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
|
-
*
|
|
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:
|
|
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 {
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
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
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
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
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
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
|
-
|
|
2007
|
-
pinMaterial
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
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:
|
|
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
|
-
? {
|
|
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';
|