@learncard/sss-key-manager 0.1.24 → 0.2.1
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 +25 -95
- package/dist/api-client.d.ts +44 -7
- package/dist/api-client.d.ts.map +1 -1
- package/dist/atomic-operations.d.ts +10 -2
- package/dist/atomic-operations.d.ts.map +1 -1
- package/dist/email-relay-crypto.d.ts +49 -0
- package/dist/email-relay-crypto.d.ts.map +1 -0
- package/dist/escrow-attestation.d.ts +12 -0
- package/dist/escrow-attestation.d.ts.map +1 -0
- package/dist/escrow-crypto.d.ts +52 -0
- package/dist/escrow-crypto.d.ts.map +1 -0
- package/dist/escrow-nitro-attestation.d.ts +32 -0
- package/dist/escrow-nitro-attestation.d.ts.map +1 -0
- package/dist/escrow-pin.d.ts +22 -0
- package/dist/escrow-pin.d.ts.map +1 -0
- package/dist/index.d.ts +15 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/key-manager.d.ts +2 -0
- package/dist/key-manager.d.ts.map +1 -1
- package/dist/qr-login.d.ts.map +1 -1
- package/dist/recovery-phrase.d.ts +6 -0
- package/dist/recovery-phrase.d.ts.map +1 -1
- package/dist/sss-key-manager.cjs.development.js +5564 -353
- package/dist/sss-key-manager.cjs.development.js.map +4 -4
- package/dist/sss-key-manager.cjs.production.min.js +33 -33
- package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
- package/dist/sss-key-manager.esm.js +3494 -354
- package/dist/sss-key-manager.esm.js.map +4 -4
- package/dist/sss-strategy.d.ts +74 -4
- package/dist/sss-strategy.d.ts.map +1 -1
- package/dist/storage.d.ts +1 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/types.d.ts +98 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -3
- package/src/__fixtures__/escrow-vectors-rust.json +82 -0
- package/src/__fixtures__/escrow-vectors.json +103 -0
- package/src/__fixtures__/nitro-attestation/bad-signature.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/debug-zero-pcrs.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/legacy-user-data.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/manifest.json +60 -0
- package/src/__fixtures__/nitro-attestation/nonce-mismatch.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/root.pem +12 -0
- package/src/__fixtures__/nitro-attestation/stale-timestamp.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/tampered-previous-key-ids.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/truncated-chain.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/valid.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/wrong-pcr0.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/wrong-root.cbor.b64 +1 -0
- package/src/api-client.test.ts +122 -0
- package/src/api-client.ts +107 -116
- package/src/atomic-operations.test.ts +56 -16
- package/src/atomic-operations.ts +55 -19
- package/src/critical-paths.test.ts +51 -1
- package/src/email-relay-crypto.test.ts +87 -0
- package/src/email-relay-crypto.ts +293 -0
- package/src/escrow-attestation.test.ts +90 -0
- package/src/escrow-attestation.ts +125 -0
- package/src/escrow-crypto.test.ts +381 -0
- package/src/escrow-crypto.ts +319 -0
- package/src/escrow-nitro-attestation.test.ts +204 -0
- package/src/escrow-nitro-attestation.ts +286 -0
- package/src/escrow-pin.test.ts +119 -0
- package/src/escrow-pin.ts +83 -0
- package/src/escrow-vectors.test.ts +198 -0
- package/src/index.ts +70 -1
- package/src/key-manager.ts +4 -9
- package/src/qr-login.ts +22 -15
- package/src/recovery-phrase.ts +40 -0
- package/src/sss-session-writes.test.ts +109 -0
- package/src/sss-strategy.test.ts +2736 -111
- package/src/sss-strategy.ts +2226 -224
- package/src/storage.test.ts +66 -8
- package/src/storage.ts +41 -29
- package/src/types.ts +118 -6
package/src/sss-strategy.ts
CHANGED
|
@@ -22,21 +22,59 @@ import type {
|
|
|
22
22
|
RecoveryResult,
|
|
23
23
|
RecoverySetupInput,
|
|
24
24
|
RecoverySetupResult,
|
|
25
|
+
RecoveryConfirmationInput,
|
|
25
26
|
RecoveryMethodInfo,
|
|
27
|
+
IdentityRecoverySession,
|
|
26
28
|
BackupFile,
|
|
29
|
+
DidAuthVpSigner,
|
|
30
|
+
EscrowAttestationPolicy,
|
|
31
|
+
EscrowHoldStatus,
|
|
32
|
+
EscrowRecoveryStart,
|
|
33
|
+
EscrowEnrollmentState,
|
|
27
34
|
} from './types';
|
|
35
|
+
import {
|
|
36
|
+
EscrowPinLockedError,
|
|
37
|
+
EscrowPinMismatchError,
|
|
38
|
+
EscrowPinThrottledError,
|
|
39
|
+
EscrowHoldRestartThrottledError,
|
|
40
|
+
EscrowPinUnavailableError,
|
|
41
|
+
} from './types';
|
|
42
|
+
import {
|
|
43
|
+
ESCROW_PIN_LOCKED_MESSAGE,
|
|
44
|
+
ESCROW_PIN_MISMATCH_PATTERN,
|
|
45
|
+
ESCROW_PIN_UNAVAILABLE_MESSAGE,
|
|
46
|
+
} from '@learncard/types';
|
|
47
|
+
import {
|
|
48
|
+
validatePin,
|
|
49
|
+
generatePinSalt,
|
|
50
|
+
derivePinProof,
|
|
51
|
+
ESCROW_PIN_MAX_ATTEMPTS,
|
|
52
|
+
} from './escrow-pin';
|
|
53
|
+
import { encryptEscrowBlob, generateEscrowKeyPair, openEscrowRelease } from './escrow-crypto';
|
|
54
|
+
import { verifyEnclaveAttestation } from './escrow-attestation';
|
|
55
|
+
import type { EmailRelayBranding } from './email-relay-crypto';
|
|
28
56
|
|
|
29
|
-
import {
|
|
57
|
+
import {
|
|
58
|
+
AtomicUpdateError,
|
|
59
|
+
ShareWriteRejectedError,
|
|
60
|
+
atomicRecovery,
|
|
61
|
+
atomicShareUpdate,
|
|
62
|
+
splitAndVerify,
|
|
63
|
+
verifyStoredShares,
|
|
64
|
+
} from './atomic-operations';
|
|
65
|
+
import type { SSSShares } from './sss';
|
|
30
66
|
import { reconstructFromShares } from './sss';
|
|
31
67
|
import {
|
|
32
68
|
storeDeviceShare as defaultStoreDeviceShare,
|
|
33
69
|
getDeviceShare as defaultGetDeviceShare,
|
|
34
70
|
hasDeviceShare as defaultHasDeviceShare,
|
|
35
71
|
clearAllShares as defaultClearAllShares,
|
|
72
|
+
deleteDeviceShare as defaultDeleteDeviceShare,
|
|
36
73
|
storeShareVersion as defaultStoreShareVersion,
|
|
37
74
|
getShareVersion as defaultGetShareVersion,
|
|
38
75
|
} from './storage';
|
|
39
|
-
import { encryptWithPassword, decryptWithPassword } from './crypto';
|
|
76
|
+
import { bytesToHex, encryptWithPassword, decryptWithPassword } from './crypto';
|
|
77
|
+
import { encryptEmailRelayPayload, generateEmailRelayConfirmationCode } from './email-relay-crypto';
|
|
40
78
|
import {
|
|
41
79
|
createPasskeyCredential,
|
|
42
80
|
encryptShareWithPasskey,
|
|
@@ -48,20 +86,30 @@ import {
|
|
|
48
86
|
shareToRecoveryPhrase,
|
|
49
87
|
recoveryPhraseToShare,
|
|
50
88
|
validateRecoveryPhrase,
|
|
89
|
+
buildRecoveryPhraseChallengeOptions,
|
|
51
90
|
} from './recovery-phrase';
|
|
52
91
|
|
|
53
92
|
const SSS_DB_NAME = 'lcb-sss-keys';
|
|
93
|
+
const DEFAULT_DEVICE_SHARE_ID = 'sss-device-share';
|
|
94
|
+
const MAX_RECONCILIATION_HISTORY = 5;
|
|
54
95
|
|
|
55
96
|
export interface SSSStorageFunctions {
|
|
56
97
|
storeDeviceShare: (share: string, id?: string) => Promise<void>;
|
|
57
98
|
getDeviceShare: (id?: string) => Promise<string | null>;
|
|
58
99
|
hasDeviceShare: (id?: string) => Promise<boolean>;
|
|
59
100
|
clearAllShares: (id?: string) => Promise<void>;
|
|
101
|
+
deleteDeviceShare: (id?: string) => Promise<void>;
|
|
60
102
|
storeShareVersion: (version: number, id?: string) => Promise<void>;
|
|
61
103
|
getShareVersion: (id?: string) => Promise<number | null>;
|
|
62
104
|
}
|
|
63
105
|
|
|
64
106
|
export interface SSSStrategyConfig {
|
|
107
|
+
/** Enable automatic enrollment under an explicit enclave trust policy. */
|
|
108
|
+
escrow?: { enabled: boolean; attestation: EscrowAttestationPolicy };
|
|
109
|
+
|
|
110
|
+
/** Report enrollment failures without logging recovery material. */
|
|
111
|
+
onEscrowError?: (err: unknown) => void;
|
|
112
|
+
|
|
65
113
|
/** Server URL for key share operations */
|
|
66
114
|
serverUrl: string;
|
|
67
115
|
|
|
@@ -69,14 +117,22 @@ export interface SSSStrategyConfig {
|
|
|
69
117
|
storage?: SSSStorageFunctions;
|
|
70
118
|
|
|
71
119
|
/**
|
|
72
|
-
* Whether
|
|
73
|
-
*
|
|
74
|
-
* but never persisted — fire-and-forget.
|
|
120
|
+
* Whether email recovery enrollment is enabled. Delivery is synchronous
|
|
121
|
+
* and must be confirmed with the code included in the recovery email.
|
|
75
122
|
*
|
|
76
123
|
* Defaults to false. Controlled by VITE_ENABLE_EMAIL_BACKUP_SHARE env var.
|
|
77
124
|
*/
|
|
78
125
|
enableEmailBackupShare?: boolean;
|
|
79
126
|
|
|
127
|
+
/** Base64 DER/SPKI P-256 public key pinned for the isolated email relay. */
|
|
128
|
+
escrowRelayPublicKey?: string;
|
|
129
|
+
|
|
130
|
+
/** Identifier selecting the matching relay private key during rotation. */
|
|
131
|
+
escrowRelayKeyId?: string;
|
|
132
|
+
|
|
133
|
+
/** Tenant branding encrypted into the relay payload with the recovery key. */
|
|
134
|
+
emailBranding?: EmailRelayBranding;
|
|
135
|
+
|
|
80
136
|
/**
|
|
81
137
|
* Tenant identifier forwarded as `X-Tenant-Id` on every server request.
|
|
82
138
|
* The lca-api uses this to brand recovery / OTP emails for the active
|
|
@@ -90,6 +146,7 @@ const defaultStorage: SSSStorageFunctions = {
|
|
|
90
146
|
getDeviceShare: defaultGetDeviceShare,
|
|
91
147
|
hasDeviceShare: defaultHasDeviceShare,
|
|
92
148
|
clearAllShares: defaultClearAllShares,
|
|
149
|
+
deleteDeviceShare: defaultDeleteDeviceShare,
|
|
93
150
|
storeShareVersion: defaultStoreShareVersion,
|
|
94
151
|
getShareVersion: defaultGetShareVersion,
|
|
95
152
|
};
|
|
@@ -108,6 +165,31 @@ const buildHeaders = (
|
|
|
108
165
|
...(tenantId ? { 'X-Tenant-Id': tenantId } : {}),
|
|
109
166
|
});
|
|
110
167
|
|
|
168
|
+
export const SERVER_REQUEST_TIMEOUT_MS = 30_000;
|
|
169
|
+
|
|
170
|
+
/** Abort signal for server writes so a stalled socket cannot block logout or key rotation forever. */
|
|
171
|
+
const requestTimeoutSignal = (ms = SERVER_REQUEST_TIMEOUT_MS): AbortSignal | undefined =>
|
|
172
|
+
typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
|
|
173
|
+
? AbortSignal.timeout(ms)
|
|
174
|
+
: undefined;
|
|
175
|
+
|
|
176
|
+
/** HTTP/2 responses have an empty statusText, so always lead with the numeric status. */
|
|
177
|
+
const describeHttpStatus = (response: Response): string =>
|
|
178
|
+
response.statusText ? `${response.status} ${response.statusText}` : `${response.status}`;
|
|
179
|
+
|
|
180
|
+
type EscrowEnrollmentResult =
|
|
181
|
+
| { enrolled: false; reason: 'disabled' | 'opted-out' }
|
|
182
|
+
| { enrolled: true; changed: false }
|
|
183
|
+
| { enrolled: true; changed: true; shareVersion: number };
|
|
184
|
+
|
|
185
|
+
/** HTTP failure metadata without potentially sensitive server response text. */
|
|
186
|
+
export class EscrowRequestError extends Error {
|
|
187
|
+
constructor(public readonly status: number) {
|
|
188
|
+
super('Escrow request failed');
|
|
189
|
+
this.name = 'EscrowRequestError';
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
111
193
|
const fetchAuthShareRaw = async (
|
|
112
194
|
serverUrl: string,
|
|
113
195
|
token: string,
|
|
@@ -118,6 +200,7 @@ const fetchAuthShareRaw = async (
|
|
|
118
200
|
const response = await fetch(`${serverUrl}/keys/auth-share`, {
|
|
119
201
|
method: 'POST',
|
|
120
202
|
headers: buildHeaders(token, undefined, tenantId),
|
|
203
|
+
signal: requestTimeoutSignal(),
|
|
121
204
|
body: JSON.stringify({
|
|
122
205
|
authToken: token,
|
|
123
206
|
providerType,
|
|
@@ -128,7 +211,7 @@ const fetchAuthShareRaw = async (
|
|
|
128
211
|
if (!response.ok) {
|
|
129
212
|
if (response.status === 404) return null;
|
|
130
213
|
|
|
131
|
-
throw new Error(`Failed to fetch key status: ${response
|
|
214
|
+
throw new Error(`Failed to fetch key status: ${describeHttpStatus(response)}`);
|
|
132
215
|
}
|
|
133
216
|
|
|
134
217
|
return response.json();
|
|
@@ -141,27 +224,86 @@ const putAuthShare = async (
|
|
|
141
224
|
authShare: string,
|
|
142
225
|
primaryDid: string,
|
|
143
226
|
didAuthVp?: string,
|
|
144
|
-
tenantId?: string
|
|
145
|
-
|
|
227
|
+
tenantId?: string,
|
|
228
|
+
expectedShareVersion?: number
|
|
229
|
+
): Promise<{ shareVersion: number; expectedShareVersionChecked: boolean }> => {
|
|
146
230
|
const response = await fetch(`${serverUrl}/keys/auth-share`, {
|
|
147
231
|
method: 'PUT',
|
|
148
232
|
headers: buildHeaders(token, didAuthVp, tenantId),
|
|
233
|
+
signal: requestTimeoutSignal(),
|
|
149
234
|
body: JSON.stringify({
|
|
150
235
|
authToken: token,
|
|
151
236
|
providerType,
|
|
152
237
|
authShare: { encryptedData: authShare, encryptedDek: '', iv: '' },
|
|
153
238
|
primaryDid,
|
|
154
239
|
securityLevel: 'basic',
|
|
240
|
+
sssActivationState: 'provisional',
|
|
241
|
+
expectedShareVersion,
|
|
155
242
|
}),
|
|
156
243
|
});
|
|
157
244
|
|
|
158
245
|
if (!response.ok) {
|
|
159
|
-
|
|
246
|
+
const message = `Failed to store auth share: ${describeHttpStatus(response)}`;
|
|
247
|
+
// A gateway/server failure or request timeout can arrive AFTER commit.
|
|
248
|
+
// Only an explicit client rejection proves this write was not accepted.
|
|
249
|
+
if (response.status >= 400 && response.status < 500 && response.status !== 408) {
|
|
250
|
+
throw new ShareWriteRejectedError(message);
|
|
251
|
+
}
|
|
252
|
+
throw new Error(message);
|
|
160
253
|
}
|
|
161
254
|
|
|
162
255
|
const data = await response.json();
|
|
163
256
|
|
|
164
|
-
return {
|
|
257
|
+
return {
|
|
258
|
+
shareVersion: data.shareVersion ?? 1,
|
|
259
|
+
expectedShareVersionChecked: data.expectedShareVersionChecked === true,
|
|
260
|
+
};
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const authShareToString = (authShare: unknown): string | null => {
|
|
264
|
+
if (typeof authShare === 'string') return authShare;
|
|
265
|
+
|
|
266
|
+
if (
|
|
267
|
+
typeof authShare === 'object' &&
|
|
268
|
+
authShare !== null &&
|
|
269
|
+
'encryptedData' in authShare &&
|
|
270
|
+
typeof authShare.encryptedData === 'string'
|
|
271
|
+
) {
|
|
272
|
+
return authShare.encryptedData;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return null;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Obtain a server-issued nonce and sign a DID-Auth VP specifically for one
|
|
280
|
+
* write. The server consumes the nonce while creating request context, so a
|
|
281
|
+
* VP returned here cannot authorize a second request.
|
|
282
|
+
*/
|
|
283
|
+
const requestFreshDidAuthVp = async (
|
|
284
|
+
serverUrl: string,
|
|
285
|
+
privateKey: string,
|
|
286
|
+
did: string,
|
|
287
|
+
signDidAuthVp: DidAuthVpSigner,
|
|
288
|
+
tenantId?: string
|
|
289
|
+
): Promise<string> => {
|
|
290
|
+
const bootstrapVp = await signDidAuthVp(privateKey);
|
|
291
|
+
const response = await fetch(`${serverUrl}/keys/challenge`, {
|
|
292
|
+
method: 'POST',
|
|
293
|
+
headers: buildHeaders('', bootstrapVp, tenantId),
|
|
294
|
+
signal: requestTimeoutSignal(),
|
|
295
|
+
body: JSON.stringify({ did }),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
if (!response.ok) {
|
|
299
|
+
throw new Error(`Failed to request DID challenge: ${describeHttpStatus(response)}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const data = (await response.json()) as { challenge?: string };
|
|
303
|
+
|
|
304
|
+
if (!data.challenge) throw new Error('DID challenge response was empty');
|
|
305
|
+
|
|
306
|
+
return signDidAuthVp(privateKey, data.challenge);
|
|
165
307
|
};
|
|
166
308
|
|
|
167
309
|
interface RecoveryShareResponse {
|
|
@@ -177,7 +319,12 @@ const fetchRecoveryShare = async (
|
|
|
177
319
|
credentialId?: string,
|
|
178
320
|
tenantId?: string
|
|
179
321
|
): Promise<RecoveryShareResponse> => {
|
|
180
|
-
|
|
322
|
+
// P0-4: this GET has no body, so — unlike the authToken-in-body pattern
|
|
323
|
+
// used elsewhere in this file — the token travels as an X-Auth-Token
|
|
324
|
+
// header (not Authorization, which is reserved for a DID-Auth VP on
|
|
325
|
+
// this same route for other callers). Never as a query param: those
|
|
326
|
+
// land in proxy/ALB access logs.
|
|
327
|
+
const params = new URLSearchParams({ type, providerType });
|
|
181
328
|
|
|
182
329
|
if (credentialId) {
|
|
183
330
|
params.set('credentialId', credentialId);
|
|
@@ -185,7 +332,11 @@ const fetchRecoveryShare = async (
|
|
|
185
332
|
|
|
186
333
|
const response = await fetch(`${serverUrl}/keys/recovery?${params}`, {
|
|
187
334
|
method: 'GET',
|
|
188
|
-
headers:
|
|
335
|
+
headers: {
|
|
336
|
+
'Content-Type': 'application/json',
|
|
337
|
+
'X-Auth-Token': token,
|
|
338
|
+
...(tenantId ? { 'X-Tenant-Id': tenantId } : {}),
|
|
339
|
+
},
|
|
189
340
|
});
|
|
190
341
|
|
|
191
342
|
if (!response.ok) {
|
|
@@ -195,6 +346,61 @@ const fetchRecoveryShare = async (
|
|
|
195
346
|
return response.json();
|
|
196
347
|
};
|
|
197
348
|
|
|
349
|
+
interface IdentityRecoverySessionResponse {
|
|
350
|
+
recoverySessionToken: string;
|
|
351
|
+
recoveryMethods: Array<{
|
|
352
|
+
type: string;
|
|
353
|
+
createdAt: string;
|
|
354
|
+
credentialId?: string;
|
|
355
|
+
shareVersion?: number;
|
|
356
|
+
}>;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
interface IdentityRecoveryMaterialResponse {
|
|
360
|
+
authShare: { encryptedData: string; encryptedDek: string; iv: string };
|
|
361
|
+
encryptedShare?: { encryptedData: string; iv: string; salt?: string };
|
|
362
|
+
primaryDid: string;
|
|
363
|
+
shareVersion: number;
|
|
364
|
+
rebindSessionToken: string;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Signals that an identity-recovery attempt failed after the one-shot server
|
|
369
|
+
* session was submitted and must be restarted with a new verification code.
|
|
370
|
+
*/
|
|
371
|
+
export class IdentityRecoverySessionConsumedError extends Error {
|
|
372
|
+
constructor(message: string) {
|
|
373
|
+
super(message);
|
|
374
|
+
this.name = 'IdentityRecoverySessionConsumedError';
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const postJson = async <T>(
|
|
379
|
+
url: string,
|
|
380
|
+
body: Record<string, unknown>,
|
|
381
|
+
headers: Record<string, string>
|
|
382
|
+
): Promise<T> => {
|
|
383
|
+
const response = await fetch(url, {
|
|
384
|
+
method: 'POST',
|
|
385
|
+
headers,
|
|
386
|
+
body: JSON.stringify(body),
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
if (!response.ok) {
|
|
390
|
+
const data = (await response.json().catch(() => null)) as {
|
|
391
|
+
message?: string;
|
|
392
|
+
error?: { message?: string };
|
|
393
|
+
} | null;
|
|
394
|
+
const message = data?.error?.message || data?.message || response.statusText;
|
|
395
|
+
if (response.status >= 400 && response.status < 500 && response.status !== 408) {
|
|
396
|
+
throw new ShareWriteRejectedError(message);
|
|
397
|
+
}
|
|
398
|
+
throw new Error(message);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return response.json();
|
|
402
|
+
};
|
|
403
|
+
|
|
198
404
|
const postRecoveryMethod = async (
|
|
199
405
|
serverUrl: string,
|
|
200
406
|
token: string,
|
|
@@ -210,10 +416,70 @@ const postRecoveryMethod = async (
|
|
|
210
416
|
});
|
|
211
417
|
|
|
212
418
|
if (!response.ok) {
|
|
213
|
-
throw new Error(`Failed to add recovery method: ${response
|
|
419
|
+
throw new Error(`Failed to add recovery method: ${describeHttpStatus(response)}`);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const postRecoveryConfirmation = async (
|
|
424
|
+
serverUrl: string,
|
|
425
|
+
token: string,
|
|
426
|
+
providerType: AuthProviderType,
|
|
427
|
+
body: Record<string, unknown>,
|
|
428
|
+
didAuthVp?: string,
|
|
429
|
+
tenantId?: string
|
|
430
|
+
): Promise<void> => {
|
|
431
|
+
const response = await fetch(`${serverUrl}/keys/recovery/confirm`, {
|
|
432
|
+
method: 'POST',
|
|
433
|
+
headers: buildHeaders(token, didAuthVp, tenantId),
|
|
434
|
+
body: JSON.stringify({ authToken: token, providerType, ...body }),
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
if (!response.ok) {
|
|
438
|
+
const data = (await response.json().catch(() => null)) as {
|
|
439
|
+
message?: string;
|
|
440
|
+
error?: { message?: string };
|
|
441
|
+
} | null;
|
|
442
|
+
const message =
|
|
443
|
+
data?.error?.message || data?.message || `Failed to confirm recovery method`;
|
|
444
|
+
|
|
445
|
+
throw new Error(message);
|
|
214
446
|
}
|
|
215
447
|
};
|
|
216
448
|
|
|
449
|
+
const calculateShareChecksum = async (share: string): Promise<string> => {
|
|
450
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(share));
|
|
451
|
+
|
|
452
|
+
return bytesToHex(new Uint8Array(digest));
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
/** Two tap-to-confirm questions with six choices each: a 1-in-36 guess. */
|
|
456
|
+
export const RECOVERY_PHRASE_CHALLENGE_COUNT = 2;
|
|
457
|
+
|
|
458
|
+
export const selectRecoveryPhraseChallengeIndices = (
|
|
459
|
+
wordCount: number,
|
|
460
|
+
challengeCount = RECOVERY_PHRASE_CHALLENGE_COUNT
|
|
461
|
+
): number[] => {
|
|
462
|
+
if (wordCount < challengeCount) {
|
|
463
|
+
throw new Error('Recovery phrase does not contain enough words for confirmation');
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const indices = new Set<number>();
|
|
467
|
+
// Rejection sampling: discard values in the partial top bucket so every
|
|
468
|
+
// index is equally likely (avoids modulo bias flagged by CodeQL).
|
|
469
|
+
const limit = 0x1_0000_0000 - (0x1_0000_0000 % wordCount);
|
|
470
|
+
|
|
471
|
+
while (indices.size < challengeCount) {
|
|
472
|
+
const randomValue = crypto.getRandomValues(new Uint32Array(1))[0];
|
|
473
|
+
|
|
474
|
+
if (randomValue === undefined) throw new Error('Failed to choose recovery phrase words');
|
|
475
|
+
if (randomValue >= limit) continue;
|
|
476
|
+
|
|
477
|
+
indices.add(randomValue % wordCount);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return [...indices].sort((a, b) => a - b);
|
|
481
|
+
};
|
|
482
|
+
|
|
217
483
|
/**
|
|
218
484
|
* Format an email share with a 4-character hex version prefix.
|
|
219
485
|
*
|
|
@@ -255,67 +521,319 @@ const sendEmailBackupShare = async (
|
|
|
255
521
|
providerType: AuthProviderType,
|
|
256
522
|
emailShare: string,
|
|
257
523
|
email: string,
|
|
524
|
+
escrowRelayPublicKey: string,
|
|
525
|
+
escrowRelayKeyId: string,
|
|
526
|
+
emailBranding?: EmailRelayBranding,
|
|
258
527
|
shareVersion?: number,
|
|
528
|
+
didAuthVp?: string,
|
|
259
529
|
tenantId?: string
|
|
260
530
|
): Promise<void> => {
|
|
261
|
-
|
|
262
|
-
// Prepend the share version so the recovery flow can request the matching auth share
|
|
263
|
-
const payload =
|
|
264
|
-
shareVersion != null ? formatVersionedEmailShare(emailShare, shareVersion) : emailShare;
|
|
265
|
-
|
|
266
|
-
const response = await fetch(`${serverUrl}/keys/email-backup`, {
|
|
267
|
-
method: 'POST',
|
|
268
|
-
headers: buildHeaders(token, undefined, tenantId),
|
|
269
|
-
body: JSON.stringify({ authToken: token, providerType, emailShare: payload, email }),
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
if (!response.ok) {
|
|
273
|
-
console.warn(`Email backup share delivery failed: ${response.statusText}`);
|
|
274
|
-
}
|
|
275
|
-
} catch (e) {
|
|
276
|
-
console.warn('Email backup share delivery failed:', e);
|
|
277
|
-
}
|
|
278
|
-
};
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Send the email share to the user's verified recovery email (stored server-side).
|
|
282
|
-
* The raw recovery email never leaves the server.
|
|
283
|
-
*/
|
|
284
|
-
const sendEmailShareToRecoveryEmail = async (
|
|
285
|
-
serverUrl: string,
|
|
286
|
-
token: string,
|
|
287
|
-
providerType: AuthProviderType,
|
|
288
|
-
emailShare: string,
|
|
289
|
-
shareVersion?: number,
|
|
290
|
-
tenantId?: string
|
|
291
|
-
): Promise<void> => {
|
|
292
|
-
const payload =
|
|
531
|
+
const recoveryKey =
|
|
293
532
|
shareVersion != null ? formatVersionedEmailShare(emailShare, shareVersion) : emailShare;
|
|
294
|
-
|
|
533
|
+
const confirmationCode = generateEmailRelayConfirmationCode();
|
|
534
|
+
const relayPayload = await encryptEmailRelayPayload(
|
|
535
|
+
{
|
|
536
|
+
targetEmail: email,
|
|
537
|
+
recoveryKey,
|
|
538
|
+
confirmationCode,
|
|
539
|
+
branding: emailBranding,
|
|
540
|
+
},
|
|
541
|
+
escrowRelayPublicKey,
|
|
542
|
+
escrowRelayKeyId
|
|
543
|
+
);
|
|
295
544
|
const response = await fetch(`${serverUrl}/keys/email-backup`, {
|
|
296
545
|
method: 'POST',
|
|
297
|
-
headers: buildHeaders(token,
|
|
546
|
+
headers: buildHeaders(token, didAuthVp, tenantId),
|
|
298
547
|
body: JSON.stringify({
|
|
299
548
|
authToken: token,
|
|
300
549
|
providerType,
|
|
301
|
-
|
|
302
|
-
|
|
550
|
+
relayPayload,
|
|
551
|
+
confirmationCode,
|
|
552
|
+
email,
|
|
553
|
+
shareVersion,
|
|
303
554
|
}),
|
|
304
555
|
});
|
|
305
556
|
|
|
306
557
|
if (!response.ok) {
|
|
307
|
-
throw new Error(`Failed to send recovery share
|
|
558
|
+
throw new Error(`Failed to send recovery share: ${describeHttpStatus(response)}`);
|
|
308
559
|
}
|
|
309
560
|
};
|
|
310
561
|
|
|
562
|
+
interface PersistedShareUpdate {
|
|
563
|
+
shares: SSSShares;
|
|
564
|
+
shareVersion: number;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
let rotationChain: Promise<unknown> = Promise.resolve();
|
|
568
|
+
|
|
311
569
|
/**
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
* `primaryDid` BEFORE any writes. This prevents a wrong key from overwriting
|
|
316
|
-
* the server's auth share and permanently corrupting recovery state.
|
|
570
|
+
* Serialize every share rotation (device + server write pair). Two rotations
|
|
571
|
+
* interleaving would leave the device share and the server auth share on
|
|
572
|
+
* different polynomials, which locks the account out on the next reconstruct.
|
|
317
573
|
*/
|
|
318
|
-
const
|
|
574
|
+
export const withRotationLock = <T>(operation: () => Promise<T>): Promise<T> => {
|
|
575
|
+
const run = rotationChain.then(operation, operation);
|
|
576
|
+
rotationChain = run.catch(() => undefined);
|
|
577
|
+
return run;
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
// Reuse the storage abstraction's encrypted, per-ID entries. No new methods are
|
|
581
|
+
// required of custom stores, and adaptive storage retains its session-only mode.
|
|
582
|
+
const pendingShareId = (storageId?: string): string =>
|
|
583
|
+
`sss-pending-share:${storageId ?? DEFAULT_DEVICE_SHARE_ID}`;
|
|
584
|
+
|
|
585
|
+
/** DID_CHALLENGE_TTL_SECS (5 minutes) plus a processing margin for auth-share PUTs. */
|
|
586
|
+
export const PENDING_WRITE_EXPIRY_MS = 10 * 60 * 1000;
|
|
587
|
+
const MAX_PENDING_SHARE_CANDIDATES = 8;
|
|
588
|
+
|
|
589
|
+
export interface PendingShareCandidate {
|
|
590
|
+
share: string;
|
|
591
|
+
createdAt: number;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Read device-only candidates from independent splits; never persist an auth,
|
|
596
|
+
* recovery, or email share here (two shares from one split reveal the key).
|
|
597
|
+
* Legacy raw shares have unknown age, but must still be checked before expiry.
|
|
598
|
+
* Mutating helpers must run under the account's share-update lock.
|
|
599
|
+
*/
|
|
600
|
+
export const readPendingShareCandidates = async (
|
|
601
|
+
storage: SSSStorageFunctions,
|
|
602
|
+
storageId?: string
|
|
603
|
+
): Promise<PendingShareCandidate[]> => {
|
|
604
|
+
const raw = await storage.getDeviceShare(pendingShareId(storageId));
|
|
605
|
+
if (!raw) return [];
|
|
606
|
+
if (!raw.trimStart().startsWith('{')) return [{ share: raw, createdAt: 0 }];
|
|
607
|
+
const data: unknown = JSON.parse(raw);
|
|
608
|
+
if (
|
|
609
|
+
!data ||
|
|
610
|
+
typeof data !== 'object' ||
|
|
611
|
+
!('v' in data) ||
|
|
612
|
+
data.v !== 1 ||
|
|
613
|
+
!('candidates' in data) ||
|
|
614
|
+
!Array.isArray(data.candidates) ||
|
|
615
|
+
!data.candidates.every(
|
|
616
|
+
(candidate: unknown) =>
|
|
617
|
+
candidate !== null &&
|
|
618
|
+
typeof candidate === 'object' &&
|
|
619
|
+
'share' in candidate &&
|
|
620
|
+
typeof candidate.share === 'string' &&
|
|
621
|
+
'createdAt' in candidate &&
|
|
622
|
+
typeof candidate.createdAt === 'number' &&
|
|
623
|
+
Number.isFinite(candidate.createdAt) &&
|
|
624
|
+
candidate.createdAt >= 0
|
|
625
|
+
)
|
|
626
|
+
)
|
|
627
|
+
throw new Error('Invalid pending share candidates');
|
|
628
|
+
return data.candidates as PendingShareCandidate[];
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
/** Clear only an empty/resolved queue or when explicitly forgetting this device. */
|
|
632
|
+
export const clearPendingShareCandidates = async (
|
|
633
|
+
storage: SSSStorageFunctions,
|
|
634
|
+
storageId?: string
|
|
635
|
+
): Promise<void> => storage.clearAllShares(pendingShareId(storageId));
|
|
636
|
+
|
|
637
|
+
/** Persist the versioned device-only queue without storing another share of any split. */
|
|
638
|
+
export const writePendingShareCandidates = async (
|
|
639
|
+
storage: SSSStorageFunctions,
|
|
640
|
+
candidates: PendingShareCandidate[],
|
|
641
|
+
storageId?: string
|
|
642
|
+
): Promise<void> => {
|
|
643
|
+
if (!candidates.length) return clearPendingShareCandidates(storage, storageId);
|
|
644
|
+
await storage.storeDeviceShare(JSON.stringify({ v: 1, candidates }), pendingShareId(storageId));
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
/** Append a candidate; never evict a live write to make room for another one. */
|
|
648
|
+
export const addPendingShareCandidate = async (
|
|
649
|
+
storage: SSSStorageFunctions,
|
|
650
|
+
candidate: PendingShareCandidate,
|
|
651
|
+
storageId?: string
|
|
652
|
+
): Promise<void> => {
|
|
653
|
+
const candidates = await readPendingShareCandidates(storage, storageId);
|
|
654
|
+
if (candidates.length >= MAX_PENDING_SHARE_CANDIDATES) {
|
|
655
|
+
throw new AtomicUpdateError(
|
|
656
|
+
'Reconcile the pending share update before writing new shares',
|
|
657
|
+
'store_device',
|
|
658
|
+
false
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
await writePendingShareCandidates(storage, [...candidates, candidate], storageId);
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
/** Roll back only the definitively rejected operation, preserving earlier in-flight writes. */
|
|
665
|
+
export const removePendingShareCandidate = async (
|
|
666
|
+
storage: SSSStorageFunctions,
|
|
667
|
+
share: string,
|
|
668
|
+
storageId?: string
|
|
669
|
+
): Promise<void> => {
|
|
670
|
+
const candidates = await readPendingShareCandidates(storage, storageId);
|
|
671
|
+
await writePendingShareCandidates(
|
|
672
|
+
storage,
|
|
673
|
+
candidates.filter(candidate => candidate.share !== share),
|
|
674
|
+
storageId
|
|
675
|
+
);
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
interface CurrentShareSnapshot {
|
|
679
|
+
currentVersion: number | null;
|
|
680
|
+
authShare: string | null;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Check current and retained history before expiry: an expired nonce does not
|
|
685
|
+
* prove a write never committed. Keep unrelated live writes even after promotion,
|
|
686
|
+
* since older servers may still commit them after this read.
|
|
687
|
+
*/
|
|
688
|
+
export const resolvePendingShareCandidates = async (
|
|
689
|
+
storage: SSSStorageFunctions,
|
|
690
|
+
storageId: string | undefined,
|
|
691
|
+
fetchCurrent: () => Promise<CurrentShareSnapshot>,
|
|
692
|
+
expectedDid: string,
|
|
693
|
+
didFromPrivateKey: (key: string) => Promise<string>,
|
|
694
|
+
assertActiveUser: () => void = () => {},
|
|
695
|
+
onPromoted?: (snapshot: CurrentShareSnapshot) => void,
|
|
696
|
+
fetchHistorical?: (version: number) => Promise<string | null>
|
|
697
|
+
): Promise<{ snapshot: CurrentShareSnapshot; privateKey: string | null }> => {
|
|
698
|
+
const candidates = await readPendingShareCandidates(storage, storageId);
|
|
699
|
+
const snapshot = await fetchCurrent();
|
|
700
|
+
assertActiveUser();
|
|
701
|
+
const { authShare, currentVersion } = snapshot;
|
|
702
|
+
if (
|
|
703
|
+
authShare &&
|
|
704
|
+
(currentVersion === null || !Number.isInteger(currentVersion) || currentVersion < 1)
|
|
705
|
+
) {
|
|
706
|
+
throw new Error('Current auth share is missing a valid share version');
|
|
707
|
+
}
|
|
708
|
+
if (authShare && currentVersion !== null) {
|
|
709
|
+
for (const candidate of candidates) {
|
|
710
|
+
const health = await verifyStoredShares(
|
|
711
|
+
{ getDevice: async () => candidate.share, getAuth: async () => authShare },
|
|
712
|
+
expectedDid,
|
|
713
|
+
didFromPrivateKey
|
|
714
|
+
);
|
|
715
|
+
assertActiveUser();
|
|
716
|
+
if (!health.healthy) continue;
|
|
717
|
+
const privateKey = await reconstructFromShares([candidate.share, authShare]);
|
|
718
|
+
assertActiveUser();
|
|
719
|
+
await storage.storeDeviceShare(candidate.share, storageId);
|
|
720
|
+
await storage.storeShareVersion(currentVersion, storageId);
|
|
721
|
+
await removePendingShareCandidate(storage, candidate.share, storageId);
|
|
722
|
+
assertActiveUser();
|
|
723
|
+
onPromoted?.(snapshot);
|
|
724
|
+
return { snapshot, privateKey };
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
const matched = new Set<string>();
|
|
728
|
+
const history = new Map<number, string>();
|
|
729
|
+
let historyComplete = true;
|
|
730
|
+
if (candidates.length && currentVersion !== null) {
|
|
731
|
+
for (
|
|
732
|
+
let version = currentVersion - 1;
|
|
733
|
+
version >= 1 && version >= currentVersion - MAX_RECONCILIATION_HISTORY;
|
|
734
|
+
version--
|
|
735
|
+
) {
|
|
736
|
+
if (!fetchHistorical) {
|
|
737
|
+
historyComplete = false;
|
|
738
|
+
break;
|
|
739
|
+
}
|
|
740
|
+
try {
|
|
741
|
+
const share = await fetchHistorical(version);
|
|
742
|
+
if (share) history.set(version, share);
|
|
743
|
+
} catch {
|
|
744
|
+
// An unavailable version cannot prove a candidate is dead.
|
|
745
|
+
historyComplete = false;
|
|
746
|
+
}
|
|
747
|
+
assertActiveUser();
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
const mainShare = await storage.getDeviceShare(storageId);
|
|
751
|
+
let mainHealthy = false;
|
|
752
|
+
const retainedShares = [...history.values(), ...(authShare ? [authShare] : [])];
|
|
753
|
+
if (mainShare) {
|
|
754
|
+
for (const share of retainedShares) {
|
|
755
|
+
const health = await verifyStoredShares(
|
|
756
|
+
{ getDevice: async () => mainShare, getAuth: async () => share },
|
|
757
|
+
expectedDid,
|
|
758
|
+
didFromPrivateKey
|
|
759
|
+
);
|
|
760
|
+
assertActiveUser();
|
|
761
|
+
if (health.healthy) {
|
|
762
|
+
mainHealthy = true;
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
let promoted: string | undefined;
|
|
768
|
+
for (const candidate of candidates) {
|
|
769
|
+
for (const [version, share] of history) {
|
|
770
|
+
const health = await verifyStoredShares(
|
|
771
|
+
{ getDevice: async () => candidate.share, getAuth: async () => share },
|
|
772
|
+
expectedDid,
|
|
773
|
+
didFromPrivateKey
|
|
774
|
+
);
|
|
775
|
+
assertActiveUser();
|
|
776
|
+
if (!health.healthy) continue;
|
|
777
|
+
matched.add(candidate.share);
|
|
778
|
+
if (!mainHealthy && !promoted) {
|
|
779
|
+
await storage.storeDeviceShare(candidate.share, storageId);
|
|
780
|
+
await storage.storeShareVersion(version, storageId);
|
|
781
|
+
assertActiveUser();
|
|
782
|
+
onPromoted?.({ currentVersion: version, authShare: share });
|
|
783
|
+
promoted = candidate.share;
|
|
784
|
+
}
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
const live = candidates.filter(
|
|
789
|
+
candidate =>
|
|
790
|
+
candidate.share !== promoted &&
|
|
791
|
+
(matched.has(candidate.share) ||
|
|
792
|
+
!historyComplete ||
|
|
793
|
+
(candidate.createdAt !== 0 &&
|
|
794
|
+
Date.now() - candidate.createdAt <= PENDING_WRITE_EXPIRY_MS))
|
|
795
|
+
);
|
|
796
|
+
if (live.length !== candidates.length) {
|
|
797
|
+
assertActiveUser();
|
|
798
|
+
await writePendingShareCandidates(storage, live, storageId);
|
|
799
|
+
}
|
|
800
|
+
return { snapshot, privateKey: null };
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
const activeShareUpdates = new WeakMap<SSSStorageFunctions, Set<string>>();
|
|
804
|
+
const shareUpdateStorageOwners = new WeakMap<SSSStorageFunctions, SSSStorageFunctions>();
|
|
805
|
+
|
|
806
|
+
/** Protect the pending slot from overlapping writers and reconciliation. */
|
|
807
|
+
const withShareUpdateLock = async <T>(
|
|
808
|
+
storage: SSSStorageFunctions,
|
|
809
|
+
storageId: string | undefined,
|
|
810
|
+
operation: () => Promise<T>
|
|
811
|
+
): Promise<T> => {
|
|
812
|
+
const id = pendingShareId(storageId);
|
|
813
|
+
const owner = shareUpdateStorageOwners.get(storage) ?? storage;
|
|
814
|
+
const active = activeShareUpdates.get(owner) ?? new Set<string>();
|
|
815
|
+
activeShareUpdates.set(owner, active);
|
|
816
|
+
const busy = (): AtomicUpdateError =>
|
|
817
|
+
new AtomicUpdateError('A share update is already in progress', 'store_device', false);
|
|
818
|
+
|
|
819
|
+
if (active.has(id)) throw busy();
|
|
820
|
+
active.add(id);
|
|
821
|
+
try {
|
|
822
|
+
// Web Locks also serialize default IndexedDB storage across tabs. The
|
|
823
|
+
// in-process guard supports runtimes/custom stores without that API.
|
|
824
|
+
if (typeof navigator !== 'undefined' && navigator.locks) {
|
|
825
|
+
return await navigator.locks.request(id, { ifAvailable: true }, async lock => {
|
|
826
|
+
if (!lock) throw busy();
|
|
827
|
+
return operation();
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
return await operation();
|
|
831
|
+
} finally {
|
|
832
|
+
active.delete(id);
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
const persistSharesWithoutLock = async (
|
|
319
837
|
privateKey: string,
|
|
320
838
|
serverUrl: string,
|
|
321
839
|
token: string,
|
|
@@ -324,8 +842,11 @@ const rotateShares = async (
|
|
|
324
842
|
storage: SSSStorageFunctions,
|
|
325
843
|
storageId?: string,
|
|
326
844
|
didFromPrivateKey?: (pk: string) => Promise<string>,
|
|
327
|
-
|
|
328
|
-
|
|
845
|
+
signDidAuthVp?: DidAuthVpSigner,
|
|
846
|
+
tenantId?: string,
|
|
847
|
+
onPromoted?: (snapshot: CurrentShareSnapshot) => void,
|
|
848
|
+
assertActiveUser: () => void = () => {}
|
|
849
|
+
): Promise<PersistedShareUpdate> => {
|
|
329
850
|
// Defensive DID check — refuse to rotate if the key is wrong
|
|
330
851
|
if (primaryDid && didFromPrivateKey) {
|
|
331
852
|
const derivedDid = await didFromPrivateKey(privateKey);
|
|
@@ -337,13 +858,86 @@ const rotateShares = async (
|
|
|
337
858
|
}
|
|
338
859
|
}
|
|
339
860
|
|
|
340
|
-
const {
|
|
861
|
+
const { snapshot } = await resolvePendingShareCandidates(
|
|
862
|
+
storage,
|
|
863
|
+
storageId,
|
|
864
|
+
async () => {
|
|
865
|
+
const data = await fetchAuthShareRaw(
|
|
866
|
+
serverUrl,
|
|
867
|
+
token,
|
|
868
|
+
providerType,
|
|
869
|
+
undefined,
|
|
870
|
+
tenantId
|
|
871
|
+
);
|
|
872
|
+
return {
|
|
873
|
+
currentVersion: data?.shareVersion ?? null,
|
|
874
|
+
authShare: authShareToString(data?.authShare),
|
|
875
|
+
};
|
|
876
|
+
},
|
|
877
|
+
primaryDid,
|
|
878
|
+
didFromPrivateKey ??
|
|
879
|
+
(async key => (key === privateKey ? primaryDid : `${primaryDid}:mismatch`)),
|
|
880
|
+
assertActiveUser,
|
|
881
|
+
onPromoted,
|
|
882
|
+
async version =>
|
|
883
|
+
authShareToString(
|
|
884
|
+
(await fetchAuthShareRaw(serverUrl, token, providerType, version, tenantId))
|
|
885
|
+
?.authShare
|
|
886
|
+
)
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
// Acquire authorization before staging: failures here cannot have committed
|
|
890
|
+
// an auth-share write and must not leave an unresolved pending operation.
|
|
891
|
+
const didAuthVp = signDidAuthVp
|
|
892
|
+
? await requestFreshDidAuthVp(serverUrl, privateKey, primaryDid, signDidAuthVp, tenantId)
|
|
893
|
+
: undefined;
|
|
894
|
+
assertActiveUser();
|
|
895
|
+
let shareVersion: number | undefined;
|
|
896
|
+
let stagedShare = '';
|
|
897
|
+
|
|
898
|
+
const shares = await atomicShareUpdate(privateKey, {
|
|
899
|
+
storeDevice: async share => {
|
|
900
|
+
assertActiveUser();
|
|
901
|
+
stagedShare = share;
|
|
902
|
+
await addPendingShareCandidate(storage, { share, createdAt: Date.now() }, storageId);
|
|
903
|
+
},
|
|
904
|
+
clearDevice: () => removePendingShareCandidate(storage, stagedShare, storageId),
|
|
905
|
+
storeAuth: async share => {
|
|
906
|
+
assertActiveUser();
|
|
907
|
+
const result = await putAuthShare(
|
|
908
|
+
serverUrl,
|
|
909
|
+
token,
|
|
910
|
+
providerType,
|
|
911
|
+
share,
|
|
912
|
+
primaryDid,
|
|
913
|
+
didAuthVp,
|
|
914
|
+
tenantId,
|
|
915
|
+
snapshot.currentVersion ?? 0
|
|
916
|
+
);
|
|
917
|
+
|
|
918
|
+
shareVersion = result.shareVersion;
|
|
919
|
+
},
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
if (shareVersion === undefined) {
|
|
923
|
+
throw new Error('Server did not return a share version');
|
|
924
|
+
}
|
|
341
925
|
|
|
342
926
|
await storage.storeDeviceShare(shares.deviceShare, storageId);
|
|
927
|
+
await storage.storeShareVersion(shareVersion, storageId);
|
|
928
|
+
await removePendingShareCandidate(storage, shares.deviceShare, storageId);
|
|
929
|
+
assertActiveUser();
|
|
343
930
|
|
|
344
|
-
|
|
931
|
+
return { shares, shareVersion };
|
|
345
932
|
};
|
|
346
933
|
|
|
934
|
+
const persistSharesAtomically = (
|
|
935
|
+
...args: Parameters<typeof persistSharesWithoutLock>
|
|
936
|
+
): Promise<PersistedShareUpdate> =>
|
|
937
|
+
withShareUpdateLock(args[5], args[6], () =>
|
|
938
|
+
withRotationLock(() => persistSharesWithoutLock(...args))
|
|
939
|
+
);
|
|
940
|
+
|
|
347
941
|
// ---------------------------------------------------------------------------
|
|
348
942
|
// Factory
|
|
349
943
|
// ---------------------------------------------------------------------------
|
|
@@ -365,8 +959,41 @@ const rotateShares = async (
|
|
|
365
959
|
* ```
|
|
366
960
|
*/
|
|
367
961
|
export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationStrategy {
|
|
368
|
-
const {
|
|
962
|
+
const {
|
|
963
|
+
serverUrl,
|
|
964
|
+
enableEmailBackupShare = false,
|
|
965
|
+
escrowRelayPublicKey = '',
|
|
966
|
+
escrowRelayKeyId = '',
|
|
967
|
+
emailBranding,
|
|
968
|
+
tenantId,
|
|
969
|
+
} = config;
|
|
369
970
|
const storage = config.storage || defaultStorage;
|
|
971
|
+
let storageGeneration = 0;
|
|
972
|
+
const localWrites = new Set<Promise<void>>();
|
|
973
|
+
|
|
974
|
+
// Capture before any await (including the rotation lock). A forgotten device
|
|
975
|
+
// must not be restored by a late write or by atomic-update rollback.
|
|
976
|
+
const guardedStorage = (): SSSStorageFunctions => {
|
|
977
|
+
const generation = storageGeneration;
|
|
978
|
+
const write = (operation: () => Promise<void>): Promise<void> => {
|
|
979
|
+
if (generation !== storageGeneration) {
|
|
980
|
+
return Promise.reject(new Error('This recovery request was cancelled.'));
|
|
981
|
+
}
|
|
982
|
+
const pending = operation();
|
|
983
|
+
localWrites.add(pending);
|
|
984
|
+
return pending.finally(() => localWrites.delete(pending));
|
|
985
|
+
};
|
|
986
|
+
const guarded: SSSStorageFunctions = {
|
|
987
|
+
...storage,
|
|
988
|
+
storeDeviceShare: (share, id) => write(() => storage.storeDeviceShare(share, id)),
|
|
989
|
+
storeShareVersion: (version, id) => write(() => storage.storeShareVersion(version, id)),
|
|
990
|
+
clearAllShares: id => write(() => storage.clearAllShares(id)),
|
|
991
|
+
deleteDeviceShare: id => write(() => storage.deleteDeviceShare(id)),
|
|
992
|
+
};
|
|
993
|
+
// Every request wrapper must share the underlying store's pending-slot lock.
|
|
994
|
+
shareUpdateStorageOwners.set(guarded, storage);
|
|
995
|
+
return guarded;
|
|
996
|
+
};
|
|
370
997
|
|
|
371
998
|
/**
|
|
372
999
|
* Per-user storage ID. When set, device shares are keyed as
|
|
@@ -394,12 +1021,368 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
394
1021
|
*/
|
|
395
1022
|
let lastShareVersion: number | undefined;
|
|
396
1023
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
1024
|
+
let pendingPhraseConfirmation:
|
|
1025
|
+
| {
|
|
1026
|
+
phrase: string;
|
|
1027
|
+
challengeWordIndices: number[];
|
|
1028
|
+
}
|
|
1029
|
+
| undefined;
|
|
1030
|
+
let pendingBackupConfirmation:
|
|
1031
|
+
| {
|
|
1032
|
+
backupFile: BackupFile;
|
|
1033
|
+
recoveryShare: string;
|
|
1034
|
+
}
|
|
1035
|
+
| undefined;
|
|
1036
|
+
|
|
1037
|
+
let pendingIdentityRecovery:
|
|
1038
|
+
| {
|
|
1039
|
+
privateKey: string;
|
|
1040
|
+
primaryDid: string;
|
|
1041
|
+
recoveryShare: string;
|
|
1042
|
+
authShare: string;
|
|
1043
|
+
rebindSessionToken: string;
|
|
1044
|
+
rebind?: { shares: SSSShares; storageId: string | undefined; attempted: boolean };
|
|
1045
|
+
}
|
|
1046
|
+
| undefined;
|
|
1047
|
+
|
|
1048
|
+
/** Last server response, including which version its auth share resolved. */
|
|
1049
|
+
let lastServerSnapshot: {
|
|
1050
|
+
currentVersion: number | null;
|
|
1051
|
+
resolvedVersion: number | null;
|
|
1052
|
+
authShare: string | null;
|
|
1053
|
+
primaryDid: string | null;
|
|
1054
|
+
} | null = null;
|
|
1055
|
+
|
|
1056
|
+
let escrowEnrollmentInFlight: Promise<EscrowEnrollmentResult> | undefined;
|
|
1057
|
+
// A stale-blob repair is attempted at most once per strategy instance
|
|
1058
|
+
// (i.e. once per app session); a failed attempt is logged and left for
|
|
1059
|
+
// the next session rather than retried on every subsequent call.
|
|
1060
|
+
let staleEscrowReenrollAttempted = false;
|
|
1061
|
+
|
|
1062
|
+
// Material from a rotation whose escrow POST failed. Retained in memory so a
|
|
1063
|
+
// retry within this session re-posts instead of burning another share version.
|
|
1064
|
+
// TODO(escrow): this cannot survive a reload; a cross-login guard needs the
|
|
1065
|
+
// server to expose a pending (unconfirmed) escrow record for the current version.
|
|
1066
|
+
let unenrolledRotation:
|
|
1067
|
+
| { shares: SSSShares; shareVersion: number; primaryDid: string; clearPin?: boolean }
|
|
1068
|
+
| undefined;
|
|
1069
|
+
|
|
1070
|
+
const escrowRequest = async <T>(
|
|
1071
|
+
path: string,
|
|
1072
|
+
init: RequestInit,
|
|
1073
|
+
pinAttempt = false
|
|
1074
|
+
): Promise<T> => {
|
|
1075
|
+
const response = await fetch(`${serverUrl}/keys/escrow${path}`, {
|
|
1076
|
+
signal: requestTimeoutSignal(),
|
|
1077
|
+
...init,
|
|
1078
|
+
});
|
|
1079
|
+
// Never propagate response bodies or status text that could contain secrets.
|
|
1080
|
+
if (!response.ok) {
|
|
1081
|
+
if (!pinAttempt && path === '/recover' && response.status === 429) {
|
|
1082
|
+
const body: unknown = await response.json().catch(() => undefined);
|
|
1083
|
+
const message =
|
|
1084
|
+
body &&
|
|
1085
|
+
typeof body === 'object' &&
|
|
1086
|
+
'message' in body &&
|
|
1087
|
+
typeof body.message === 'string'
|
|
1088
|
+
? body.message
|
|
1089
|
+
: '';
|
|
1090
|
+
const retryAfter =
|
|
1091
|
+
/Try again after (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)\./.exec(
|
|
1092
|
+
message
|
|
1093
|
+
)?.[1];
|
|
1094
|
+
throw new EscrowHoldRestartThrottledError(
|
|
1095
|
+
retryAfter && Number.isFinite(Date.parse(retryAfter)) ? retryAfter : undefined
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
if (pinAttempt && response.status === 429) {
|
|
1099
|
+
const body: unknown = await response.json().catch(() => undefined);
|
|
1100
|
+
if (
|
|
1101
|
+
body &&
|
|
1102
|
+
typeof body === 'object' &&
|
|
1103
|
+
'message' in body &&
|
|
1104
|
+
body.message === ESCROW_PIN_LOCKED_MESSAGE
|
|
1105
|
+
) {
|
|
1106
|
+
throw new EscrowPinLockedError();
|
|
1107
|
+
}
|
|
1108
|
+
throw new EscrowPinThrottledError();
|
|
1109
|
+
}
|
|
1110
|
+
if (pinAttempt && response.status === 403) {
|
|
1111
|
+
const body: unknown = await response.json().catch(() => undefined);
|
|
1112
|
+
if (
|
|
1113
|
+
body &&
|
|
1114
|
+
typeof body === 'object' &&
|
|
1115
|
+
'message' in body &&
|
|
1116
|
+
body.message === ESCROW_PIN_UNAVAILABLE_MESSAGE
|
|
1117
|
+
) {
|
|
1118
|
+
throw new EscrowPinUnavailableError();
|
|
1119
|
+
}
|
|
1120
|
+
if (
|
|
1121
|
+
body &&
|
|
1122
|
+
typeof body === 'object' &&
|
|
1123
|
+
'message' in body &&
|
|
1124
|
+
typeof body.message === 'string' &&
|
|
1125
|
+
body.message.startsWith('Incorrect PIN.')
|
|
1126
|
+
) {
|
|
1127
|
+
const match = ESCROW_PIN_MISMATCH_PATTERN.exec(body.message);
|
|
1128
|
+
const remaining = match ? Number(match[1]) : 0;
|
|
1129
|
+
// Older/malformed responses must not invent remaining guesses; use 0.
|
|
1130
|
+
throw new EscrowPinMismatchError(
|
|
1131
|
+
Number.isSafeInteger(remaining) &&
|
|
1132
|
+
remaining >= 0 &&
|
|
1133
|
+
remaining < ESCROW_PIN_MAX_ATTEMPTS
|
|
1134
|
+
? remaining
|
|
1135
|
+
: 0
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
throw new EscrowRequestError(response.status);
|
|
1140
|
+
}
|
|
1141
|
+
try {
|
|
1142
|
+
return await response.json();
|
|
1143
|
+
} catch {
|
|
1144
|
+
throw new EscrowRequestError(response.status);
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
|
|
1148
|
+
const fetchVerifiedEnclaveKey = async (): Promise<{ publicKey: string; keyId: string }> => {
|
|
1149
|
+
if (!config.escrow?.enabled) throw new Error('Escrow enrollment is disabled');
|
|
1150
|
+
const nonce = crypto.getRandomValues(new Uint8Array(32));
|
|
1151
|
+
const nonceHex = Array.from(nonce, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
1152
|
+
const { attestation } = await escrowRequest<{ attestation: unknown }>(
|
|
1153
|
+
`/attestation?nonce=${nonceHex}`,
|
|
1154
|
+
{
|
|
1155
|
+
method: 'GET',
|
|
1156
|
+
headers: buildHeaders('', undefined, tenantId),
|
|
1157
|
+
}
|
|
1158
|
+
);
|
|
1159
|
+
return verifyEnclaveAttestation(attestation, config.escrow.attestation, nonce);
|
|
1160
|
+
};
|
|
1161
|
+
|
|
1162
|
+
const enrollEscrow = async (
|
|
1163
|
+
token: string,
|
|
1164
|
+
providerType: AuthProviderType,
|
|
1165
|
+
privateKey: string,
|
|
1166
|
+
primaryDid: string,
|
|
1167
|
+
shares: SSSShares,
|
|
1168
|
+
shareVersion: number,
|
|
1169
|
+
signDidAuthVp?: DidAuthVpSigner,
|
|
1170
|
+
verifiedKey?: { publicKey: string; keyId: string },
|
|
1171
|
+
pinMaterial?: { pinSalt: string; pinVerifier: string },
|
|
1172
|
+
clearPin = false
|
|
1173
|
+
): Promise<void> => {
|
|
1174
|
+
if (!config.escrow?.enabled) throw new Error('Escrow enrollment is disabled');
|
|
1175
|
+
if (!signDidAuthVp) throw new Error('DID proof signing is required for escrow enrollment');
|
|
1176
|
+
const { publicKey, keyId } = verifiedKey ?? (await fetchVerifiedEnclaveKey());
|
|
1177
|
+
const envelope = await encryptEscrowBlob(
|
|
1178
|
+
{
|
|
1179
|
+
recoveryShare: shares.recoveryShare,
|
|
1180
|
+
did: primaryDid,
|
|
1181
|
+
shareVersion,
|
|
1182
|
+
...(pinMaterial ? { pinVerifier: pinMaterial.pinVerifier } : {}),
|
|
1183
|
+
},
|
|
1184
|
+
publicKey,
|
|
1185
|
+
keyId
|
|
1186
|
+
);
|
|
1187
|
+
const vp = await requestFreshDidAuthVp(
|
|
1188
|
+
serverUrl,
|
|
1189
|
+
privateKey,
|
|
1190
|
+
primaryDid,
|
|
1191
|
+
signDidAuthVp,
|
|
1192
|
+
tenantId
|
|
1193
|
+
);
|
|
1194
|
+
await escrowRequest('', {
|
|
1195
|
+
method: 'POST',
|
|
1196
|
+
headers: buildHeaders(token, vp, tenantId),
|
|
1197
|
+
body: JSON.stringify({
|
|
1198
|
+
authToken: token,
|
|
1199
|
+
providerType,
|
|
1200
|
+
envelope,
|
|
1201
|
+
shareVersion,
|
|
1202
|
+
enclaveKeyId: keyId,
|
|
1203
|
+
...(pinMaterial ? { pinSalt: pinMaterial.pinSalt } : {}),
|
|
1204
|
+
...(clearPin ? { clearPin: true } : {}),
|
|
1205
|
+
}),
|
|
1206
|
+
});
|
|
1207
|
+
};
|
|
1208
|
+
|
|
1209
|
+
const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<void> => {
|
|
1210
|
+
if (!config.escrow?.enabled) return;
|
|
1211
|
+
try {
|
|
1212
|
+
await enrollEscrow(...args);
|
|
1213
|
+
} catch (err) {
|
|
1214
|
+
// A reporting callback must not turn a successful rotation into a failure.
|
|
1215
|
+
try {
|
|
1216
|
+
if (config.onEscrowError) config.onEscrowError(err);
|
|
1217
|
+
else console.warn('SSS: escrow enrollment failed');
|
|
1218
|
+
} catch {
|
|
1219
|
+
console.warn('SSS: escrow error reporting failed');
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
|
|
1224
|
+
const ensureEscrowEnrollmentUnguarded = async (
|
|
1225
|
+
self: SSSKeyDerivationStrategy,
|
|
1226
|
+
params: Parameters<NonNullable<SSSKeyDerivationStrategy['ensureEscrowEnrollment']>>[0],
|
|
1227
|
+
storage: SSSStorageFunctions,
|
|
1228
|
+
forceRotate = false
|
|
1229
|
+
): Promise<EscrowEnrollmentResult> => {
|
|
1230
|
+
if (!config.escrow?.enabled) return { enrolled: false, reason: 'disabled' };
|
|
1231
|
+
const pin = params.options?.pin;
|
|
1232
|
+
if (pin !== undefined) {
|
|
1233
|
+
const validation = validatePin(pin);
|
|
1234
|
+
if (!validation.ok)
|
|
1235
|
+
throw new Error(
|
|
1236
|
+
validation.reason === 'length'
|
|
1237
|
+
? 'PIN must contain 6–12 digits.'
|
|
1238
|
+
: 'Choose a PIN without trivial or sequential patterns.'
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
const status = await self.fetchServerKeyStatus(params.token, params.providerType);
|
|
1242
|
+
if (status.escrowOptedOut) return { enrolled: false, reason: 'opted-out' };
|
|
1243
|
+
const currentlyEnrolled =
|
|
1244
|
+
status.shareVersion !== null &&
|
|
1245
|
+
status.recoveryMethods.some(
|
|
1246
|
+
method =>
|
|
1247
|
+
method.type === 'escrow' &&
|
|
1248
|
+
method.shareVersion === status.shareVersion &&
|
|
1249
|
+
(Boolean(method.confirmedAt) ||
|
|
1250
|
+
('confirmationStatus' in method &&
|
|
1251
|
+
method.confirmationStatus === 'confirmed'))
|
|
1252
|
+
);
|
|
1253
|
+
// A confirmed enrollment whose sealed blob no longer matches the active
|
|
1254
|
+
// enclave (P6 mode migration/rollback, or a rotated enclave key) still
|
|
1255
|
+
// needs the same rotation a missing enrollment would get — but only one
|
|
1256
|
+
// automatic attempt per session, so a still-unreachable enclave doesn't
|
|
1257
|
+
// retry on every ensureEscrowEnrollment call within the same session.
|
|
1258
|
+
const repairingStaleBlob =
|
|
1259
|
+
pin === undefined &&
|
|
1260
|
+
!forceRotate &&
|
|
1261
|
+
currentlyEnrolled &&
|
|
1262
|
+
!!status.escrowStale &&
|
|
1263
|
+
!staleEscrowReenrollAttempted;
|
|
1264
|
+
if (!forceRotate && pin === undefined && currentlyEnrolled && !repairingStaleBlob)
|
|
1265
|
+
// Automatic repair preserves current enrollment regardless of PIN status.
|
|
1266
|
+
// The enclave can carry an existing verifier when shares rotate.
|
|
1267
|
+
return { enrolled: true, changed: false };
|
|
1268
|
+
if (repairingStaleBlob) staleEscrowReenrollAttempted = true;
|
|
1269
|
+
if (!status.primaryDid) throw new Error('Cannot enroll escrow without a primary DID');
|
|
1270
|
+
if (!params.signDidAuthVp) {
|
|
1271
|
+
throw new Error('DID proof signing is required for escrow enrollment');
|
|
1272
|
+
}
|
|
1273
|
+
try {
|
|
1274
|
+
if (
|
|
1275
|
+
!forceRotate &&
|
|
1276
|
+
pin === undefined &&
|
|
1277
|
+
unenrolledRotation &&
|
|
1278
|
+
unenrolledRotation.shareVersion === status.shareVersion &&
|
|
1279
|
+
unenrolledRotation.primaryDid === status.primaryDid
|
|
1280
|
+
) {
|
|
1281
|
+
const retry = unenrolledRotation;
|
|
1282
|
+
await enrollEscrow(
|
|
1283
|
+
params.token,
|
|
1284
|
+
params.providerType,
|
|
1285
|
+
params.privateKey,
|
|
1286
|
+
retry.primaryDid,
|
|
1287
|
+
retry.shares,
|
|
1288
|
+
retry.shareVersion,
|
|
1289
|
+
params.signDidAuthVp,
|
|
1290
|
+
undefined,
|
|
1291
|
+
undefined,
|
|
1292
|
+
retry.clearPin
|
|
1293
|
+
);
|
|
1294
|
+
unenrolledRotation = undefined;
|
|
1295
|
+
return { enrolled: true, changed: true, shareVersion: retry.shareVersion };
|
|
1296
|
+
}
|
|
1297
|
+
// Verify the enclave before rotating: a bad attestation must not burn
|
|
1298
|
+
// a share version (and, repeated, the retained auth-share history).
|
|
1299
|
+
const verifiedKey = await fetchVerifiedEnclaveKey();
|
|
1300
|
+
const pinSalt = pin !== undefined ? generatePinSalt() : undefined;
|
|
1301
|
+
const pinMaterial =
|
|
1302
|
+
pin !== undefined && pinSalt !== undefined
|
|
1303
|
+
? { pinSalt, pinVerifier: await derivePinProof(pin, pinSalt) }
|
|
1304
|
+
: undefined;
|
|
1305
|
+
const storageId = activeStorageId;
|
|
1306
|
+
const primaryDid = status.primaryDid;
|
|
1307
|
+
const { shares, shareVersion } = await withShareUpdateLock(storage, storageId, () =>
|
|
1308
|
+
persistSharesWithoutLock(
|
|
1309
|
+
params.privateKey,
|
|
1310
|
+
serverUrl,
|
|
1311
|
+
params.token,
|
|
1312
|
+
params.providerType,
|
|
1313
|
+
primaryDid,
|
|
1314
|
+
storage,
|
|
1315
|
+
storageId,
|
|
1316
|
+
undefined,
|
|
1317
|
+
params.signDidAuthVp,
|
|
1318
|
+
tenantId
|
|
1319
|
+
)
|
|
1320
|
+
);
|
|
1321
|
+
lastEmailShare = shares.emailShare;
|
|
1322
|
+
lastShareVersion = shareVersion;
|
|
1323
|
+
lastServerSnapshot = {
|
|
1324
|
+
currentVersion: shareVersion,
|
|
1325
|
+
resolvedVersion: shareVersion,
|
|
1326
|
+
authShare: shares.authShare,
|
|
1327
|
+
primaryDid: status.primaryDid,
|
|
1328
|
+
};
|
|
1329
|
+
unenrolledRotation = pinMaterial
|
|
1330
|
+
? undefined
|
|
1331
|
+
: { shares, shareVersion, primaryDid: status.primaryDid, clearPin: forceRotate };
|
|
1332
|
+
await enrollEscrow(
|
|
1333
|
+
params.token,
|
|
1334
|
+
params.providerType,
|
|
1335
|
+
params.privateKey,
|
|
1336
|
+
status.primaryDid,
|
|
1337
|
+
shares,
|
|
1338
|
+
shareVersion,
|
|
1339
|
+
params.signDidAuthVp,
|
|
1340
|
+
verifiedKey,
|
|
1341
|
+
pinMaterial,
|
|
1342
|
+
forceRotate
|
|
1343
|
+
);
|
|
1344
|
+
unenrolledRotation = undefined;
|
|
1345
|
+
return { enrolled: true, changed: true, shareVersion };
|
|
1346
|
+
} catch (err) {
|
|
1347
|
+
// A background repair the caller never asked for must not surface as
|
|
1348
|
+
// a hard failure (mirrors tryEnrollEscrow's existing swallow-and-report
|
|
1349
|
+
// shape); an explicit not-yet-enrolled/PIN/forced rotation still throws.
|
|
1350
|
+
if (!repairingStaleBlob) throw err;
|
|
1351
|
+
try {
|
|
1352
|
+
if (config.onEscrowError) config.onEscrowError(err);
|
|
1353
|
+
else console.warn('SSS: escrow enrollment failed');
|
|
1354
|
+
} catch {
|
|
1355
|
+
console.warn('SSS: escrow error reporting failed');
|
|
1356
|
+
}
|
|
1357
|
+
return { enrolled: true, changed: false };
|
|
1358
|
+
}
|
|
1359
|
+
};
|
|
1360
|
+
|
|
1361
|
+
const recordPromotion =
|
|
1362
|
+
(primaryDid: string) =>
|
|
1363
|
+
(snapshot: CurrentShareSnapshot): void => {
|
|
1364
|
+
// A device-only candidate cannot supply the email share of its split.
|
|
1365
|
+
lastEmailShare = undefined;
|
|
1366
|
+
lastShareVersion = snapshot.currentVersion ?? undefined;
|
|
1367
|
+
lastServerSnapshot = {
|
|
1368
|
+
...snapshot,
|
|
1369
|
+
resolvedVersion: snapshot.currentVersion,
|
|
1370
|
+
primaryDid,
|
|
1371
|
+
};
|
|
1372
|
+
};
|
|
1373
|
+
|
|
1374
|
+
const captureActiveUserGuard = (): (() => void) => {
|
|
1375
|
+
const storageId = activeStorageId;
|
|
1376
|
+
return () => {
|
|
1377
|
+
if (activeStorageId !== storageId) {
|
|
1378
|
+
throw new AtomicUpdateError(
|
|
1379
|
+
'Active account changed during share update',
|
|
1380
|
+
'verify_stored',
|
|
1381
|
+
false
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
};
|
|
1385
|
+
};
|
|
403
1386
|
|
|
404
1387
|
return {
|
|
405
1388
|
name: 'sss',
|
|
@@ -414,13 +1397,18 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
414
1397
|
// --- User scoping ---
|
|
415
1398
|
|
|
416
1399
|
setActiveUser(userId: string): void {
|
|
1400
|
+
if (activeStorageId !== `sss-device-share:${userId}`) storageGeneration++;
|
|
417
1401
|
activeStorageId = `sss-device-share:${userId}`;
|
|
1402
|
+
lastServerSnapshot = null;
|
|
1403
|
+
pendingPhraseConfirmation = undefined;
|
|
1404
|
+
pendingBackupConfirmation = undefined;
|
|
418
1405
|
},
|
|
419
1406
|
|
|
420
1407
|
// --- Key lifecycle ---
|
|
421
1408
|
|
|
422
1409
|
async hasLocalKey(): Promise<boolean> {
|
|
423
1410
|
if (await storage.hasDeviceShare(activeStorageId)) return true;
|
|
1411
|
+
if ((await readPendingShareCandidates(storage, activeStorageId)).length) return true;
|
|
424
1412
|
|
|
425
1413
|
// Fallback: check legacy unscoped key for shares stored before per-user scoping
|
|
426
1414
|
if (activeStorageId) {
|
|
@@ -431,10 +1419,14 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
431
1419
|
},
|
|
432
1420
|
|
|
433
1421
|
async getLocalKey(): Promise<string | null> {
|
|
1422
|
+
const storage = guardedStorage();
|
|
434
1423
|
const scoped = await storage.getDeviceShare(activeStorageId);
|
|
435
1424
|
|
|
436
1425
|
if (scoped) return scoped;
|
|
437
1426
|
|
|
1427
|
+
const pending = await readPendingShareCandidates(storage, activeStorageId);
|
|
1428
|
+
if (pending.length) return pending[pending.length - 1]!.share;
|
|
1429
|
+
|
|
438
1430
|
// Fallback: try legacy unscoped key and auto-migrate if found
|
|
439
1431
|
if (activeStorageId) {
|
|
440
1432
|
const legacy = await storage.getDeviceShare();
|
|
@@ -454,8 +1446,21 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
454
1446
|
return storage.storeDeviceShare(key, activeStorageId);
|
|
455
1447
|
},
|
|
456
1448
|
|
|
457
|
-
async clearLocalKeys(): Promise<void> {
|
|
458
|
-
|
|
1449
|
+
async clearLocalKeys(options?: { preservePending?: boolean }): Promise<void> {
|
|
1450
|
+
const storageId = activeStorageId;
|
|
1451
|
+
if (options?.preservePending) {
|
|
1452
|
+
const storage = guardedStorage();
|
|
1453
|
+
// Retain unresolved candidates without racing another share update
|
|
1454
|
+
// or invalidating its generation. Never wipe unscoped storage here.
|
|
1455
|
+
return withShareUpdateLock(storage, storageId, () =>
|
|
1456
|
+
storage.deleteDeviceShare(storageId)
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
storageGeneration++;
|
|
1461
|
+
await Promise.allSettled(localWrites);
|
|
1462
|
+
await clearPendingShareCandidates(storage, storageId);
|
|
1463
|
+
return storage.clearAllShares(storageId);
|
|
459
1464
|
},
|
|
460
1465
|
|
|
461
1466
|
async splitKey(privateKey: string): Promise<{ localKey: string; remoteKey: string }> {
|
|
@@ -492,12 +1497,219 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
492
1497
|
return result.healthy;
|
|
493
1498
|
},
|
|
494
1499
|
|
|
1500
|
+
async getFreshDidAuthVp(
|
|
1501
|
+
privateKey: string,
|
|
1502
|
+
did: string,
|
|
1503
|
+
signDidAuthVp: DidAuthVpSigner
|
|
1504
|
+
): Promise<string> {
|
|
1505
|
+
return requestFreshDidAuthVp(serverUrl, privateKey, did, signDidAuthVp, tenantId);
|
|
1506
|
+
},
|
|
1507
|
+
|
|
1508
|
+
async atomicUpdateShares(params): Promise<void> {
|
|
1509
|
+
const storage = guardedStorage();
|
|
1510
|
+
const result = await persistSharesAtomically(
|
|
1511
|
+
params.privateKey,
|
|
1512
|
+
serverUrl,
|
|
1513
|
+
params.token,
|
|
1514
|
+
params.providerType,
|
|
1515
|
+
params.did,
|
|
1516
|
+
storage,
|
|
1517
|
+
activeStorageId,
|
|
1518
|
+
undefined,
|
|
1519
|
+
params.signDidAuthVp,
|
|
1520
|
+
tenantId,
|
|
1521
|
+
recordPromotion(params.did),
|
|
1522
|
+
captureActiveUserGuard()
|
|
1523
|
+
);
|
|
1524
|
+
|
|
1525
|
+
lastEmailShare = result.shares.emailShare;
|
|
1526
|
+
lastShareVersion = result.shareVersion;
|
|
1527
|
+
lastServerSnapshot = {
|
|
1528
|
+
currentVersion: result.shareVersion,
|
|
1529
|
+
resolvedVersion: result.shareVersion,
|
|
1530
|
+
authShare: result.shares.authShare,
|
|
1531
|
+
primaryDid: params.did,
|
|
1532
|
+
};
|
|
1533
|
+
},
|
|
1534
|
+
|
|
1535
|
+
async reconcileShares(params): Promise<RecoveryResult | null> {
|
|
1536
|
+
const storage = guardedStorage();
|
|
1537
|
+
const storageId = activeStorageId;
|
|
1538
|
+
const assertActiveUser = (): void => {
|
|
1539
|
+
if (activeStorageId !== storageId) {
|
|
1540
|
+
throw new AtomicUpdateError(
|
|
1541
|
+
'Active account changed during reconciliation',
|
|
1542
|
+
'verify_stored',
|
|
1543
|
+
false
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
};
|
|
1547
|
+
return withShareUpdateLock(storage, storageId, async () => {
|
|
1548
|
+
let deviceShare = await storage.getDeviceShare(storageId);
|
|
1549
|
+
const pending = await readPendingShareCandidates(storage, storageId);
|
|
1550
|
+
|
|
1551
|
+
if (!deviceShare && !pending.length) return null;
|
|
1552
|
+
|
|
1553
|
+
const resolution = await resolvePendingShareCandidates(
|
|
1554
|
+
storage,
|
|
1555
|
+
storageId,
|
|
1556
|
+
async () => {
|
|
1557
|
+
const data = await fetchAuthShareRaw(
|
|
1558
|
+
serverUrl,
|
|
1559
|
+
params.token,
|
|
1560
|
+
params.providerType,
|
|
1561
|
+
undefined,
|
|
1562
|
+
tenantId
|
|
1563
|
+
);
|
|
1564
|
+
return {
|
|
1565
|
+
currentVersion: data?.shareVersion ?? null,
|
|
1566
|
+
authShare: authShareToString(data?.authShare),
|
|
1567
|
+
};
|
|
1568
|
+
},
|
|
1569
|
+
params.expectedDid,
|
|
1570
|
+
params.didFromPrivateKey,
|
|
1571
|
+
assertActiveUser,
|
|
1572
|
+
recordPromotion(params.expectedDid),
|
|
1573
|
+
async version =>
|
|
1574
|
+
authShareToString(
|
|
1575
|
+
(
|
|
1576
|
+
await fetchAuthShareRaw(
|
|
1577
|
+
serverUrl,
|
|
1578
|
+
params.token,
|
|
1579
|
+
params.providerType,
|
|
1580
|
+
version,
|
|
1581
|
+
tenantId
|
|
1582
|
+
)
|
|
1583
|
+
)?.authShare
|
|
1584
|
+
)
|
|
1585
|
+
);
|
|
1586
|
+
assertActiveUser();
|
|
1587
|
+
if (resolution.privateKey) {
|
|
1588
|
+
return { privateKey: resolution.privateKey, did: params.expectedDid };
|
|
1589
|
+
}
|
|
1590
|
+
const { currentVersion, authShare: currentAuthShare } = resolution.snapshot;
|
|
1591
|
+
if (currentVersion === null && !currentAuthShare) return null;
|
|
1592
|
+
|
|
1593
|
+
// Resolution may have recovered a historical candidate without a main share.
|
|
1594
|
+
deviceShare = await storage.getDeviceShare(storageId);
|
|
1595
|
+
const localVersion = await storage.getShareVersion(storageId);
|
|
1596
|
+
if (!deviceShare) return null;
|
|
1597
|
+
const candidates = new Map<number, string>();
|
|
1598
|
+
|
|
1599
|
+
const addCandidate = (version: number | null, authShare: string | null): void => {
|
|
1600
|
+
if (version !== null && authShare && !candidates.has(version)) {
|
|
1601
|
+
candidates.set(version, authShare);
|
|
1602
|
+
}
|
|
1603
|
+
};
|
|
1604
|
+
|
|
1605
|
+
addCandidate(
|
|
1606
|
+
lastServerSnapshot?.resolvedVersion ?? null,
|
|
1607
|
+
lastServerSnapshot?.authShare ?? null
|
|
1608
|
+
);
|
|
1609
|
+
addCandidate(currentVersion, currentAuthShare);
|
|
1610
|
+
|
|
1611
|
+
const versionsToTry = new Set<number>();
|
|
1612
|
+
if (localVersion !== null) versionsToTry.add(localVersion);
|
|
1613
|
+
|
|
1614
|
+
if (currentVersion !== null) {
|
|
1615
|
+
for (
|
|
1616
|
+
let version = currentVersion;
|
|
1617
|
+
version >= 1 && version >= currentVersion - MAX_RECONCILIATION_HISTORY;
|
|
1618
|
+
version--
|
|
1619
|
+
) {
|
|
1620
|
+
versionsToTry.add(version);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
for (const version of versionsToTry) {
|
|
1625
|
+
if (candidates.has(version)) continue;
|
|
1626
|
+
|
|
1627
|
+
const data = await fetchAuthShareRaw(
|
|
1628
|
+
serverUrl,
|
|
1629
|
+
params.token,
|
|
1630
|
+
params.providerType,
|
|
1631
|
+
version,
|
|
1632
|
+
tenantId
|
|
1633
|
+
);
|
|
1634
|
+
|
|
1635
|
+
addCandidate(version, authShareToString(data?.authShare));
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
for (const [candidateVersion, authShare] of candidates) {
|
|
1639
|
+
const health = await verifyStoredShares(
|
|
1640
|
+
{
|
|
1641
|
+
getDevice: async () => deviceShare,
|
|
1642
|
+
getAuth: async () => authShare,
|
|
1643
|
+
},
|
|
1644
|
+
params.expectedDid,
|
|
1645
|
+
params.didFromPrivateKey
|
|
1646
|
+
);
|
|
1647
|
+
|
|
1648
|
+
if (!health.healthy) continue;
|
|
1649
|
+
|
|
1650
|
+
assertActiveUser();
|
|
1651
|
+
|
|
1652
|
+
const privateKey = await reconstructFromShares([deviceShare, authShare]);
|
|
1653
|
+
|
|
1654
|
+
if (candidateVersion === currentVersion) {
|
|
1655
|
+
if (localVersion !== currentVersion) {
|
|
1656
|
+
await storage.storeShareVersion(candidateVersion, storageId);
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
assertActiveUser();
|
|
1660
|
+
lastServerSnapshot = {
|
|
1661
|
+
currentVersion,
|
|
1662
|
+
resolvedVersion: candidateVersion,
|
|
1663
|
+
authShare,
|
|
1664
|
+
primaryDid: params.expectedDid,
|
|
1665
|
+
};
|
|
1666
|
+
|
|
1667
|
+
return localVersion === currentVersion
|
|
1668
|
+
? null
|
|
1669
|
+
: { privateKey, did: params.expectedDid };
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// The local device share only matches a historical server share.
|
|
1673
|
+
// Re-split and atomically advance both stores to one fresh version.
|
|
1674
|
+
const result = await persistSharesWithoutLock(
|
|
1675
|
+
privateKey,
|
|
1676
|
+
serverUrl,
|
|
1677
|
+
params.token,
|
|
1678
|
+
params.providerType,
|
|
1679
|
+
params.expectedDid,
|
|
1680
|
+
storage,
|
|
1681
|
+
storageId,
|
|
1682
|
+
params.didFromPrivateKey,
|
|
1683
|
+
params.signDidAuthVp,
|
|
1684
|
+
tenantId,
|
|
1685
|
+
recordPromotion(params.expectedDid),
|
|
1686
|
+
assertActiveUser
|
|
1687
|
+
);
|
|
1688
|
+
|
|
1689
|
+
assertActiveUser();
|
|
1690
|
+
lastEmailShare = result.shares.emailShare;
|
|
1691
|
+
lastShareVersion = result.shareVersion;
|
|
1692
|
+
lastServerSnapshot = {
|
|
1693
|
+
currentVersion: result.shareVersion,
|
|
1694
|
+
resolvedVersion: result.shareVersion,
|
|
1695
|
+
authShare: result.shares.authShare,
|
|
1696
|
+
primaryDid: params.expectedDid,
|
|
1697
|
+
};
|
|
1698
|
+
|
|
1699
|
+
return { privateKey, did: params.expectedDid };
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
return null;
|
|
1703
|
+
});
|
|
1704
|
+
},
|
|
1705
|
+
|
|
495
1706
|
// --- Server communication ---
|
|
496
1707
|
|
|
497
1708
|
async fetchServerKeyStatus(
|
|
498
1709
|
token: string,
|
|
499
1710
|
providerType: AuthProviderType
|
|
500
1711
|
): Promise<ServerKeyStatus> {
|
|
1712
|
+
const storage = guardedStorage();
|
|
501
1713
|
// Pass the local device share's version so the server returns the matching auth share
|
|
502
1714
|
const localVersion = await storage.getShareVersion(activeStorageId);
|
|
503
1715
|
|
|
@@ -510,9 +1722,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
510
1722
|
);
|
|
511
1723
|
|
|
512
1724
|
if (!data) {
|
|
513
|
-
|
|
514
|
-
// Prevents stale state from a previous user session.
|
|
515
|
-
hasRecoveryEmail = false;
|
|
1725
|
+
lastServerSnapshot = null;
|
|
516
1726
|
|
|
517
1727
|
return {
|
|
518
1728
|
exists: false,
|
|
@@ -522,32 +1732,36 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
522
1732
|
authShare: null,
|
|
523
1733
|
shareVersion: null,
|
|
524
1734
|
maskedRecoveryEmail: null,
|
|
1735
|
+
sssActivationState: null,
|
|
525
1736
|
};
|
|
526
1737
|
}
|
|
527
1738
|
|
|
528
1739
|
const rawAuthShare = data.authShare;
|
|
529
|
-
|
|
530
|
-
const authShareString =
|
|
531
|
-
typeof rawAuthShare === 'object' && rawAuthShare !== null
|
|
532
|
-
? rawAuthShare.encryptedData ?? null
|
|
533
|
-
: typeof rawAuthShare === 'string'
|
|
534
|
-
? rawAuthShare
|
|
535
|
-
: null;
|
|
1740
|
+
const authShareString = authShareToString(rawAuthShare);
|
|
536
1741
|
|
|
537
1742
|
const serverVersion = data.shareVersion ?? null;
|
|
538
1743
|
|
|
539
1744
|
// Version repair: if the server knows the version but local storage
|
|
540
1745
|
// doesn't (e.g. account created before versioning was added), backfill
|
|
541
1746
|
// it so QR device-link transfers always include the version.
|
|
542
|
-
if (
|
|
1747
|
+
if (
|
|
1748
|
+
// Version 0 means "no auth share yet" and must never be pinned locally.
|
|
1749
|
+
serverVersion != null &&
|
|
1750
|
+
serverVersion > 0 &&
|
|
1751
|
+
localVersion == null &&
|
|
1752
|
+
!(await readPendingShareCandidates(storage, activeStorageId)).length
|
|
1753
|
+
) {
|
|
543
1754
|
storage
|
|
544
1755
|
.storeShareVersion(serverVersion, activeStorageId)
|
|
545
1756
|
.catch(e => console.warn('SSS: failed to backfill local shareVersion', e));
|
|
546
1757
|
}
|
|
547
1758
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
1759
|
+
lastServerSnapshot = {
|
|
1760
|
+
currentVersion: serverVersion,
|
|
1761
|
+
resolvedVersion: authShareString ? (localVersion ?? serverVersion) : null,
|
|
1762
|
+
authShare: authShareString,
|
|
1763
|
+
primaryDid: data.primaryDid || null,
|
|
1764
|
+
};
|
|
551
1765
|
|
|
552
1766
|
return {
|
|
553
1767
|
exists: !!rawAuthShare || !!data.keyProvider || !!data.primaryDid,
|
|
@@ -557,6 +1771,10 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
557
1771
|
authShare: authShareString,
|
|
558
1772
|
shareVersion: serverVersion,
|
|
559
1773
|
maskedRecoveryEmail: data.maskedRecoveryEmail ?? null,
|
|
1774
|
+
escrowOptedOut: data.escrowOptedOut === true,
|
|
1775
|
+
escrowPin: data.escrowPin,
|
|
1776
|
+
escrowStale: data.escrowStale,
|
|
1777
|
+
sssActivationState: data.sssActivationState ?? 'active',
|
|
560
1778
|
};
|
|
561
1779
|
},
|
|
562
1780
|
|
|
@@ -567,49 +1785,329 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
567
1785
|
primaryDid: string,
|
|
568
1786
|
didAuthVp?: string
|
|
569
1787
|
): Promise<void> {
|
|
570
|
-
const
|
|
1788
|
+
const storage = guardedStorage();
|
|
1789
|
+
const { shareVersion } = await withRotationLock(() =>
|
|
1790
|
+
putAuthShare(
|
|
1791
|
+
serverUrl,
|
|
1792
|
+
token,
|
|
1793
|
+
providerType,
|
|
1794
|
+
authShare,
|
|
1795
|
+
primaryDid,
|
|
1796
|
+
didAuthVp,
|
|
1797
|
+
tenantId
|
|
1798
|
+
)
|
|
1799
|
+
);
|
|
1800
|
+
|
|
1801
|
+
// Persist the version alongside the device share so we can request
|
|
1802
|
+
// the matching auth share on next login.
|
|
1803
|
+
await storage.storeShareVersion(shareVersion, activeStorageId);
|
|
1804
|
+
|
|
1805
|
+
// Cache for the upcoming sendEmailBackupShare call
|
|
1806
|
+
lastShareVersion = shareVersion;
|
|
1807
|
+
},
|
|
1808
|
+
|
|
1809
|
+
async markMigrated(
|
|
1810
|
+
token: string,
|
|
1811
|
+
providerType: AuthProviderType,
|
|
1812
|
+
didAuthVp?: string
|
|
1813
|
+
): Promise<void> {
|
|
1814
|
+
const response = await fetch(`${serverUrl}/keys/migrate`, {
|
|
1815
|
+
method: 'POST',
|
|
1816
|
+
headers: buildHeaders(token, didAuthVp, tenantId),
|
|
1817
|
+
body: JSON.stringify({ authToken: token, providerType }),
|
|
1818
|
+
});
|
|
1819
|
+
|
|
1820
|
+
if (!response.ok) {
|
|
1821
|
+
throw new Error(`Failed to mark migrated: ${describeHttpStatus(response)}`);
|
|
1822
|
+
}
|
|
1823
|
+
},
|
|
1824
|
+
|
|
1825
|
+
async activate(
|
|
1826
|
+
token: string,
|
|
1827
|
+
providerType: AuthProviderType,
|
|
1828
|
+
didAuthVp?: string
|
|
1829
|
+
): Promise<void> {
|
|
1830
|
+
const response = await fetch(`${serverUrl}/keys/activate`, {
|
|
1831
|
+
method: 'POST',
|
|
1832
|
+
headers: buildHeaders(token, didAuthVp, tenantId),
|
|
1833
|
+
body: JSON.stringify({ authToken: token, providerType }),
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
if (!response.ok) {
|
|
1837
|
+
throw new Error(`Failed to activate SSS key: ${describeHttpStatus(response)}`);
|
|
1838
|
+
}
|
|
1839
|
+
},
|
|
1840
|
+
|
|
1841
|
+
// --- Recovery execution ---
|
|
1842
|
+
|
|
1843
|
+
async getEscrowEnrollmentState(params): Promise<EscrowEnrollmentState> {
|
|
1844
|
+
if (!config.escrow?.enabled) return { state: 'disabled' };
|
|
1845
|
+
const status = await this.fetchServerKeyStatus(params.token, params.providerType);
|
|
1846
|
+
if (status.escrowOptedOut) return { state: 'opted-out', escrowPin: status.escrowPin };
|
|
1847
|
+
const confirmedEscrow = status.recoveryMethods.filter(
|
|
1848
|
+
method =>
|
|
1849
|
+
method.type === 'escrow' &&
|
|
1850
|
+
(Boolean(method.confirmedAt) ||
|
|
1851
|
+
('confirmationStatus' in method &&
|
|
1852
|
+
method.confirmationStatus === 'confirmed'))
|
|
1853
|
+
);
|
|
1854
|
+
const enrolled =
|
|
1855
|
+
status.shareVersion !== null &&
|
|
1856
|
+
confirmedEscrow.some(method => method.shareVersion === status.shareVersion);
|
|
1857
|
+
// Previously enrolled accounts must bypass the new-enrollment rollout
|
|
1858
|
+
// even when a rotation succeeded but resealing the escrow blob failed.
|
|
1859
|
+
if (confirmedEscrow.length > 0 && (!enrolled || status.escrowStale))
|
|
1860
|
+
return {
|
|
1861
|
+
state: 'stale',
|
|
1862
|
+
escrowPin: status.escrowPin,
|
|
1863
|
+
staleReason: status.escrowStale,
|
|
1864
|
+
};
|
|
1865
|
+
return { state: enrolled ? 'enrolled' : 'not-enrolled', escrowPin: status.escrowPin };
|
|
1866
|
+
},
|
|
1867
|
+
|
|
1868
|
+
async disableEscrowRecovery(params): Promise<void> {
|
|
1869
|
+
const status = await this.fetchServerKeyStatus(params.token, params.providerType);
|
|
1870
|
+
if (!status.primaryDid) throw new Error('Cannot disable escrow without a primary DID');
|
|
1871
|
+
const vp = await requestFreshDidAuthVp(
|
|
571
1872
|
serverUrl,
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
primaryDid,
|
|
576
|
-
didAuthVp,
|
|
1873
|
+
params.privateKey,
|
|
1874
|
+
status.primaryDid,
|
|
1875
|
+
params.signDidAuthVp,
|
|
577
1876
|
tenantId
|
|
578
1877
|
);
|
|
1878
|
+
await escrowRequest('', {
|
|
1879
|
+
method: 'DELETE',
|
|
1880
|
+
headers: buildHeaders(params.token, vp, tenantId),
|
|
1881
|
+
body: JSON.stringify({
|
|
1882
|
+
authToken: params.token,
|
|
1883
|
+
providerType: params.providerType,
|
|
1884
|
+
optOut: true,
|
|
1885
|
+
}),
|
|
1886
|
+
});
|
|
1887
|
+
},
|
|
1888
|
+
|
|
1889
|
+
async enableEscrowRecovery(params) {
|
|
1890
|
+
const storage = guardedStorage();
|
|
1891
|
+
return withRotationLock(async () => {
|
|
1892
|
+
if (!config.escrow?.enabled)
|
|
1893
|
+
return { enrolled: false as const, reason: 'disabled' as const };
|
|
1894
|
+
const status = await this.fetchServerKeyStatus(params.token, params.providerType);
|
|
1895
|
+
if (!status.primaryDid)
|
|
1896
|
+
throw new Error('Cannot enable escrow without a primary DID');
|
|
1897
|
+
const vp = await requestFreshDidAuthVp(
|
|
1898
|
+
serverUrl,
|
|
1899
|
+
params.privateKey,
|
|
1900
|
+
status.primaryDid,
|
|
1901
|
+
params.signDidAuthVp,
|
|
1902
|
+
tenantId
|
|
1903
|
+
);
|
|
1904
|
+
await escrowRequest('/opt-in', {
|
|
1905
|
+
method: 'POST',
|
|
1906
|
+
headers: buildHeaders(params.token, vp, tenantId),
|
|
1907
|
+
body: JSON.stringify({
|
|
1908
|
+
authToken: params.token,
|
|
1909
|
+
providerType: params.providerType,
|
|
1910
|
+
}),
|
|
1911
|
+
});
|
|
1912
|
+
return ensureEscrowEnrollmentUnguarded(this, params, storage);
|
|
1913
|
+
});
|
|
1914
|
+
},
|
|
1915
|
+
|
|
1916
|
+
async setEscrowPin({ pin, ...params }): Promise<void> {
|
|
1917
|
+
const storage = guardedStorage();
|
|
1918
|
+
const result = await withRotationLock(() =>
|
|
1919
|
+
ensureEscrowEnrollmentUnguarded(this, { ...params, options: { pin } }, storage)
|
|
1920
|
+
);
|
|
1921
|
+
if (!result.enrolled || !result.changed) {
|
|
1922
|
+
throw new Error('Automatic recovery is not available for this account.');
|
|
1923
|
+
}
|
|
1924
|
+
},
|
|
1925
|
+
|
|
1926
|
+
async clearEscrowPin(params): Promise<void> {
|
|
1927
|
+
const storage = guardedStorage();
|
|
1928
|
+
const result = await withRotationLock(() =>
|
|
1929
|
+
ensureEscrowEnrollmentUnguarded(this, params, storage, true)
|
|
1930
|
+
);
|
|
1931
|
+
if (!result.enrolled || !result.changed) {
|
|
1932
|
+
throw new Error('Automatic recovery is not available for this account.');
|
|
1933
|
+
}
|
|
1934
|
+
},
|
|
1935
|
+
|
|
1936
|
+
/** Enroll missing/stale escrow material using the standard atomic rotation. */
|
|
1937
|
+
async ensureEscrowEnrollment(params): Promise<EscrowEnrollmentResult> {
|
|
1938
|
+
const storage = guardedStorage();
|
|
1939
|
+
if (params.options?.pin !== undefined)
|
|
1940
|
+
return withRotationLock(() =>
|
|
1941
|
+
ensureEscrowEnrollmentUnguarded(this, params, storage)
|
|
1942
|
+
);
|
|
1943
|
+
if (escrowEnrollmentInFlight) return escrowEnrollmentInFlight;
|
|
1944
|
+
escrowEnrollmentInFlight = withRotationLock(() =>
|
|
1945
|
+
ensureEscrowEnrollmentUnguarded(this, params, storage)
|
|
1946
|
+
);
|
|
1947
|
+
try {
|
|
1948
|
+
return await escrowEnrollmentInFlight;
|
|
1949
|
+
} finally {
|
|
1950
|
+
escrowEnrollmentInFlight = undefined;
|
|
1951
|
+
}
|
|
1952
|
+
},
|
|
1953
|
+
|
|
1954
|
+
/** Start or explicitly restart a hold with fresh client-bound resume secrets. */
|
|
1955
|
+
async startEscrowRecovery(params): Promise<EscrowRecoveryStart> {
|
|
1956
|
+
const sessionProof = params.recoverySessionToken !== undefined;
|
|
1957
|
+
if (
|
|
1958
|
+
sessionProof
|
|
1959
|
+
? params.token !== undefined || params.providerType !== undefined
|
|
1960
|
+
: !params.token || !params.providerType
|
|
1961
|
+
) {
|
|
1962
|
+
throw new Error('Provide exactly one identity proof');
|
|
1963
|
+
}
|
|
1964
|
+
const pair = await generateEscrowKeyPair();
|
|
1965
|
+
const result = await escrowRequest<
|
|
1966
|
+
Omit<EscrowRecoveryStart, 'clientEphemeralPrivateKey'>
|
|
1967
|
+
>(
|
|
1968
|
+
'/recover',
|
|
1969
|
+
{
|
|
1970
|
+
method: 'POST',
|
|
1971
|
+
headers: buildHeaders('', undefined, params.tenantId ?? tenantId),
|
|
1972
|
+
body: JSON.stringify({
|
|
1973
|
+
clientEphemeralPublicKey: pair.publicKey,
|
|
1974
|
+
...(params.options?.restart ? { restart: true } : {}),
|
|
1975
|
+
...(params.options?.releasePolicy
|
|
1976
|
+
? { releasePolicy: params.options.releasePolicy }
|
|
1977
|
+
: {}),
|
|
1978
|
+
...(sessionProof
|
|
1979
|
+
? { recoverySessionToken: params.recoverySessionToken }
|
|
1980
|
+
: { authToken: params.token, providerType: params.providerType }),
|
|
1981
|
+
}),
|
|
1982
|
+
},
|
|
1983
|
+
params.options?.releasePolicy === 'pin'
|
|
1984
|
+
);
|
|
1985
|
+
return { ...result, clientEphemeralPrivateKey: pair.privateKey };
|
|
1986
|
+
},
|
|
579
1987
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
1988
|
+
/** Read hold status; secrets (auth or resume token) never travel in a URL. */
|
|
1989
|
+
async getEscrowRecoveryStatus(params): Promise<EscrowHoldStatus | null> {
|
|
1990
|
+
const query = new URLSearchParams(
|
|
1991
|
+
'holdId' in params
|
|
1992
|
+
? { holdId: params.holdId }
|
|
1993
|
+
: { providerType: params.providerType }
|
|
1994
|
+
);
|
|
1995
|
+
const secret = 'holdId' in params ? params.resumeToken : params.token;
|
|
1996
|
+
const result = await escrowRequest<{ hold: EscrowHoldStatus | null }>(
|
|
1997
|
+
`/status?${query}`,
|
|
1998
|
+
{
|
|
1999
|
+
method: 'GET',
|
|
2000
|
+
headers: {
|
|
2001
|
+
...buildHeaders('', undefined, tenantId),
|
|
2002
|
+
'X-Auth-Token': secret,
|
|
2003
|
+
},
|
|
2004
|
+
}
|
|
2005
|
+
);
|
|
2006
|
+
return result.hold;
|
|
586
2007
|
},
|
|
587
2008
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
2009
|
+
/** Cancel a pending hold with a fresh DID-owner proof. */
|
|
2010
|
+
async cancelEscrowRecovery(params): Promise<{ cancelled: boolean }> {
|
|
2011
|
+
const status = await this.fetchServerKeyStatus(params.token, params.providerType);
|
|
2012
|
+
if (!status.primaryDid) throw new Error('Cannot cancel escrow without a primary DID');
|
|
2013
|
+
const vp = await requestFreshDidAuthVp(
|
|
2014
|
+
serverUrl,
|
|
2015
|
+
params.privateKey,
|
|
2016
|
+
status.primaryDid,
|
|
2017
|
+
params.signDidAuthVp,
|
|
2018
|
+
tenantId
|
|
2019
|
+
);
|
|
2020
|
+
const result = await escrowRequest<{ cancelled: boolean }>('/cancel', {
|
|
594
2021
|
method: 'POST',
|
|
595
|
-
headers: buildHeaders(token,
|
|
596
|
-
body: JSON.stringify({
|
|
2022
|
+
headers: buildHeaders(params.token, vp, tenantId),
|
|
2023
|
+
body: JSON.stringify({
|
|
2024
|
+
authToken: params.token,
|
|
2025
|
+
providerType: params.providerType,
|
|
2026
|
+
}),
|
|
597
2027
|
});
|
|
598
|
-
|
|
599
|
-
if (!response.ok) {
|
|
600
|
-
throw new Error(`Failed to mark migrated: ${response.statusText}`);
|
|
601
|
-
}
|
|
2028
|
+
return { cancelled: result.cancelled };
|
|
602
2029
|
},
|
|
603
2030
|
|
|
604
|
-
// --- Recovery execution ---
|
|
605
|
-
|
|
606
2031
|
async executeRecovery(params: {
|
|
607
2032
|
token: string;
|
|
608
2033
|
providerType: AuthProviderType;
|
|
609
2034
|
input: RecoveryInput;
|
|
610
2035
|
didFromPrivateKey?: (privateKey: string) => Promise<string>;
|
|
2036
|
+
signDidAuthVp?: DidAuthVpSigner;
|
|
611
2037
|
}): Promise<RecoveryResult> {
|
|
612
|
-
const
|
|
2038
|
+
const storage = guardedStorage();
|
|
2039
|
+
const { token, providerType, input, didFromPrivateKey, signDidAuthVp } = params;
|
|
2040
|
+
|
|
2041
|
+
if (input.method === 'escrow' || input.method === 'escrow-pin') {
|
|
2042
|
+
if (!didFromPrivateKey)
|
|
2043
|
+
throw new Error('DID verification is required for escrow recovery');
|
|
2044
|
+
// Each PIN call owns a fresh, single-use hold and in-memory key pair.
|
|
2045
|
+
// Never persist this key or retry completion after a burned attempt.
|
|
2046
|
+
const start =
|
|
2047
|
+
input.method === 'escrow-pin'
|
|
2048
|
+
? await this.startEscrowRecovery!({
|
|
2049
|
+
token,
|
|
2050
|
+
providerType,
|
|
2051
|
+
options: { releasePolicy: 'pin' },
|
|
2052
|
+
})
|
|
2053
|
+
: input;
|
|
2054
|
+
let pinProof: string | undefined;
|
|
2055
|
+
if (input.method === 'escrow-pin') {
|
|
2056
|
+
if (!('pinSalt' in start) || !start.pinSalt || !start.resumeToken)
|
|
2057
|
+
throw new Error('PIN recovery response is missing required material');
|
|
2058
|
+
pinProof = await derivePinProof(input.pin, start.pinSalt);
|
|
2059
|
+
}
|
|
2060
|
+
const material = await escrowRequest<
|
|
2061
|
+
IdentityRecoveryMaterialResponse & { sealedShare: unknown }
|
|
2062
|
+
>(
|
|
2063
|
+
'/complete',
|
|
2064
|
+
{
|
|
2065
|
+
method: 'POST',
|
|
2066
|
+
headers: buildHeaders('', undefined, tenantId),
|
|
2067
|
+
body: JSON.stringify({
|
|
2068
|
+
holdId: start.holdId,
|
|
2069
|
+
resumeToken: start.resumeToken,
|
|
2070
|
+
...(pinProof ? { pinProof } : {}),
|
|
2071
|
+
}),
|
|
2072
|
+
},
|
|
2073
|
+
input.method === 'escrow-pin'
|
|
2074
|
+
);
|
|
2075
|
+
const plaintext = await openEscrowRelease(
|
|
2076
|
+
material.sealedShare,
|
|
2077
|
+
start.clientEphemeralPrivateKey
|
|
2078
|
+
);
|
|
2079
|
+
if (
|
|
2080
|
+
plaintext.holdId !== start.holdId ||
|
|
2081
|
+
plaintext.did !== material.primaryDid ||
|
|
2082
|
+
plaintext.shareVersion !== material.shareVersion
|
|
2083
|
+
) {
|
|
2084
|
+
throw new Error('Escrow recovery material does not match the request');
|
|
2085
|
+
}
|
|
2086
|
+
const authShare = material.authShare.encryptedData;
|
|
2087
|
+
const privateKey = await reconstructFromShares([
|
|
2088
|
+
plaintext.recoveryShare,
|
|
2089
|
+
authShare,
|
|
2090
|
+
]);
|
|
2091
|
+
if (
|
|
2092
|
+
!material.primaryDid ||
|
|
2093
|
+
(await didFromPrivateKey(privateKey)) !== material.primaryDid
|
|
2094
|
+
) {
|
|
2095
|
+
throw new Error(
|
|
2096
|
+
'Recovery produced an incorrect key. Try another recovery method.'
|
|
2097
|
+
);
|
|
2098
|
+
}
|
|
2099
|
+
pendingIdentityRecovery = {
|
|
2100
|
+
privateKey,
|
|
2101
|
+
primaryDid: material.primaryDid,
|
|
2102
|
+
recoveryShare: plaintext.recoveryShare,
|
|
2103
|
+
authShare,
|
|
2104
|
+
rebindSessionToken: material.rebindSessionToken,
|
|
2105
|
+
};
|
|
2106
|
+
// The OTP path uses exactly this pending state and atomic rebind operation.
|
|
2107
|
+
// Without a provider session the caller completes it after signing in.
|
|
2108
|
+
if (!token) return { privateKey, did: material.primaryDid };
|
|
2109
|
+
return this.completeIdentityRecovery!({ token, providerType, signDidAuthVp });
|
|
2110
|
+
}
|
|
613
2111
|
|
|
614
2112
|
let recoveryShare: string;
|
|
615
2113
|
let recoveryShareVersion: number | undefined;
|
|
@@ -745,18 +2243,45 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
745
2243
|
}
|
|
746
2244
|
}
|
|
747
2245
|
|
|
748
|
-
// Step 3:
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
2246
|
+
// Step 3: Re-split and persist both sides atomically. The old auth
|
|
2247
|
+
// share remains in server history, so existing versioned recovery
|
|
2248
|
+
// methods continue to work while this device advances to a fresh pair.
|
|
2249
|
+
const { shares, shareVersion } = await persistSharesAtomically(
|
|
2250
|
+
privateKey,
|
|
2251
|
+
serverUrl,
|
|
2252
|
+
token,
|
|
2253
|
+
providerType,
|
|
2254
|
+
primaryDid,
|
|
2255
|
+
storage,
|
|
2256
|
+
activeStorageId,
|
|
2257
|
+
didFromPrivateKey,
|
|
2258
|
+
signDidAuthVp,
|
|
2259
|
+
tenantId,
|
|
2260
|
+
recordPromotion(primaryDid),
|
|
2261
|
+
captureActiveUserGuard()
|
|
2262
|
+
);
|
|
754
2263
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
2264
|
+
lastEmailShare = shares.emailShare;
|
|
2265
|
+
lastShareVersion = shareVersion;
|
|
2266
|
+
lastServerSnapshot = {
|
|
2267
|
+
currentVersion: shareVersion,
|
|
2268
|
+
resolvedVersion: shareVersion,
|
|
2269
|
+
authShare: shares.authShare,
|
|
2270
|
+
primaryDid,
|
|
2271
|
+
};
|
|
759
2272
|
|
|
2273
|
+
// Non-escrow methods never exposed the PIN, so let the server carry it to the
|
|
2274
|
+
// new version. Escrow recovery completes via completeIdentityRecovery, which
|
|
2275
|
+
// retires it.
|
|
2276
|
+
await tryEnrollEscrow(
|
|
2277
|
+
token,
|
|
2278
|
+
providerType,
|
|
2279
|
+
privateKey,
|
|
2280
|
+
primaryDid,
|
|
2281
|
+
shares,
|
|
2282
|
+
shareVersion,
|
|
2283
|
+
signDidAuthVp
|
|
2284
|
+
);
|
|
760
2285
|
return { privateKey, did: primaryDid };
|
|
761
2286
|
},
|
|
762
2287
|
|
|
@@ -768,10 +2293,35 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
768
2293
|
privateKey: string;
|
|
769
2294
|
input: RecoverySetupInput;
|
|
770
2295
|
authUser?: AuthUser;
|
|
771
|
-
signDidAuthVp?:
|
|
2296
|
+
signDidAuthVp?: DidAuthVpSigner;
|
|
772
2297
|
}): Promise<RecoverySetupResult> {
|
|
2298
|
+
const storage = guardedStorage();
|
|
773
2299
|
const { token, providerType, privateKey, input, authUser, signDidAuthVp } = params;
|
|
774
2300
|
|
|
2301
|
+
if (input.method === 'escrow' && input.pin !== undefined) {
|
|
2302
|
+
if (!signDidAuthVp)
|
|
2303
|
+
throw new Error('DID proof signing is required for escrow enrollment');
|
|
2304
|
+
const result = await this.enableEscrowRecovery!({
|
|
2305
|
+
token,
|
|
2306
|
+
providerType,
|
|
2307
|
+
privateKey,
|
|
2308
|
+
signDidAuthVp,
|
|
2309
|
+
options: { pin: input.pin },
|
|
2310
|
+
});
|
|
2311
|
+
if (!result.enrolled || !result.changed)
|
|
2312
|
+
throw new Error('Escrow enrollment is disabled');
|
|
2313
|
+
return { method: 'escrow', shareVersion: result.shareVersion };
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
if (input.method === 'escrow' && !config.escrow?.enabled) {
|
|
2317
|
+
throw new Error('Escrow enrollment is disabled');
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
// Escrow pre-flight: attest the enclave BEFORE rotating so a bad
|
|
2321
|
+
// attestation has no side effects (no version bump).
|
|
2322
|
+
const escrowKey =
|
|
2323
|
+
input.method === 'escrow' ? await fetchVerifiedEnclaveKey() : undefined;
|
|
2324
|
+
|
|
775
2325
|
// Passkey pre-flight: create the credential and verify PRF support
|
|
776
2326
|
// BEFORE any split/store/email work. If PRF isn't available, fail
|
|
777
2327
|
// cleanly without side effects (no version bump, no email re-send).
|
|
@@ -788,18 +2338,6 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
788
2338
|
passkeyCredential = await createPasskeyCredential(userId, userName);
|
|
789
2339
|
}
|
|
790
2340
|
|
|
791
|
-
// Sign DID-Auth VP once for all write operations in this method
|
|
792
|
-
const vpJwt = signDidAuthVp ? await signDidAuthVp(privateKey) : undefined;
|
|
793
|
-
|
|
794
|
-
// All setup methods start by re-splitting the key to get a fresh recovery share
|
|
795
|
-
const { shares } = await splitAndVerify(privateKey);
|
|
796
|
-
|
|
797
|
-
// Cache the email share so sendEmailBackupShare can use it
|
|
798
|
-
lastEmailShare = shares.emailShare;
|
|
799
|
-
|
|
800
|
-
// Store new device + auth shares
|
|
801
|
-
await storage.storeDeviceShare(shares.deviceShare, activeStorageId);
|
|
802
|
-
|
|
803
2341
|
const serverData = await fetchAuthShareRaw(
|
|
804
2342
|
serverUrl,
|
|
805
2343
|
token,
|
|
@@ -809,52 +2347,48 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
809
2347
|
);
|
|
810
2348
|
const primaryDid = serverData?.primaryDid || '';
|
|
811
2349
|
|
|
812
|
-
|
|
2350
|
+
if (!primaryDid) throw new Error('Cannot set up recovery without a primary DID');
|
|
2351
|
+
|
|
2352
|
+
// Every recovery-method setup rotates device+auth shares through
|
|
2353
|
+
// the verified atomic path. A failed server write restores the
|
|
2354
|
+
// previous device share; an ack-loss skew is repaired on login.
|
|
2355
|
+
const { shares, shareVersion } = await persistSharesAtomically(
|
|
2356
|
+
privateKey,
|
|
813
2357
|
serverUrl,
|
|
814
2358
|
token,
|
|
815
2359
|
providerType,
|
|
816
|
-
shares.authShare,
|
|
817
2360
|
primaryDid,
|
|
818
|
-
|
|
819
|
-
|
|
2361
|
+
storage,
|
|
2362
|
+
activeStorageId,
|
|
2363
|
+
undefined,
|
|
2364
|
+
signDidAuthVp,
|
|
2365
|
+
tenantId,
|
|
2366
|
+
recordPromotion(primaryDid),
|
|
2367
|
+
captureActiveUserGuard()
|
|
820
2368
|
);
|
|
821
2369
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
? sendEmailShareToRecoveryEmail(
|
|
831
|
-
serverUrl,
|
|
832
|
-
token,
|
|
833
|
-
providerType,
|
|
834
|
-
lastEmailShare,
|
|
835
|
-
shareVersion,
|
|
836
|
-
tenantId
|
|
837
|
-
)
|
|
838
|
-
: authUser?.email
|
|
839
|
-
? sendEmailBackupShare(
|
|
840
|
-
serverUrl,
|
|
841
|
-
token,
|
|
842
|
-
providerType,
|
|
843
|
-
lastEmailShare,
|
|
844
|
-
authUser.email,
|
|
845
|
-
shareVersion,
|
|
846
|
-
tenantId
|
|
847
|
-
)
|
|
848
|
-
: Promise.resolve();
|
|
849
|
-
|
|
850
|
-
resend.catch(e =>
|
|
851
|
-
console.warn('Email backup share re-send failed (non-fatal):', e)
|
|
852
|
-
);
|
|
853
|
-
|
|
854
|
-
lastEmailShare = undefined;
|
|
855
|
-
}
|
|
2370
|
+
lastEmailShare = shares.emailShare;
|
|
2371
|
+
lastShareVersion = shareVersion;
|
|
2372
|
+
lastServerSnapshot = {
|
|
2373
|
+
currentVersion: shareVersion,
|
|
2374
|
+
resolvedVersion: shareVersion,
|
|
2375
|
+
authShare: shares.authShare,
|
|
2376
|
+
primaryDid,
|
|
2377
|
+
};
|
|
856
2378
|
|
|
857
2379
|
switch (input.method) {
|
|
2380
|
+
case 'escrow':
|
|
2381
|
+
await enrollEscrow(
|
|
2382
|
+
token,
|
|
2383
|
+
providerType,
|
|
2384
|
+
privateKey,
|
|
2385
|
+
primaryDid,
|
|
2386
|
+
shares,
|
|
2387
|
+
shareVersion,
|
|
2388
|
+
signDidAuthVp,
|
|
2389
|
+
escrowKey
|
|
2390
|
+
);
|
|
2391
|
+
return { method: 'escrow', shareVersion };
|
|
858
2392
|
case 'passkey': {
|
|
859
2393
|
// passkeyCredential was created in the pre-flight block above
|
|
860
2394
|
// (before any split/store work) so PRF is already validated.
|
|
@@ -878,10 +2412,44 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
878
2412
|
credentialId: credential.credentialId,
|
|
879
2413
|
shareVersion,
|
|
880
2414
|
},
|
|
881
|
-
|
|
2415
|
+
signDidAuthVp
|
|
2416
|
+
? await requestFreshDidAuthVp(
|
|
2417
|
+
serverUrl,
|
|
2418
|
+
privateKey,
|
|
2419
|
+
primaryDid,
|
|
2420
|
+
signDidAuthVp,
|
|
2421
|
+
tenantId
|
|
2422
|
+
)
|
|
2423
|
+
: undefined,
|
|
2424
|
+
tenantId
|
|
2425
|
+
);
|
|
2426
|
+
|
|
2427
|
+
await postRecoveryConfirmation(
|
|
2428
|
+
serverUrl,
|
|
2429
|
+
token,
|
|
2430
|
+
providerType,
|
|
2431
|
+
{ type: 'passkey', credentialId: credential.credentialId },
|
|
2432
|
+
signDidAuthVp
|
|
2433
|
+
? await requestFreshDidAuthVp(
|
|
2434
|
+
serverUrl,
|
|
2435
|
+
privateKey,
|
|
2436
|
+
primaryDid,
|
|
2437
|
+
signDidAuthVp,
|
|
2438
|
+
tenantId
|
|
2439
|
+
)
|
|
2440
|
+
: undefined,
|
|
882
2441
|
tenantId
|
|
883
2442
|
);
|
|
884
2443
|
|
|
2444
|
+
await tryEnrollEscrow(
|
|
2445
|
+
token,
|
|
2446
|
+
providerType,
|
|
2447
|
+
privateKey,
|
|
2448
|
+
primaryDid,
|
|
2449
|
+
shares,
|
|
2450
|
+
shareVersion,
|
|
2451
|
+
signDidAuthVp
|
|
2452
|
+
);
|
|
885
2453
|
return { method: 'passkey', credentialId: credential.credentialId };
|
|
886
2454
|
}
|
|
887
2455
|
|
|
@@ -899,11 +2467,42 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
899
2467
|
type: 'phrase',
|
|
900
2468
|
shareVersion,
|
|
901
2469
|
},
|
|
902
|
-
|
|
2470
|
+
signDidAuthVp
|
|
2471
|
+
? await requestFreshDidAuthVp(
|
|
2472
|
+
serverUrl,
|
|
2473
|
+
privateKey,
|
|
2474
|
+
primaryDid,
|
|
2475
|
+
signDidAuthVp,
|
|
2476
|
+
tenantId
|
|
2477
|
+
)
|
|
2478
|
+
: undefined,
|
|
903
2479
|
tenantId
|
|
904
2480
|
);
|
|
905
2481
|
|
|
906
|
-
|
|
2482
|
+
const challengeWordIndices = selectRecoveryPhraseChallengeIndices(
|
|
2483
|
+
phrase.split(/\s+/).length
|
|
2484
|
+
);
|
|
2485
|
+
const challengeWordOptions = await buildRecoveryPhraseChallengeOptions(
|
|
2486
|
+
phrase,
|
|
2487
|
+
challengeWordIndices
|
|
2488
|
+
);
|
|
2489
|
+
pendingPhraseConfirmation = { phrase, challengeWordIndices };
|
|
2490
|
+
|
|
2491
|
+
await tryEnrollEscrow(
|
|
2492
|
+
token,
|
|
2493
|
+
providerType,
|
|
2494
|
+
privateKey,
|
|
2495
|
+
primaryDid,
|
|
2496
|
+
shares,
|
|
2497
|
+
shareVersion,
|
|
2498
|
+
signDidAuthVp
|
|
2499
|
+
);
|
|
2500
|
+
return {
|
|
2501
|
+
method: 'phrase',
|
|
2502
|
+
phrase,
|
|
2503
|
+
challengeWordIndices,
|
|
2504
|
+
challengeWordOptions,
|
|
2505
|
+
};
|
|
907
2506
|
}
|
|
908
2507
|
|
|
909
2508
|
case 'backup': {
|
|
@@ -917,6 +2516,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
917
2516
|
createdAt: new Date().toISOString(),
|
|
918
2517
|
primaryDid: input.did,
|
|
919
2518
|
shareVersion,
|
|
2519
|
+
shareChecksum: await calculateShareChecksum(shares.recoveryShare),
|
|
920
2520
|
encryptedShare: {
|
|
921
2521
|
ciphertext: encrypted.ciphertext,
|
|
922
2522
|
iv: encrypted.iv,
|
|
@@ -935,47 +2535,160 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
935
2535
|
type: 'backup',
|
|
936
2536
|
shareVersion,
|
|
937
2537
|
},
|
|
938
|
-
|
|
2538
|
+
signDidAuthVp
|
|
2539
|
+
? await requestFreshDidAuthVp(
|
|
2540
|
+
serverUrl,
|
|
2541
|
+
privateKey,
|
|
2542
|
+
primaryDid,
|
|
2543
|
+
signDidAuthVp,
|
|
2544
|
+
tenantId
|
|
2545
|
+
)
|
|
2546
|
+
: undefined,
|
|
939
2547
|
tenantId
|
|
940
2548
|
);
|
|
941
2549
|
|
|
2550
|
+
pendingBackupConfirmation = {
|
|
2551
|
+
backupFile,
|
|
2552
|
+
recoveryShare: shares.recoveryShare,
|
|
2553
|
+
};
|
|
2554
|
+
|
|
2555
|
+
await tryEnrollEscrow(
|
|
2556
|
+
token,
|
|
2557
|
+
providerType,
|
|
2558
|
+
privateKey,
|
|
2559
|
+
primaryDid,
|
|
2560
|
+
shares,
|
|
2561
|
+
shareVersion,
|
|
2562
|
+
signDidAuthVp
|
|
2563
|
+
);
|
|
942
2564
|
return { method: 'backup', backupFile };
|
|
943
2565
|
}
|
|
944
2566
|
|
|
945
2567
|
case 'email': {
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
2568
|
+
const didAuthVp = signDidAuthVp
|
|
2569
|
+
? await requestFreshDidAuthVp(
|
|
2570
|
+
serverUrl,
|
|
2571
|
+
privateKey,
|
|
2572
|
+
primaryDid,
|
|
2573
|
+
signDidAuthVp,
|
|
2574
|
+
tenantId
|
|
2575
|
+
)
|
|
2576
|
+
: undefined;
|
|
2577
|
+
|
|
2578
|
+
await sendEmailBackupShare(
|
|
950
2579
|
serverUrl,
|
|
951
2580
|
token,
|
|
952
2581
|
providerType,
|
|
953
2582
|
shares.emailShare,
|
|
2583
|
+
input.email,
|
|
2584
|
+
escrowRelayPublicKey,
|
|
2585
|
+
escrowRelayKeyId,
|
|
2586
|
+
emailBranding,
|
|
954
2587
|
shareVersion,
|
|
2588
|
+
didAuthVp,
|
|
955
2589
|
tenantId
|
|
956
2590
|
);
|
|
957
2591
|
|
|
958
|
-
|
|
959
|
-
// getAvailableRecoveryMethods includes it.
|
|
960
|
-
await postRecoveryMethod(
|
|
961
|
-
serverUrl,
|
|
2592
|
+
await tryEnrollEscrow(
|
|
962
2593
|
token,
|
|
963
2594
|
providerType,
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
tenantId
|
|
2595
|
+
privateKey,
|
|
2596
|
+
primaryDid,
|
|
2597
|
+
shares,
|
|
2598
|
+
shareVersion,
|
|
2599
|
+
signDidAuthVp
|
|
970
2600
|
);
|
|
2601
|
+
return { method: 'email' };
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
},
|
|
971
2605
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
2606
|
+
async confirmRecoveryMethod(params: {
|
|
2607
|
+
token: string;
|
|
2608
|
+
providerType: AuthProviderType;
|
|
2609
|
+
privateKey: string;
|
|
2610
|
+
input: RecoveryConfirmationInput;
|
|
2611
|
+
signDidAuthVp?: DidAuthVpSigner;
|
|
2612
|
+
}): Promise<void> {
|
|
2613
|
+
const { token, providerType, privateKey, input, signDidAuthVp } = params;
|
|
2614
|
+
const primaryDid = lastServerSnapshot?.primaryDid;
|
|
975
2615
|
|
|
976
|
-
|
|
2616
|
+
if (!primaryDid) throw new Error('Cannot confirm recovery without a primary DID');
|
|
2617
|
+
|
|
2618
|
+
const confirmationBody: Record<string, unknown> = { type: input.method };
|
|
2619
|
+
|
|
2620
|
+
if (input.method === 'phrase') {
|
|
2621
|
+
const pending = pendingPhraseConfirmation;
|
|
2622
|
+
|
|
2623
|
+
if (!pending) throw new Error('Generate a new recovery phrase to continue');
|
|
2624
|
+
|
|
2625
|
+
const phraseWords = pending.phrase.trim().toLowerCase().split(/\s+/);
|
|
2626
|
+
const matches =
|
|
2627
|
+
input.challengeWords.length === pending.challengeWordIndices.length &&
|
|
2628
|
+
pending.challengeWordIndices.every(
|
|
2629
|
+
(wordIndex, answerIndex) =>
|
|
2630
|
+
input.challengeWords[answerIndex]?.trim().toLowerCase() ===
|
|
2631
|
+
phraseWords[wordIndex]
|
|
2632
|
+
);
|
|
2633
|
+
|
|
2634
|
+
if (!matches) {
|
|
2635
|
+
throw new Error('Those words do not match. Check your phrase and try again.');
|
|
2636
|
+
}
|
|
2637
|
+
} else if (input.method === 'backup') {
|
|
2638
|
+
const pending = pendingBackupConfirmation;
|
|
2639
|
+
|
|
2640
|
+
if (!pending) throw new Error('Generate a new backup file to continue');
|
|
2641
|
+
|
|
2642
|
+
let suppliedBackup: BackupFile;
|
|
2643
|
+
let decryptedShare: string;
|
|
2644
|
+
|
|
2645
|
+
try {
|
|
2646
|
+
suppliedBackup = JSON.parse(input.fileContents) as BackupFile;
|
|
2647
|
+
decryptedShare = await decryptWithPassword(
|
|
2648
|
+
suppliedBackup.encryptedShare.ciphertext,
|
|
2649
|
+
suppliedBackup.encryptedShare.iv,
|
|
2650
|
+
suppliedBackup.encryptedShare.salt,
|
|
2651
|
+
input.password,
|
|
2652
|
+
suppliedBackup.encryptedShare.kdfParams
|
|
2653
|
+
);
|
|
2654
|
+
} catch {
|
|
2655
|
+
throw new Error('Incorrect password or corrupted data. Please try again.');
|
|
977
2656
|
}
|
|
2657
|
+
|
|
2658
|
+
const checksum = await calculateShareChecksum(decryptedShare);
|
|
2659
|
+
|
|
2660
|
+
if (
|
|
2661
|
+
suppliedBackup.shareChecksum !== checksum ||
|
|
2662
|
+
pending.backupFile.shareChecksum !== checksum ||
|
|
2663
|
+
decryptedShare !== pending.recoveryShare
|
|
2664
|
+
) {
|
|
2665
|
+
throw new Error('This backup file could not be verified. Please try again.');
|
|
2666
|
+
}
|
|
2667
|
+
} else {
|
|
2668
|
+
confirmationBody.code = input.code;
|
|
978
2669
|
}
|
|
2670
|
+
|
|
2671
|
+
const didAuthVp = signDidAuthVp
|
|
2672
|
+
? await requestFreshDidAuthVp(
|
|
2673
|
+
serverUrl,
|
|
2674
|
+
privateKey,
|
|
2675
|
+
primaryDid,
|
|
2676
|
+
signDidAuthVp,
|
|
2677
|
+
tenantId
|
|
2678
|
+
)
|
|
2679
|
+
: undefined;
|
|
2680
|
+
|
|
2681
|
+
await postRecoveryConfirmation(
|
|
2682
|
+
serverUrl,
|
|
2683
|
+
token,
|
|
2684
|
+
providerType,
|
|
2685
|
+
confirmationBody,
|
|
2686
|
+
didAuthVp,
|
|
2687
|
+
tenantId
|
|
2688
|
+
);
|
|
2689
|
+
|
|
2690
|
+
if (input.method === 'phrase') pendingPhraseConfirmation = undefined;
|
|
2691
|
+
if (input.method === 'backup') pendingBackupConfirmation = undefined;
|
|
979
2692
|
},
|
|
980
2693
|
|
|
981
2694
|
async getAvailableRecoveryMethods(
|
|
@@ -990,26 +2703,323 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
990
2703
|
undefined,
|
|
991
2704
|
tenantId
|
|
992
2705
|
);
|
|
993
|
-
const methods = serverData?.recoveryMethods || []
|
|
2706
|
+
const methods = (serverData?.recoveryMethods || []) as Array<
|
|
2707
|
+
RecoveryMethodInfo & {
|
|
2708
|
+
confirmedAt?: Date | string;
|
|
2709
|
+
confirmationStatus?: 'pending' | 'confirmed';
|
|
2710
|
+
}
|
|
2711
|
+
>;
|
|
2712
|
+
const activationState = serverData?.sssActivationState ?? 'active';
|
|
994
2713
|
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
// 'email' method (from a secondary recovery email setup).
|
|
999
|
-
if (
|
|
1000
|
-
enableEmailBackupShare &&
|
|
1001
|
-
!methods.some((m: RecoveryMethodInfo) => m.type === 'email')
|
|
1002
|
-
) {
|
|
1003
|
-
methods.push({ type: 'email', createdAt: new Date() });
|
|
1004
|
-
}
|
|
2714
|
+
return methods.filter(method => {
|
|
2715
|
+
if (method.confirmedAt) return true;
|
|
2716
|
+
if (method.confirmationStatus) return false;
|
|
1005
2717
|
|
|
1006
|
-
|
|
2718
|
+
return activationState === 'active';
|
|
2719
|
+
});
|
|
1007
2720
|
} catch (e) {
|
|
1008
2721
|
console.error('Error getting recovery methods:', e);
|
|
1009
2722
|
return [];
|
|
1010
2723
|
}
|
|
1011
2724
|
},
|
|
1012
2725
|
|
|
2726
|
+
async startIdentityRecovery(email: string): Promise<void> {
|
|
2727
|
+
await postJson<{ success: true }>(
|
|
2728
|
+
`${serverUrl}/keys/recovery-session/start`,
|
|
2729
|
+
{ email },
|
|
2730
|
+
buildHeaders('', undefined, tenantId)
|
|
2731
|
+
);
|
|
2732
|
+
},
|
|
2733
|
+
|
|
2734
|
+
async verifyIdentityRecovery(
|
|
2735
|
+
email: string,
|
|
2736
|
+
code: string
|
|
2737
|
+
): Promise<IdentityRecoverySession> {
|
|
2738
|
+
const result = await postJson<IdentityRecoverySessionResponse>(
|
|
2739
|
+
`${serverUrl}/keys/recovery-session/verify`,
|
|
2740
|
+
{ email, code },
|
|
2741
|
+
buildHeaders('', undefined, tenantId)
|
|
2742
|
+
);
|
|
2743
|
+
|
|
2744
|
+
return {
|
|
2745
|
+
...result,
|
|
2746
|
+
recoveryMethods: result.recoveryMethods.map(method => ({
|
|
2747
|
+
...method,
|
|
2748
|
+
createdAt: new Date(method.createdAt),
|
|
2749
|
+
})),
|
|
2750
|
+
};
|
|
2751
|
+
},
|
|
2752
|
+
|
|
2753
|
+
async prepareIdentityRecovery(params): Promise<RecoveryResult> {
|
|
2754
|
+
const { input } = params;
|
|
2755
|
+
if (input.method === 'escrow') {
|
|
2756
|
+
throw new Error('Use executeRecovery to complete an escrow hold');
|
|
2757
|
+
}
|
|
2758
|
+
let recoveryShare: string | undefined;
|
|
2759
|
+
|
|
2760
|
+
switch (input.method) {
|
|
2761
|
+
case 'passkey':
|
|
2762
|
+
// Passkey decryption requires the encrypted server material.
|
|
2763
|
+
break;
|
|
2764
|
+
case 'phrase':
|
|
2765
|
+
if (!(await validateRecoveryPhrase(input.phrase))) {
|
|
2766
|
+
throw new Error('Invalid recovery phrase');
|
|
2767
|
+
}
|
|
2768
|
+
recoveryShare = await recoveryPhraseToShare(input.phrase);
|
|
2769
|
+
break;
|
|
2770
|
+
case 'backup': {
|
|
2771
|
+
const backup = JSON.parse(input.fileContents) as BackupFile;
|
|
2772
|
+
if (backup.version !== 1) throw new Error('Unsupported backup file version');
|
|
2773
|
+
recoveryShare = await decryptWithPassword(
|
|
2774
|
+
backup.encryptedShare.ciphertext,
|
|
2775
|
+
backup.encryptedShare.iv,
|
|
2776
|
+
backup.encryptedShare.salt,
|
|
2777
|
+
input.password,
|
|
2778
|
+
backup.encryptedShare.kdfParams
|
|
2779
|
+
);
|
|
2780
|
+
break;
|
|
2781
|
+
}
|
|
2782
|
+
case 'email':
|
|
2783
|
+
recoveryShare = parseVersionedEmailShare(input.emailShare.trim()).share;
|
|
2784
|
+
break;
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
try {
|
|
2788
|
+
const material = await postJson<IdentityRecoveryMaterialResponse>(
|
|
2789
|
+
`${serverUrl}/keys/recovery-session/recover`,
|
|
2790
|
+
{
|
|
2791
|
+
recoverySessionToken: params.recoverySessionToken,
|
|
2792
|
+
type: input.method,
|
|
2793
|
+
...(input.method === 'passkey' ? { credentialId: input.credentialId } : {}),
|
|
2794
|
+
},
|
|
2795
|
+
buildHeaders('', undefined, tenantId)
|
|
2796
|
+
);
|
|
2797
|
+
|
|
2798
|
+
if (input.method === 'passkey') {
|
|
2799
|
+
if (!material.encryptedShare) {
|
|
2800
|
+
throw new Error('No passkey recovery data found');
|
|
2801
|
+
}
|
|
2802
|
+
recoveryShare = await decryptShareWithPasskey({
|
|
2803
|
+
encryptedData: material.encryptedShare.encryptedData,
|
|
2804
|
+
iv: material.encryptedShare.iv,
|
|
2805
|
+
credentialId: input.credentialId,
|
|
2806
|
+
});
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
if (!recoveryShare) throw new Error('Recovery data was empty');
|
|
2810
|
+
|
|
2811
|
+
const authShare = material.authShare.encryptedData;
|
|
2812
|
+
let privateKey: string;
|
|
2813
|
+
|
|
2814
|
+
try {
|
|
2815
|
+
privateKey = await reconstructFromShares([recoveryShare, authShare]);
|
|
2816
|
+
} catch {
|
|
2817
|
+
throw new Error(
|
|
2818
|
+
'Recovery produced an incorrect key. Try another recovery method.'
|
|
2819
|
+
);
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
const derivedDid = await params.didFromPrivateKey(privateKey);
|
|
2823
|
+
if (!derivedDid || derivedDid !== material.primaryDid) {
|
|
2824
|
+
throw new Error(
|
|
2825
|
+
'Recovery produced an incorrect key. Try another recovery method.'
|
|
2826
|
+
);
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
pendingIdentityRecovery = {
|
|
2830
|
+
privateKey,
|
|
2831
|
+
primaryDid: material.primaryDid,
|
|
2832
|
+
recoveryShare,
|
|
2833
|
+
authShare,
|
|
2834
|
+
rebindSessionToken: material.rebindSessionToken,
|
|
2835
|
+
};
|
|
2836
|
+
|
|
2837
|
+
return { privateKey, did: material.primaryDid };
|
|
2838
|
+
} catch (error) {
|
|
2839
|
+
const message = error instanceof Error ? error.message : 'Identity recovery failed';
|
|
2840
|
+
|
|
2841
|
+
throw new IdentityRecoverySessionConsumedError(message);
|
|
2842
|
+
}
|
|
2843
|
+
},
|
|
2844
|
+
|
|
2845
|
+
hasPendingIdentityRecovery(): boolean {
|
|
2846
|
+
return Boolean(pendingIdentityRecovery);
|
|
2847
|
+
},
|
|
2848
|
+
|
|
2849
|
+
cancelIdentityRecovery(): void {
|
|
2850
|
+
pendingIdentityRecovery = undefined;
|
|
2851
|
+
},
|
|
2852
|
+
|
|
2853
|
+
async completeIdentityRecovery(params): Promise<RecoveryResult> {
|
|
2854
|
+
const storage = guardedStorage();
|
|
2855
|
+
const generation = storageGeneration;
|
|
2856
|
+
const pending = pendingIdentityRecovery;
|
|
2857
|
+
const signDidAuthVp = params.signDidAuthVp;
|
|
2858
|
+
|
|
2859
|
+
if (!pending) throw new Error('No identity recovery is waiting for a new sign-in');
|
|
2860
|
+
if (!signDidAuthVp) {
|
|
2861
|
+
throw new Error('DID proof signing is required to bind a new sign-in');
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
const storageId = activeStorageId;
|
|
2865
|
+
const { shares, shareVersion } = await withShareUpdateLock(storage, storageId, () =>
|
|
2866
|
+
withRotationLock(async () => {
|
|
2867
|
+
const assertActive = (): void => {
|
|
2868
|
+
if (
|
|
2869
|
+
activeStorageId !== storageId ||
|
|
2870
|
+
pendingIdentityRecovery !== pending ||
|
|
2871
|
+
storageGeneration !== generation
|
|
2872
|
+
) {
|
|
2873
|
+
throw new Error('This recovery request was cancelled.');
|
|
2874
|
+
}
|
|
2875
|
+
};
|
|
2876
|
+
assertActive();
|
|
2877
|
+
const candidates = await readPendingShareCandidates(storage, storageId);
|
|
2878
|
+
if (pending.rebind && pending.rebind.storageId !== storageId) {
|
|
2879
|
+
throw new Error('Active account changed during identity recovery');
|
|
2880
|
+
}
|
|
2881
|
+
if (
|
|
2882
|
+
candidates.some(
|
|
2883
|
+
candidate => candidate.share !== pending.rebind?.shares.deviceShare
|
|
2884
|
+
)
|
|
2885
|
+
) {
|
|
2886
|
+
throw new Error(
|
|
2887
|
+
'Reconcile the pending share update before binding a sign-in'
|
|
2888
|
+
);
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
let shareVersion: number | undefined;
|
|
2892
|
+
const retry = pending.rebind?.attempted ?? false;
|
|
2893
|
+
if (retry && pending.rebind) {
|
|
2894
|
+
const current = await fetchAuthShareRaw(
|
|
2895
|
+
serverUrl,
|
|
2896
|
+
params.token,
|
|
2897
|
+
params.providerType,
|
|
2898
|
+
undefined,
|
|
2899
|
+
tenantId
|
|
2900
|
+
);
|
|
2901
|
+
const authShare = authShareToString(current?.authShare);
|
|
2902
|
+
// prepareIdentityRecovery already verified this key's DID.
|
|
2903
|
+
// Comparing the reconstructed key avoids trusting server DID metadata.
|
|
2904
|
+
if (authShare && current?.shareVersion != null) {
|
|
2905
|
+
const health = await verifyStoredShares(
|
|
2906
|
+
{
|
|
2907
|
+
getDevice: async () => pending.rebind!.shares.deviceShare,
|
|
2908
|
+
getAuth: async () => authShare,
|
|
2909
|
+
},
|
|
2910
|
+
pending.primaryDid,
|
|
2911
|
+
async key => (key === pending.privateKey ? pending.primaryDid : '')
|
|
2912
|
+
);
|
|
2913
|
+
if (health.healthy) shareVersion = current.shareVersion;
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
assertActive();
|
|
2918
|
+
if (shareVersion === undefined) {
|
|
2919
|
+
const didAuthVp = await requestFreshDidAuthVp(
|
|
2920
|
+
serverUrl,
|
|
2921
|
+
pending.privateKey,
|
|
2922
|
+
pending.primaryDid,
|
|
2923
|
+
signDidAuthVp,
|
|
2924
|
+
tenantId
|
|
2925
|
+
);
|
|
2926
|
+
assertActive();
|
|
2927
|
+
pending.rebind ??= {
|
|
2928
|
+
shares: (await splitAndVerify(pending.privateKey)).shares,
|
|
2929
|
+
storageId,
|
|
2930
|
+
attempted: false,
|
|
2931
|
+
};
|
|
2932
|
+
const rebind = pending.rebind;
|
|
2933
|
+
if (
|
|
2934
|
+
!candidates.some(
|
|
2935
|
+
candidate => candidate.share === rebind.shares.deviceShare
|
|
2936
|
+
)
|
|
2937
|
+
) {
|
|
2938
|
+
await addPendingShareCandidate(
|
|
2939
|
+
storage,
|
|
2940
|
+
{ share: rebind.shares.deviceShare, createdAt: Date.now() },
|
|
2941
|
+
storageId
|
|
2942
|
+
);
|
|
2943
|
+
}
|
|
2944
|
+
assertActive();
|
|
2945
|
+
rebind.attempted = true;
|
|
2946
|
+
try {
|
|
2947
|
+
const response = await postJson<{
|
|
2948
|
+
shareVersion: number;
|
|
2949
|
+
recoveryMethodsRequireConfirmation: string[];
|
|
2950
|
+
}>(
|
|
2951
|
+
`${serverUrl}/keys/recovery-session/rebind`,
|
|
2952
|
+
{
|
|
2953
|
+
recoverySessionToken: pending.rebindSessionToken,
|
|
2954
|
+
providerType: params.providerType,
|
|
2955
|
+
primaryDid: pending.primaryDid,
|
|
2956
|
+
authShare: {
|
|
2957
|
+
encryptedData: rebind.shares.authShare,
|
|
2958
|
+
encryptedDek: '',
|
|
2959
|
+
iv: '',
|
|
2960
|
+
},
|
|
2961
|
+
},
|
|
2962
|
+
{
|
|
2963
|
+
...buildHeaders('', didAuthVp, tenantId),
|
|
2964
|
+
'X-Auth-Token': params.token,
|
|
2965
|
+
}
|
|
2966
|
+
);
|
|
2967
|
+
|
|
2968
|
+
shareVersion = response.shareVersion;
|
|
2969
|
+
} catch (error) {
|
|
2970
|
+
// A retry's rejection does not disprove an earlier commit
|
|
2971
|
+
// (the first request may still have been in flight at the read).
|
|
2972
|
+
if (error instanceof ShareWriteRejectedError && !retry) {
|
|
2973
|
+
await removePendingShareCandidate(
|
|
2974
|
+
storage,
|
|
2975
|
+
rebind.shares.deviceShare,
|
|
2976
|
+
storageId
|
|
2977
|
+
);
|
|
2978
|
+
pending.rebind = undefined;
|
|
2979
|
+
}
|
|
2980
|
+
throw error;
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
if (shareVersion === undefined || !pending.rebind) {
|
|
2984
|
+
throw new Error('Server did not confirm the new share');
|
|
2985
|
+
}
|
|
2986
|
+
assertActive();
|
|
2987
|
+
const shares = pending.rebind.shares;
|
|
2988
|
+
await storage.storeDeviceShare(shares.deviceShare, storageId);
|
|
2989
|
+
await storage.storeShareVersion(shareVersion, storageId);
|
|
2990
|
+
await clearPendingShareCandidates(storage, storageId);
|
|
2991
|
+
assertActive();
|
|
2992
|
+
lastEmailShare = shares.emailShare;
|
|
2993
|
+
lastShareVersion = shareVersion;
|
|
2994
|
+
lastServerSnapshot = {
|
|
2995
|
+
currentVersion: shareVersion,
|
|
2996
|
+
resolvedVersion: shareVersion,
|
|
2997
|
+
authShare: shares.authShare,
|
|
2998
|
+
primaryDid: pending.primaryDid,
|
|
2999
|
+
};
|
|
3000
|
+
pendingIdentityRecovery = undefined;
|
|
3001
|
+
return { shares, shareVersion };
|
|
3002
|
+
})
|
|
3003
|
+
);
|
|
3004
|
+
|
|
3005
|
+
await tryEnrollEscrow(
|
|
3006
|
+
params.token,
|
|
3007
|
+
params.providerType,
|
|
3008
|
+
pending.privateKey,
|
|
3009
|
+
pending.primaryDid,
|
|
3010
|
+
shares,
|
|
3011
|
+
shareVersion,
|
|
3012
|
+
signDidAuthVp,
|
|
3013
|
+
undefined,
|
|
3014
|
+
undefined,
|
|
3015
|
+
true
|
|
3016
|
+
);
|
|
3017
|
+
if (activeStorageId !== storageId || storageGeneration !== generation) {
|
|
3018
|
+
throw new Error('This recovery request was cancelled.');
|
|
3019
|
+
}
|
|
3020
|
+
return { privateKey: pending.privateKey, did: pending.primaryDid };
|
|
3021
|
+
},
|
|
3022
|
+
|
|
1013
3023
|
// --- Contact method management ---
|
|
1014
3024
|
|
|
1015
3025
|
async upgradeContactMethod(
|
|
@@ -1050,7 +3060,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1050
3060
|
token: string,
|
|
1051
3061
|
providerType: AuthProviderType,
|
|
1052
3062
|
_privateKey: string,
|
|
1053
|
-
email: string
|
|
3063
|
+
email: string,
|
|
3064
|
+
didAuthVp?: string
|
|
1054
3065
|
): Promise<void> {
|
|
1055
3066
|
if (!enableEmailBackupShare) return;
|
|
1056
3067
|
|
|
@@ -1061,32 +3072,22 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1061
3072
|
return;
|
|
1062
3073
|
}
|
|
1063
3074
|
|
|
1064
|
-
|
|
1065
|
-
// email — eliminates the primary-email single point of failure.
|
|
1066
|
-
if (hasRecoveryEmail) {
|
|
1067
|
-
await sendEmailShareToRecoveryEmail(
|
|
1068
|
-
serverUrl,
|
|
1069
|
-
token,
|
|
1070
|
-
providerType,
|
|
1071
|
-
lastEmailShare,
|
|
1072
|
-
lastShareVersion,
|
|
1073
|
-
tenantId
|
|
1074
|
-
);
|
|
1075
|
-
} else {
|
|
1076
|
-
if (!email) {
|
|
1077
|
-
console.warn('Cannot send email backup share: no email address');
|
|
1078
|
-
return;
|
|
1079
|
-
}
|
|
1080
|
-
|
|
3075
|
+
if (email) {
|
|
1081
3076
|
await sendEmailBackupShare(
|
|
1082
3077
|
serverUrl,
|
|
1083
3078
|
token,
|
|
1084
3079
|
providerType,
|
|
1085
3080
|
lastEmailShare,
|
|
1086
3081
|
email,
|
|
3082
|
+
escrowRelayPublicKey,
|
|
3083
|
+
escrowRelayKeyId,
|
|
3084
|
+
emailBranding,
|
|
1087
3085
|
lastShareVersion,
|
|
3086
|
+
didAuthVp,
|
|
1088
3087
|
tenantId
|
|
1089
3088
|
);
|
|
3089
|
+
} else {
|
|
3090
|
+
throw new Error('Enter a recovery email before sending a recovery key');
|
|
1090
3091
|
}
|
|
1091
3092
|
|
|
1092
3093
|
// Clear after use — one-shot to avoid stale data
|
|
@@ -1111,7 +3112,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1111
3112
|
},
|
|
1112
3113
|
|
|
1113
3114
|
async cleanup(): Promise<void> {
|
|
1114
|
-
|
|
3115
|
+
storageGeneration++;
|
|
3116
|
+
await Promise.allSettled(localWrites);
|
|
1115
3117
|
},
|
|
1116
3118
|
};
|
|
1117
3119
|
}
|