@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.
- package/README.md +9 -0
- package/dist/escrow-authority.d.ts +4 -3
- package/dist/escrow-authority.d.ts.map +1 -1
- package/dist/escrow-authority.types.d.ts +15 -0
- package/dist/escrow-authority.types.d.ts.map +1 -1
- package/dist/escrow-crypto.d.ts +19 -1
- package/dist/escrow-crypto.d.ts.map +1 -1
- package/dist/escrow-enrollment-intent.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/sss-key-manager.cjs.development.js +776 -89
- package/dist/sss-key-manager.cjs.development.js.map +3 -3
- package/dist/sss-key-manager.cjs.production.min.js +30 -30
- package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
- package/dist/sss-key-manager.esm.js +778 -89
- package/dist/sss-key-manager.esm.js.map +3 -3
- package/dist/sss-strategy.d.ts +26 -0
- 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/__fixtures__/escrow-authority-brand-names-v1.json +29 -0
- package/src/__fixtures__/escrow-carry-contact-contract-v1.json +25 -1
- package/src/__fixtures__/escrow-email-brand-contract-v1.json +225 -0
- package/src/escrow-authority.test.ts +2 -0
- package/src/escrow-authority.ts +24 -7
- package/src/escrow-authority.types.ts +27 -0
- package/src/escrow-crypto.test.ts +75 -0
- package/src/escrow-crypto.ts +43 -1
- package/src/escrow-enrollment-intent.ts +2 -1
- package/src/index.ts +14 -2
- package/src/sss-authority.test.ts +392 -32
- package/src/sss-strategy.test.ts +238 -0
- package/src/sss-strategy.ts +245 -104
- 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,
|
|
@@ -53,7 +54,12 @@ import {
|
|
|
53
54
|
derivePinProof,
|
|
54
55
|
ESCROW_PIN_MAX_ATTEMPTS,
|
|
55
56
|
} from './escrow-pin';
|
|
56
|
-
import {
|
|
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?: {
|
|
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 !==
|
|
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
|
-
|
|
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
|
-
? {
|
|
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
|
-
*
|
|
1818
|
-
*
|
|
1819
|
-
*
|
|
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
|
|
1884
|
+
const recordedWrongPinAttempts = (escrowPin: unknown): number | undefined => {
|
|
1822
1885
|
const status = escrowPin as { state?: unknown; wrongAttempts?: unknown } | undefined;
|
|
1823
|
-
if (status?.state === 'none') return
|
|
1824
|
-
|
|
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
|
-
*
|
|
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:
|
|
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 {
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
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
|
-
|
|
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';
|
|
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
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
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
|
-
|
|
2007
|
-
pinMaterial
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
? {
|
|
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';
|