@learncard/sss-key-manager 0.1.23 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -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 +8 -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-pin.d.ts +22 -0
- package/dist/escrow-pin.d.ts.map +1 -0
- package/dist/index.d.ts +13 -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 +3163 -440
- 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 +3163 -440
- 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 +87 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- 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 +85 -0
- package/src/escrow-attestation.ts +64 -0
- package/src/escrow-crypto.test.ts +381 -0
- package/src/escrow-crypto.ts +319 -0
- package/src/escrow-pin.test.ts +119 -0
- package/src/escrow-pin.ts +83 -0
- package/src/index.ts +63 -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 +2642 -110
- package/src/sss-strategy.ts +2186 -224
- package/src/storage.test.ts +66 -8
- package/src/storage.ts +41 -29
- package/src/types.ts +107 -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)}`);
|
|
214
420
|
}
|
|
215
421
|
};
|
|
216
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);
|
|
446
|
+
}
|
|
447
|
+
};
|
|
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
|
-
|
|
316
|
-
|
|
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.
|
|
573
|
+
*/
|
|
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.
|
|
317
599
|
*/
|
|
318
|
-
const
|
|
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,335 @@ 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
|
+
|
|
1058
|
+
// Material from a rotation whose escrow POST failed. Retained in memory so a
|
|
1059
|
+
// retry within this session re-posts instead of burning another share version.
|
|
1060
|
+
// TODO(escrow): this cannot survive a reload; a cross-login guard needs the
|
|
1061
|
+
// server to expose a pending (unconfirmed) escrow record for the current version.
|
|
1062
|
+
let unenrolledRotation:
|
|
1063
|
+
| { shares: SSSShares; shareVersion: number; primaryDid: string; clearPin?: boolean }
|
|
1064
|
+
| undefined;
|
|
1065
|
+
|
|
1066
|
+
const escrowRequest = async <T>(
|
|
1067
|
+
path: string,
|
|
1068
|
+
init: RequestInit,
|
|
1069
|
+
pinAttempt = false
|
|
1070
|
+
): Promise<T> => {
|
|
1071
|
+
const response = await fetch(`${serverUrl}/keys/escrow${path}`, {
|
|
1072
|
+
signal: requestTimeoutSignal(),
|
|
1073
|
+
...init,
|
|
1074
|
+
});
|
|
1075
|
+
// Never propagate response bodies or status text that could contain secrets.
|
|
1076
|
+
if (!response.ok) {
|
|
1077
|
+
if (!pinAttempt && path === '/recover' && response.status === 429) {
|
|
1078
|
+
const body: unknown = await response.json().catch(() => undefined);
|
|
1079
|
+
const message =
|
|
1080
|
+
body &&
|
|
1081
|
+
typeof body === 'object' &&
|
|
1082
|
+
'message' in body &&
|
|
1083
|
+
typeof body.message === 'string'
|
|
1084
|
+
? body.message
|
|
1085
|
+
: '';
|
|
1086
|
+
const retryAfter =
|
|
1087
|
+
/Try again after (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)\./.exec(
|
|
1088
|
+
message
|
|
1089
|
+
)?.[1];
|
|
1090
|
+
throw new EscrowHoldRestartThrottledError(
|
|
1091
|
+
retryAfter && Number.isFinite(Date.parse(retryAfter)) ? retryAfter : undefined
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
if (pinAttempt && response.status === 429) {
|
|
1095
|
+
const body: unknown = await response.json().catch(() => undefined);
|
|
1096
|
+
if (
|
|
1097
|
+
body &&
|
|
1098
|
+
typeof body === 'object' &&
|
|
1099
|
+
'message' in body &&
|
|
1100
|
+
body.message === ESCROW_PIN_LOCKED_MESSAGE
|
|
1101
|
+
) {
|
|
1102
|
+
throw new EscrowPinLockedError();
|
|
1103
|
+
}
|
|
1104
|
+
throw new EscrowPinThrottledError();
|
|
1105
|
+
}
|
|
1106
|
+
if (pinAttempt && response.status === 403) {
|
|
1107
|
+
const body: unknown = await response.json().catch(() => undefined);
|
|
1108
|
+
if (
|
|
1109
|
+
body &&
|
|
1110
|
+
typeof body === 'object' &&
|
|
1111
|
+
'message' in body &&
|
|
1112
|
+
body.message === ESCROW_PIN_UNAVAILABLE_MESSAGE
|
|
1113
|
+
) {
|
|
1114
|
+
throw new EscrowPinUnavailableError();
|
|
1115
|
+
}
|
|
1116
|
+
if (
|
|
1117
|
+
body &&
|
|
1118
|
+
typeof body === 'object' &&
|
|
1119
|
+
'message' in body &&
|
|
1120
|
+
typeof body.message === 'string' &&
|
|
1121
|
+
body.message.startsWith('Incorrect PIN.')
|
|
1122
|
+
) {
|
|
1123
|
+
const match = ESCROW_PIN_MISMATCH_PATTERN.exec(body.message);
|
|
1124
|
+
const remaining = match ? Number(match[1]) : 0;
|
|
1125
|
+
// Older/malformed responses must not invent remaining guesses; use 0.
|
|
1126
|
+
throw new EscrowPinMismatchError(
|
|
1127
|
+
Number.isSafeInteger(remaining) &&
|
|
1128
|
+
remaining >= 0 &&
|
|
1129
|
+
remaining < ESCROW_PIN_MAX_ATTEMPTS
|
|
1130
|
+
? remaining
|
|
1131
|
+
: 0
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
throw new EscrowRequestError(response.status);
|
|
1136
|
+
}
|
|
1137
|
+
try {
|
|
1138
|
+
return await response.json();
|
|
1139
|
+
} catch {
|
|
1140
|
+
throw new EscrowRequestError(response.status);
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
const fetchVerifiedEnclaveKey = async (): Promise<{ publicKey: string; keyId: string }> => {
|
|
1145
|
+
if (!config.escrow?.enabled) throw new Error('Escrow enrollment is disabled');
|
|
1146
|
+
const { attestation } = await escrowRequest<{ attestation: unknown }>('/attestation', {
|
|
1147
|
+
method: 'GET',
|
|
1148
|
+
headers: buildHeaders('', undefined, tenantId),
|
|
1149
|
+
});
|
|
1150
|
+
return verifyEnclaveAttestation(attestation, config.escrow.attestation);
|
|
1151
|
+
};
|
|
1152
|
+
|
|
1153
|
+
const enrollEscrow = async (
|
|
1154
|
+
token: string,
|
|
1155
|
+
providerType: AuthProviderType,
|
|
1156
|
+
privateKey: string,
|
|
1157
|
+
primaryDid: string,
|
|
1158
|
+
shares: SSSShares,
|
|
1159
|
+
shareVersion: number,
|
|
1160
|
+
signDidAuthVp?: DidAuthVpSigner,
|
|
1161
|
+
verifiedKey?: { publicKey: string; keyId: string },
|
|
1162
|
+
pinMaterial?: { pinSalt: string; pinVerifier: string },
|
|
1163
|
+
clearPin = false
|
|
1164
|
+
): Promise<void> => {
|
|
1165
|
+
if (!config.escrow?.enabled) throw new Error('Escrow enrollment is disabled');
|
|
1166
|
+
if (!signDidAuthVp) throw new Error('DID proof signing is required for escrow enrollment');
|
|
1167
|
+
const { publicKey, keyId } = verifiedKey ?? (await fetchVerifiedEnclaveKey());
|
|
1168
|
+
const envelope = await encryptEscrowBlob(
|
|
1169
|
+
{
|
|
1170
|
+
recoveryShare: shares.recoveryShare,
|
|
1171
|
+
did: primaryDid,
|
|
1172
|
+
shareVersion,
|
|
1173
|
+
...(pinMaterial ? { pinVerifier: pinMaterial.pinVerifier } : {}),
|
|
1174
|
+
},
|
|
1175
|
+
publicKey,
|
|
1176
|
+
keyId
|
|
1177
|
+
);
|
|
1178
|
+
const vp = await requestFreshDidAuthVp(
|
|
1179
|
+
serverUrl,
|
|
1180
|
+
privateKey,
|
|
1181
|
+
primaryDid,
|
|
1182
|
+
signDidAuthVp,
|
|
1183
|
+
tenantId
|
|
1184
|
+
);
|
|
1185
|
+
await escrowRequest('', {
|
|
1186
|
+
method: 'POST',
|
|
1187
|
+
headers: buildHeaders(token, vp, tenantId),
|
|
1188
|
+
body: JSON.stringify({
|
|
1189
|
+
authToken: token,
|
|
1190
|
+
providerType,
|
|
1191
|
+
envelope,
|
|
1192
|
+
shareVersion,
|
|
1193
|
+
enclaveKeyId: keyId,
|
|
1194
|
+
...(pinMaterial ? { pinSalt: pinMaterial.pinSalt } : {}),
|
|
1195
|
+
...(clearPin ? { clearPin: true } : {}),
|
|
1196
|
+
}),
|
|
1197
|
+
});
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<void> => {
|
|
1201
|
+
if (!config.escrow?.enabled) return;
|
|
1202
|
+
try {
|
|
1203
|
+
await enrollEscrow(...args);
|
|
1204
|
+
} catch (err) {
|
|
1205
|
+
// A reporting callback must not turn a successful rotation into a failure.
|
|
1206
|
+
try {
|
|
1207
|
+
if (config.onEscrowError) config.onEscrowError(err);
|
|
1208
|
+
else console.warn('SSS: escrow enrollment failed');
|
|
1209
|
+
} catch {
|
|
1210
|
+
console.warn('SSS: escrow error reporting failed');
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
};
|
|
1214
|
+
|
|
1215
|
+
const ensureEscrowEnrollmentUnguarded = async (
|
|
1216
|
+
self: SSSKeyDerivationStrategy,
|
|
1217
|
+
params: Parameters<NonNullable<SSSKeyDerivationStrategy['ensureEscrowEnrollment']>>[0],
|
|
1218
|
+
storage: SSSStorageFunctions,
|
|
1219
|
+
forceRotate = false
|
|
1220
|
+
): Promise<EscrowEnrollmentResult> => {
|
|
1221
|
+
if (!config.escrow?.enabled) return { enrolled: false, reason: 'disabled' };
|
|
1222
|
+
const pin = params.options?.pin;
|
|
1223
|
+
if (pin !== undefined) {
|
|
1224
|
+
const validation = validatePin(pin);
|
|
1225
|
+
if (!validation.ok)
|
|
1226
|
+
throw new Error(
|
|
1227
|
+
validation.reason === 'length'
|
|
1228
|
+
? 'PIN must contain 6–12 digits.'
|
|
1229
|
+
: 'Choose a PIN without trivial or sequential patterns.'
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1232
|
+
const status = await self.fetchServerKeyStatus(params.token, params.providerType);
|
|
1233
|
+
if (status.escrowOptedOut) return { enrolled: false, reason: 'opted-out' };
|
|
1234
|
+
if (
|
|
1235
|
+
!forceRotate &&
|
|
1236
|
+
pin === undefined &&
|
|
1237
|
+
status.shareVersion !== null &&
|
|
1238
|
+
status.recoveryMethods.some(
|
|
1239
|
+
method =>
|
|
1240
|
+
method.type === 'escrow' &&
|
|
1241
|
+
method.shareVersion === status.shareVersion &&
|
|
1242
|
+
(Boolean(method.confirmedAt) ||
|
|
1243
|
+
('confirmationStatus' in method &&
|
|
1244
|
+
method.confirmationStatus === 'confirmed'))
|
|
1245
|
+
)
|
|
1246
|
+
)
|
|
1247
|
+
// Automatic repair preserves current enrollment regardless of PIN status.
|
|
1248
|
+
// The enclave can carry an existing verifier when shares rotate.
|
|
1249
|
+
return { enrolled: true, changed: false };
|
|
1250
|
+
if (!status.primaryDid) throw new Error('Cannot enroll escrow without a primary DID');
|
|
1251
|
+
if (!params.signDidAuthVp) {
|
|
1252
|
+
throw new Error('DID proof signing is required for escrow enrollment');
|
|
1253
|
+
}
|
|
1254
|
+
if (
|
|
1255
|
+
!forceRotate &&
|
|
1256
|
+
pin === undefined &&
|
|
1257
|
+
unenrolledRotation &&
|
|
1258
|
+
unenrolledRotation.shareVersion === status.shareVersion &&
|
|
1259
|
+
unenrolledRotation.primaryDid === status.primaryDid
|
|
1260
|
+
) {
|
|
1261
|
+
const retry = unenrolledRotation;
|
|
1262
|
+
await enrollEscrow(
|
|
1263
|
+
params.token,
|
|
1264
|
+
params.providerType,
|
|
1265
|
+
params.privateKey,
|
|
1266
|
+
retry.primaryDid,
|
|
1267
|
+
retry.shares,
|
|
1268
|
+
retry.shareVersion,
|
|
1269
|
+
params.signDidAuthVp,
|
|
1270
|
+
undefined,
|
|
1271
|
+
undefined,
|
|
1272
|
+
retry.clearPin
|
|
1273
|
+
);
|
|
1274
|
+
unenrolledRotation = undefined;
|
|
1275
|
+
return { enrolled: true, changed: true, shareVersion: retry.shareVersion };
|
|
1276
|
+
}
|
|
1277
|
+
// Verify the enclave before rotating: a bad attestation must not burn
|
|
1278
|
+
// a share version (and, repeated, the retained auth-share history).
|
|
1279
|
+
const verifiedKey = await fetchVerifiedEnclaveKey();
|
|
1280
|
+
const pinSalt = pin !== undefined ? generatePinSalt() : undefined;
|
|
1281
|
+
const pinMaterial =
|
|
1282
|
+
pin !== undefined && pinSalt !== undefined
|
|
1283
|
+
? { pinSalt, pinVerifier: await derivePinProof(pin, pinSalt) }
|
|
1284
|
+
: undefined;
|
|
1285
|
+
const storageId = activeStorageId;
|
|
1286
|
+
const primaryDid = status.primaryDid;
|
|
1287
|
+
const { shares, shareVersion } = await withShareUpdateLock(storage, storageId, () =>
|
|
1288
|
+
persistSharesWithoutLock(
|
|
1289
|
+
params.privateKey,
|
|
1290
|
+
serverUrl,
|
|
1291
|
+
params.token,
|
|
1292
|
+
params.providerType,
|
|
1293
|
+
primaryDid,
|
|
1294
|
+
storage,
|
|
1295
|
+
storageId,
|
|
1296
|
+
undefined,
|
|
1297
|
+
params.signDidAuthVp,
|
|
1298
|
+
tenantId
|
|
1299
|
+
)
|
|
1300
|
+
);
|
|
1301
|
+
lastEmailShare = shares.emailShare;
|
|
1302
|
+
lastShareVersion = shareVersion;
|
|
1303
|
+
lastServerSnapshot = {
|
|
1304
|
+
currentVersion: shareVersion,
|
|
1305
|
+
resolvedVersion: shareVersion,
|
|
1306
|
+
authShare: shares.authShare,
|
|
1307
|
+
primaryDid: status.primaryDid,
|
|
1308
|
+
};
|
|
1309
|
+
unenrolledRotation = pinMaterial
|
|
1310
|
+
? undefined
|
|
1311
|
+
: { shares, shareVersion, primaryDid: status.primaryDid, clearPin: forceRotate };
|
|
1312
|
+
await enrollEscrow(
|
|
1313
|
+
params.token,
|
|
1314
|
+
params.providerType,
|
|
1315
|
+
params.privateKey,
|
|
1316
|
+
status.primaryDid,
|
|
1317
|
+
shares,
|
|
1318
|
+
shareVersion,
|
|
1319
|
+
params.signDidAuthVp,
|
|
1320
|
+
verifiedKey,
|
|
1321
|
+
pinMaterial,
|
|
1322
|
+
forceRotate
|
|
1323
|
+
);
|
|
1324
|
+
unenrolledRotation = undefined;
|
|
1325
|
+
return { enrolled: true, changed: true, shareVersion };
|
|
1326
|
+
};
|
|
1327
|
+
|
|
1328
|
+
const recordPromotion =
|
|
1329
|
+
(primaryDid: string) =>
|
|
1330
|
+
(snapshot: CurrentShareSnapshot): void => {
|
|
1331
|
+
// A device-only candidate cannot supply the email share of its split.
|
|
1332
|
+
lastEmailShare = undefined;
|
|
1333
|
+
lastShareVersion = snapshot.currentVersion ?? undefined;
|
|
1334
|
+
lastServerSnapshot = {
|
|
1335
|
+
...snapshot,
|
|
1336
|
+
resolvedVersion: snapshot.currentVersion,
|
|
1337
|
+
primaryDid,
|
|
1338
|
+
};
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
const captureActiveUserGuard = (): (() => void) => {
|
|
1342
|
+
const storageId = activeStorageId;
|
|
1343
|
+
return () => {
|
|
1344
|
+
if (activeStorageId !== storageId) {
|
|
1345
|
+
throw new AtomicUpdateError(
|
|
1346
|
+
'Active account changed during share update',
|
|
1347
|
+
'verify_stored',
|
|
1348
|
+
false
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
};
|
|
1352
|
+
};
|
|
403
1353
|
|
|
404
1354
|
return {
|
|
405
1355
|
name: 'sss',
|
|
@@ -414,13 +1364,18 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
414
1364
|
// --- User scoping ---
|
|
415
1365
|
|
|
416
1366
|
setActiveUser(userId: string): void {
|
|
1367
|
+
if (activeStorageId !== `sss-device-share:${userId}`) storageGeneration++;
|
|
417
1368
|
activeStorageId = `sss-device-share:${userId}`;
|
|
1369
|
+
lastServerSnapshot = null;
|
|
1370
|
+
pendingPhraseConfirmation = undefined;
|
|
1371
|
+
pendingBackupConfirmation = undefined;
|
|
418
1372
|
},
|
|
419
1373
|
|
|
420
1374
|
// --- Key lifecycle ---
|
|
421
1375
|
|
|
422
1376
|
async hasLocalKey(): Promise<boolean> {
|
|
423
1377
|
if (await storage.hasDeviceShare(activeStorageId)) return true;
|
|
1378
|
+
if ((await readPendingShareCandidates(storage, activeStorageId)).length) return true;
|
|
424
1379
|
|
|
425
1380
|
// Fallback: check legacy unscoped key for shares stored before per-user scoping
|
|
426
1381
|
if (activeStorageId) {
|
|
@@ -431,10 +1386,14 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
431
1386
|
},
|
|
432
1387
|
|
|
433
1388
|
async getLocalKey(): Promise<string | null> {
|
|
1389
|
+
const storage = guardedStorage();
|
|
434
1390
|
const scoped = await storage.getDeviceShare(activeStorageId);
|
|
435
1391
|
|
|
436
1392
|
if (scoped) return scoped;
|
|
437
1393
|
|
|
1394
|
+
const pending = await readPendingShareCandidates(storage, activeStorageId);
|
|
1395
|
+
if (pending.length) return pending[pending.length - 1]!.share;
|
|
1396
|
+
|
|
438
1397
|
// Fallback: try legacy unscoped key and auto-migrate if found
|
|
439
1398
|
if (activeStorageId) {
|
|
440
1399
|
const legacy = await storage.getDeviceShare();
|
|
@@ -454,8 +1413,21 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
454
1413
|
return storage.storeDeviceShare(key, activeStorageId);
|
|
455
1414
|
},
|
|
456
1415
|
|
|
457
|
-
async clearLocalKeys(): Promise<void> {
|
|
458
|
-
|
|
1416
|
+
async clearLocalKeys(options?: { preservePending?: boolean }): Promise<void> {
|
|
1417
|
+
const storageId = activeStorageId;
|
|
1418
|
+
if (options?.preservePending) {
|
|
1419
|
+
const storage = guardedStorage();
|
|
1420
|
+
// Retain unresolved candidates without racing another share update
|
|
1421
|
+
// or invalidating its generation. Never wipe unscoped storage here.
|
|
1422
|
+
return withShareUpdateLock(storage, storageId, () =>
|
|
1423
|
+
storage.deleteDeviceShare(storageId)
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
storageGeneration++;
|
|
1428
|
+
await Promise.allSettled(localWrites);
|
|
1429
|
+
await clearPendingShareCandidates(storage, storageId);
|
|
1430
|
+
return storage.clearAllShares(storageId);
|
|
459
1431
|
},
|
|
460
1432
|
|
|
461
1433
|
async splitKey(privateKey: string): Promise<{ localKey: string; remoteKey: string }> {
|
|
@@ -492,12 +1464,219 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
492
1464
|
return result.healthy;
|
|
493
1465
|
},
|
|
494
1466
|
|
|
1467
|
+
async getFreshDidAuthVp(
|
|
1468
|
+
privateKey: string,
|
|
1469
|
+
did: string,
|
|
1470
|
+
signDidAuthVp: DidAuthVpSigner
|
|
1471
|
+
): Promise<string> {
|
|
1472
|
+
return requestFreshDidAuthVp(serverUrl, privateKey, did, signDidAuthVp, tenantId);
|
|
1473
|
+
},
|
|
1474
|
+
|
|
1475
|
+
async atomicUpdateShares(params): Promise<void> {
|
|
1476
|
+
const storage = guardedStorage();
|
|
1477
|
+
const result = await persistSharesAtomically(
|
|
1478
|
+
params.privateKey,
|
|
1479
|
+
serverUrl,
|
|
1480
|
+
params.token,
|
|
1481
|
+
params.providerType,
|
|
1482
|
+
params.did,
|
|
1483
|
+
storage,
|
|
1484
|
+
activeStorageId,
|
|
1485
|
+
undefined,
|
|
1486
|
+
params.signDidAuthVp,
|
|
1487
|
+
tenantId,
|
|
1488
|
+
recordPromotion(params.did),
|
|
1489
|
+
captureActiveUserGuard()
|
|
1490
|
+
);
|
|
1491
|
+
|
|
1492
|
+
lastEmailShare = result.shares.emailShare;
|
|
1493
|
+
lastShareVersion = result.shareVersion;
|
|
1494
|
+
lastServerSnapshot = {
|
|
1495
|
+
currentVersion: result.shareVersion,
|
|
1496
|
+
resolvedVersion: result.shareVersion,
|
|
1497
|
+
authShare: result.shares.authShare,
|
|
1498
|
+
primaryDid: params.did,
|
|
1499
|
+
};
|
|
1500
|
+
},
|
|
1501
|
+
|
|
1502
|
+
async reconcileShares(params): Promise<RecoveryResult | null> {
|
|
1503
|
+
const storage = guardedStorage();
|
|
1504
|
+
const storageId = activeStorageId;
|
|
1505
|
+
const assertActiveUser = (): void => {
|
|
1506
|
+
if (activeStorageId !== storageId) {
|
|
1507
|
+
throw new AtomicUpdateError(
|
|
1508
|
+
'Active account changed during reconciliation',
|
|
1509
|
+
'verify_stored',
|
|
1510
|
+
false
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
1513
|
+
};
|
|
1514
|
+
return withShareUpdateLock(storage, storageId, async () => {
|
|
1515
|
+
let deviceShare = await storage.getDeviceShare(storageId);
|
|
1516
|
+
const pending = await readPendingShareCandidates(storage, storageId);
|
|
1517
|
+
|
|
1518
|
+
if (!deviceShare && !pending.length) return null;
|
|
1519
|
+
|
|
1520
|
+
const resolution = await resolvePendingShareCandidates(
|
|
1521
|
+
storage,
|
|
1522
|
+
storageId,
|
|
1523
|
+
async () => {
|
|
1524
|
+
const data = await fetchAuthShareRaw(
|
|
1525
|
+
serverUrl,
|
|
1526
|
+
params.token,
|
|
1527
|
+
params.providerType,
|
|
1528
|
+
undefined,
|
|
1529
|
+
tenantId
|
|
1530
|
+
);
|
|
1531
|
+
return {
|
|
1532
|
+
currentVersion: data?.shareVersion ?? null,
|
|
1533
|
+
authShare: authShareToString(data?.authShare),
|
|
1534
|
+
};
|
|
1535
|
+
},
|
|
1536
|
+
params.expectedDid,
|
|
1537
|
+
params.didFromPrivateKey,
|
|
1538
|
+
assertActiveUser,
|
|
1539
|
+
recordPromotion(params.expectedDid),
|
|
1540
|
+
async version =>
|
|
1541
|
+
authShareToString(
|
|
1542
|
+
(
|
|
1543
|
+
await fetchAuthShareRaw(
|
|
1544
|
+
serverUrl,
|
|
1545
|
+
params.token,
|
|
1546
|
+
params.providerType,
|
|
1547
|
+
version,
|
|
1548
|
+
tenantId
|
|
1549
|
+
)
|
|
1550
|
+
)?.authShare
|
|
1551
|
+
)
|
|
1552
|
+
);
|
|
1553
|
+
assertActiveUser();
|
|
1554
|
+
if (resolution.privateKey) {
|
|
1555
|
+
return { privateKey: resolution.privateKey, did: params.expectedDid };
|
|
1556
|
+
}
|
|
1557
|
+
const { currentVersion, authShare: currentAuthShare } = resolution.snapshot;
|
|
1558
|
+
if (currentVersion === null && !currentAuthShare) return null;
|
|
1559
|
+
|
|
1560
|
+
// Resolution may have recovered a historical candidate without a main share.
|
|
1561
|
+
deviceShare = await storage.getDeviceShare(storageId);
|
|
1562
|
+
const localVersion = await storage.getShareVersion(storageId);
|
|
1563
|
+
if (!deviceShare) return null;
|
|
1564
|
+
const candidates = new Map<number, string>();
|
|
1565
|
+
|
|
1566
|
+
const addCandidate = (version: number | null, authShare: string | null): void => {
|
|
1567
|
+
if (version !== null && authShare && !candidates.has(version)) {
|
|
1568
|
+
candidates.set(version, authShare);
|
|
1569
|
+
}
|
|
1570
|
+
};
|
|
1571
|
+
|
|
1572
|
+
addCandidate(
|
|
1573
|
+
lastServerSnapshot?.resolvedVersion ?? null,
|
|
1574
|
+
lastServerSnapshot?.authShare ?? null
|
|
1575
|
+
);
|
|
1576
|
+
addCandidate(currentVersion, currentAuthShare);
|
|
1577
|
+
|
|
1578
|
+
const versionsToTry = new Set<number>();
|
|
1579
|
+
if (localVersion !== null) versionsToTry.add(localVersion);
|
|
1580
|
+
|
|
1581
|
+
if (currentVersion !== null) {
|
|
1582
|
+
for (
|
|
1583
|
+
let version = currentVersion;
|
|
1584
|
+
version >= 1 && version >= currentVersion - MAX_RECONCILIATION_HISTORY;
|
|
1585
|
+
version--
|
|
1586
|
+
) {
|
|
1587
|
+
versionsToTry.add(version);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
for (const version of versionsToTry) {
|
|
1592
|
+
if (candidates.has(version)) continue;
|
|
1593
|
+
|
|
1594
|
+
const data = await fetchAuthShareRaw(
|
|
1595
|
+
serverUrl,
|
|
1596
|
+
params.token,
|
|
1597
|
+
params.providerType,
|
|
1598
|
+
version,
|
|
1599
|
+
tenantId
|
|
1600
|
+
);
|
|
1601
|
+
|
|
1602
|
+
addCandidate(version, authShareToString(data?.authShare));
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
for (const [candidateVersion, authShare] of candidates) {
|
|
1606
|
+
const health = await verifyStoredShares(
|
|
1607
|
+
{
|
|
1608
|
+
getDevice: async () => deviceShare,
|
|
1609
|
+
getAuth: async () => authShare,
|
|
1610
|
+
},
|
|
1611
|
+
params.expectedDid,
|
|
1612
|
+
params.didFromPrivateKey
|
|
1613
|
+
);
|
|
1614
|
+
|
|
1615
|
+
if (!health.healthy) continue;
|
|
1616
|
+
|
|
1617
|
+
assertActiveUser();
|
|
1618
|
+
|
|
1619
|
+
const privateKey = await reconstructFromShares([deviceShare, authShare]);
|
|
1620
|
+
|
|
1621
|
+
if (candidateVersion === currentVersion) {
|
|
1622
|
+
if (localVersion !== currentVersion) {
|
|
1623
|
+
await storage.storeShareVersion(candidateVersion, storageId);
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
assertActiveUser();
|
|
1627
|
+
lastServerSnapshot = {
|
|
1628
|
+
currentVersion,
|
|
1629
|
+
resolvedVersion: candidateVersion,
|
|
1630
|
+
authShare,
|
|
1631
|
+
primaryDid: params.expectedDid,
|
|
1632
|
+
};
|
|
1633
|
+
|
|
1634
|
+
return localVersion === currentVersion
|
|
1635
|
+
? null
|
|
1636
|
+
: { privateKey, did: params.expectedDid };
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// The local device share only matches a historical server share.
|
|
1640
|
+
// Re-split and atomically advance both stores to one fresh version.
|
|
1641
|
+
const result = await persistSharesWithoutLock(
|
|
1642
|
+
privateKey,
|
|
1643
|
+
serverUrl,
|
|
1644
|
+
params.token,
|
|
1645
|
+
params.providerType,
|
|
1646
|
+
params.expectedDid,
|
|
1647
|
+
storage,
|
|
1648
|
+
storageId,
|
|
1649
|
+
params.didFromPrivateKey,
|
|
1650
|
+
params.signDidAuthVp,
|
|
1651
|
+
tenantId,
|
|
1652
|
+
recordPromotion(params.expectedDid),
|
|
1653
|
+
assertActiveUser
|
|
1654
|
+
);
|
|
1655
|
+
|
|
1656
|
+
assertActiveUser();
|
|
1657
|
+
lastEmailShare = result.shares.emailShare;
|
|
1658
|
+
lastShareVersion = result.shareVersion;
|
|
1659
|
+
lastServerSnapshot = {
|
|
1660
|
+
currentVersion: result.shareVersion,
|
|
1661
|
+
resolvedVersion: result.shareVersion,
|
|
1662
|
+
authShare: result.shares.authShare,
|
|
1663
|
+
primaryDid: params.expectedDid,
|
|
1664
|
+
};
|
|
1665
|
+
|
|
1666
|
+
return { privateKey, did: params.expectedDid };
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
return null;
|
|
1670
|
+
});
|
|
1671
|
+
},
|
|
1672
|
+
|
|
495
1673
|
// --- Server communication ---
|
|
496
1674
|
|
|
497
1675
|
async fetchServerKeyStatus(
|
|
498
1676
|
token: string,
|
|
499
1677
|
providerType: AuthProviderType
|
|
500
1678
|
): Promise<ServerKeyStatus> {
|
|
1679
|
+
const storage = guardedStorage();
|
|
501
1680
|
// Pass the local device share's version so the server returns the matching auth share
|
|
502
1681
|
const localVersion = await storage.getShareVersion(activeStorageId);
|
|
503
1682
|
|
|
@@ -510,9 +1689,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
510
1689
|
);
|
|
511
1690
|
|
|
512
1691
|
if (!data) {
|
|
513
|
-
|
|
514
|
-
// Prevents stale state from a previous user session.
|
|
515
|
-
hasRecoveryEmail = false;
|
|
1692
|
+
lastServerSnapshot = null;
|
|
516
1693
|
|
|
517
1694
|
return {
|
|
518
1695
|
exists: false,
|
|
@@ -522,32 +1699,36 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
522
1699
|
authShare: null,
|
|
523
1700
|
shareVersion: null,
|
|
524
1701
|
maskedRecoveryEmail: null,
|
|
1702
|
+
sssActivationState: null,
|
|
525
1703
|
};
|
|
526
1704
|
}
|
|
527
1705
|
|
|
528
1706
|
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;
|
|
1707
|
+
const authShareString = authShareToString(rawAuthShare);
|
|
536
1708
|
|
|
537
1709
|
const serverVersion = data.shareVersion ?? null;
|
|
538
1710
|
|
|
539
1711
|
// Version repair: if the server knows the version but local storage
|
|
540
1712
|
// doesn't (e.g. account created before versioning was added), backfill
|
|
541
1713
|
// it so QR device-link transfers always include the version.
|
|
542
|
-
if (
|
|
1714
|
+
if (
|
|
1715
|
+
// Version 0 means "no auth share yet" and must never be pinned locally.
|
|
1716
|
+
serverVersion != null &&
|
|
1717
|
+
serverVersion > 0 &&
|
|
1718
|
+
localVersion == null &&
|
|
1719
|
+
!(await readPendingShareCandidates(storage, activeStorageId)).length
|
|
1720
|
+
) {
|
|
543
1721
|
storage
|
|
544
1722
|
.storeShareVersion(serverVersion, activeStorageId)
|
|
545
1723
|
.catch(e => console.warn('SSS: failed to backfill local shareVersion', e));
|
|
546
1724
|
}
|
|
547
1725
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
1726
|
+
lastServerSnapshot = {
|
|
1727
|
+
currentVersion: serverVersion,
|
|
1728
|
+
resolvedVersion: authShareString ? (localVersion ?? serverVersion) : null,
|
|
1729
|
+
authShare: authShareString,
|
|
1730
|
+
primaryDid: data.primaryDid || null,
|
|
1731
|
+
};
|
|
551
1732
|
|
|
552
1733
|
return {
|
|
553
1734
|
exists: !!rawAuthShare || !!data.keyProvider || !!data.primaryDid,
|
|
@@ -557,6 +1738,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
557
1738
|
authShare: authShareString,
|
|
558
1739
|
shareVersion: serverVersion,
|
|
559
1740
|
maskedRecoveryEmail: data.maskedRecoveryEmail ?? null,
|
|
1741
|
+
escrowOptedOut: data.escrowOptedOut === true,
|
|
1742
|
+
escrowPin: data.escrowPin,
|
|
1743
|
+
sssActivationState: data.sssActivationState ?? 'active',
|
|
560
1744
|
};
|
|
561
1745
|
},
|
|
562
1746
|
|
|
@@ -567,49 +1751,323 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
567
1751
|
primaryDid: string,
|
|
568
1752
|
didAuthVp?: string
|
|
569
1753
|
): Promise<void> {
|
|
570
|
-
const
|
|
1754
|
+
const storage = guardedStorage();
|
|
1755
|
+
const { shareVersion } = await withRotationLock(() =>
|
|
1756
|
+
putAuthShare(
|
|
1757
|
+
serverUrl,
|
|
1758
|
+
token,
|
|
1759
|
+
providerType,
|
|
1760
|
+
authShare,
|
|
1761
|
+
primaryDid,
|
|
1762
|
+
didAuthVp,
|
|
1763
|
+
tenantId
|
|
1764
|
+
)
|
|
1765
|
+
);
|
|
1766
|
+
|
|
1767
|
+
// Persist the version alongside the device share so we can request
|
|
1768
|
+
// the matching auth share on next login.
|
|
1769
|
+
await storage.storeShareVersion(shareVersion, activeStorageId);
|
|
1770
|
+
|
|
1771
|
+
// Cache for the upcoming sendEmailBackupShare call
|
|
1772
|
+
lastShareVersion = shareVersion;
|
|
1773
|
+
},
|
|
1774
|
+
|
|
1775
|
+
async markMigrated(
|
|
1776
|
+
token: string,
|
|
1777
|
+
providerType: AuthProviderType,
|
|
1778
|
+
didAuthVp?: string
|
|
1779
|
+
): Promise<void> {
|
|
1780
|
+
const response = await fetch(`${serverUrl}/keys/migrate`, {
|
|
1781
|
+
method: 'POST',
|
|
1782
|
+
headers: buildHeaders(token, didAuthVp, tenantId),
|
|
1783
|
+
body: JSON.stringify({ authToken: token, providerType }),
|
|
1784
|
+
});
|
|
1785
|
+
|
|
1786
|
+
if (!response.ok) {
|
|
1787
|
+
throw new Error(`Failed to mark migrated: ${describeHttpStatus(response)}`);
|
|
1788
|
+
}
|
|
1789
|
+
},
|
|
1790
|
+
|
|
1791
|
+
async activate(
|
|
1792
|
+
token: string,
|
|
1793
|
+
providerType: AuthProviderType,
|
|
1794
|
+
didAuthVp?: string
|
|
1795
|
+
): Promise<void> {
|
|
1796
|
+
const response = await fetch(`${serverUrl}/keys/activate`, {
|
|
1797
|
+
method: 'POST',
|
|
1798
|
+
headers: buildHeaders(token, didAuthVp, tenantId),
|
|
1799
|
+
body: JSON.stringify({ authToken: token, providerType }),
|
|
1800
|
+
});
|
|
1801
|
+
|
|
1802
|
+
if (!response.ok) {
|
|
1803
|
+
throw new Error(`Failed to activate SSS key: ${describeHttpStatus(response)}`);
|
|
1804
|
+
}
|
|
1805
|
+
},
|
|
1806
|
+
|
|
1807
|
+
// --- Recovery execution ---
|
|
1808
|
+
|
|
1809
|
+
async getEscrowEnrollmentState(params): Promise<EscrowEnrollmentState> {
|
|
1810
|
+
if (!config.escrow?.enabled) return { state: 'disabled' };
|
|
1811
|
+
const status = await this.fetchServerKeyStatus(params.token, params.providerType);
|
|
1812
|
+
if (status.escrowOptedOut) return { state: 'opted-out', escrowPin: status.escrowPin };
|
|
1813
|
+
const state =
|
|
1814
|
+
status.shareVersion !== null &&
|
|
1815
|
+
status.recoveryMethods.some(
|
|
1816
|
+
method =>
|
|
1817
|
+
method.type === 'escrow' &&
|
|
1818
|
+
method.shareVersion === status.shareVersion &&
|
|
1819
|
+
(Boolean(method.confirmedAt) ||
|
|
1820
|
+
('confirmationStatus' in method &&
|
|
1821
|
+
method.confirmationStatus === 'confirmed'))
|
|
1822
|
+
)
|
|
1823
|
+
? 'enrolled'
|
|
1824
|
+
: 'not-enrolled';
|
|
1825
|
+
return { state, escrowPin: status.escrowPin };
|
|
1826
|
+
},
|
|
1827
|
+
|
|
1828
|
+
async disableEscrowRecovery(params): Promise<void> {
|
|
1829
|
+
const status = await this.fetchServerKeyStatus(params.token, params.providerType);
|
|
1830
|
+
if (!status.primaryDid) throw new Error('Cannot disable escrow without a primary DID');
|
|
1831
|
+
const vp = await requestFreshDidAuthVp(
|
|
571
1832
|
serverUrl,
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
primaryDid,
|
|
576
|
-
didAuthVp,
|
|
1833
|
+
params.privateKey,
|
|
1834
|
+
status.primaryDid,
|
|
1835
|
+
params.signDidAuthVp,
|
|
577
1836
|
tenantId
|
|
578
1837
|
);
|
|
1838
|
+
await escrowRequest('', {
|
|
1839
|
+
method: 'DELETE',
|
|
1840
|
+
headers: buildHeaders(params.token, vp, tenantId),
|
|
1841
|
+
body: JSON.stringify({
|
|
1842
|
+
authToken: params.token,
|
|
1843
|
+
providerType: params.providerType,
|
|
1844
|
+
optOut: true,
|
|
1845
|
+
}),
|
|
1846
|
+
});
|
|
1847
|
+
},
|
|
1848
|
+
|
|
1849
|
+
async enableEscrowRecovery(params) {
|
|
1850
|
+
const storage = guardedStorage();
|
|
1851
|
+
return withRotationLock(async () => {
|
|
1852
|
+
if (!config.escrow?.enabled)
|
|
1853
|
+
return { enrolled: false as const, reason: 'disabled' as const };
|
|
1854
|
+
const status = await this.fetchServerKeyStatus(params.token, params.providerType);
|
|
1855
|
+
if (!status.primaryDid)
|
|
1856
|
+
throw new Error('Cannot enable escrow without a primary DID');
|
|
1857
|
+
const vp = await requestFreshDidAuthVp(
|
|
1858
|
+
serverUrl,
|
|
1859
|
+
params.privateKey,
|
|
1860
|
+
status.primaryDid,
|
|
1861
|
+
params.signDidAuthVp,
|
|
1862
|
+
tenantId
|
|
1863
|
+
);
|
|
1864
|
+
await escrowRequest('/opt-in', {
|
|
1865
|
+
method: 'POST',
|
|
1866
|
+
headers: buildHeaders(params.token, vp, tenantId),
|
|
1867
|
+
body: JSON.stringify({
|
|
1868
|
+
authToken: params.token,
|
|
1869
|
+
providerType: params.providerType,
|
|
1870
|
+
}),
|
|
1871
|
+
});
|
|
1872
|
+
return ensureEscrowEnrollmentUnguarded(this, params, storage);
|
|
1873
|
+
});
|
|
1874
|
+
},
|
|
1875
|
+
|
|
1876
|
+
async setEscrowPin({ pin, ...params }): Promise<void> {
|
|
1877
|
+
const storage = guardedStorage();
|
|
1878
|
+
const result = await withRotationLock(() =>
|
|
1879
|
+
ensureEscrowEnrollmentUnguarded(this, { ...params, options: { pin } }, storage)
|
|
1880
|
+
);
|
|
1881
|
+
if (!result.enrolled) {
|
|
1882
|
+
throw new Error('Automatic recovery is not available for this account.');
|
|
1883
|
+
}
|
|
1884
|
+
},
|
|
1885
|
+
|
|
1886
|
+
async clearEscrowPin(params): Promise<void> {
|
|
1887
|
+
const storage = guardedStorage();
|
|
1888
|
+
const result = await withRotationLock(() =>
|
|
1889
|
+
ensureEscrowEnrollmentUnguarded(this, params, storage, true)
|
|
1890
|
+
);
|
|
1891
|
+
if (!result.enrolled) {
|
|
1892
|
+
throw new Error('Automatic recovery is not available for this account.');
|
|
1893
|
+
}
|
|
1894
|
+
},
|
|
1895
|
+
|
|
1896
|
+
/** Enroll missing/stale escrow material using the standard atomic rotation. */
|
|
1897
|
+
async ensureEscrowEnrollment(params): Promise<EscrowEnrollmentResult> {
|
|
1898
|
+
const storage = guardedStorage();
|
|
1899
|
+
if (params.options?.pin !== undefined)
|
|
1900
|
+
return withRotationLock(() =>
|
|
1901
|
+
ensureEscrowEnrollmentUnguarded(this, params, storage)
|
|
1902
|
+
);
|
|
1903
|
+
if (escrowEnrollmentInFlight) return escrowEnrollmentInFlight;
|
|
1904
|
+
escrowEnrollmentInFlight = withRotationLock(() =>
|
|
1905
|
+
ensureEscrowEnrollmentUnguarded(this, params, storage)
|
|
1906
|
+
);
|
|
1907
|
+
try {
|
|
1908
|
+
return await escrowEnrollmentInFlight;
|
|
1909
|
+
} finally {
|
|
1910
|
+
escrowEnrollmentInFlight = undefined;
|
|
1911
|
+
}
|
|
1912
|
+
},
|
|
1913
|
+
|
|
1914
|
+
/** Start or explicitly restart a hold with fresh client-bound resume secrets. */
|
|
1915
|
+
async startEscrowRecovery(params): Promise<EscrowRecoveryStart> {
|
|
1916
|
+
const sessionProof = params.recoverySessionToken !== undefined;
|
|
1917
|
+
if (
|
|
1918
|
+
sessionProof
|
|
1919
|
+
? params.token !== undefined || params.providerType !== undefined
|
|
1920
|
+
: !params.token || !params.providerType
|
|
1921
|
+
) {
|
|
1922
|
+
throw new Error('Provide exactly one identity proof');
|
|
1923
|
+
}
|
|
1924
|
+
const pair = await generateEscrowKeyPair();
|
|
1925
|
+
const result = await escrowRequest<
|
|
1926
|
+
Omit<EscrowRecoveryStart, 'clientEphemeralPrivateKey'>
|
|
1927
|
+
>(
|
|
1928
|
+
'/recover',
|
|
1929
|
+
{
|
|
1930
|
+
method: 'POST',
|
|
1931
|
+
headers: buildHeaders('', undefined, params.tenantId ?? tenantId),
|
|
1932
|
+
body: JSON.stringify({
|
|
1933
|
+
clientEphemeralPublicKey: pair.publicKey,
|
|
1934
|
+
...(params.options?.restart ? { restart: true } : {}),
|
|
1935
|
+
...(params.options?.releasePolicy
|
|
1936
|
+
? { releasePolicy: params.options.releasePolicy }
|
|
1937
|
+
: {}),
|
|
1938
|
+
...(sessionProof
|
|
1939
|
+
? { recoverySessionToken: params.recoverySessionToken }
|
|
1940
|
+
: { authToken: params.token, providerType: params.providerType }),
|
|
1941
|
+
}),
|
|
1942
|
+
},
|
|
1943
|
+
params.options?.releasePolicy === 'pin'
|
|
1944
|
+
);
|
|
1945
|
+
return { ...result, clientEphemeralPrivateKey: pair.privateKey };
|
|
1946
|
+
},
|
|
579
1947
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
1948
|
+
/** Read hold status; secrets (auth or resume token) never travel in a URL. */
|
|
1949
|
+
async getEscrowRecoveryStatus(params): Promise<EscrowHoldStatus | null> {
|
|
1950
|
+
const query = new URLSearchParams(
|
|
1951
|
+
'holdId' in params
|
|
1952
|
+
? { holdId: params.holdId }
|
|
1953
|
+
: { providerType: params.providerType }
|
|
1954
|
+
);
|
|
1955
|
+
const secret = 'holdId' in params ? params.resumeToken : params.token;
|
|
1956
|
+
const result = await escrowRequest<{ hold: EscrowHoldStatus | null }>(
|
|
1957
|
+
`/status?${query}`,
|
|
1958
|
+
{
|
|
1959
|
+
method: 'GET',
|
|
1960
|
+
headers: {
|
|
1961
|
+
...buildHeaders('', undefined, tenantId),
|
|
1962
|
+
'X-Auth-Token': secret,
|
|
1963
|
+
},
|
|
1964
|
+
}
|
|
1965
|
+
);
|
|
1966
|
+
return result.hold;
|
|
586
1967
|
},
|
|
587
1968
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
1969
|
+
/** Cancel a pending hold with a fresh DID-owner proof. */
|
|
1970
|
+
async cancelEscrowRecovery(params): Promise<{ cancelled: boolean }> {
|
|
1971
|
+
const status = await this.fetchServerKeyStatus(params.token, params.providerType);
|
|
1972
|
+
if (!status.primaryDid) throw new Error('Cannot cancel escrow without a primary DID');
|
|
1973
|
+
const vp = await requestFreshDidAuthVp(
|
|
1974
|
+
serverUrl,
|
|
1975
|
+
params.privateKey,
|
|
1976
|
+
status.primaryDid,
|
|
1977
|
+
params.signDidAuthVp,
|
|
1978
|
+
tenantId
|
|
1979
|
+
);
|
|
1980
|
+
const result = await escrowRequest<{ cancelled: boolean }>('/cancel', {
|
|
594
1981
|
method: 'POST',
|
|
595
|
-
headers: buildHeaders(token,
|
|
596
|
-
body: JSON.stringify({
|
|
1982
|
+
headers: buildHeaders(params.token, vp, tenantId),
|
|
1983
|
+
body: JSON.stringify({
|
|
1984
|
+
authToken: params.token,
|
|
1985
|
+
providerType: params.providerType,
|
|
1986
|
+
}),
|
|
597
1987
|
});
|
|
598
|
-
|
|
599
|
-
if (!response.ok) {
|
|
600
|
-
throw new Error(`Failed to mark migrated: ${response.statusText}`);
|
|
601
|
-
}
|
|
1988
|
+
return { cancelled: result.cancelled };
|
|
602
1989
|
},
|
|
603
1990
|
|
|
604
|
-
// --- Recovery execution ---
|
|
605
|
-
|
|
606
1991
|
async executeRecovery(params: {
|
|
607
1992
|
token: string;
|
|
608
1993
|
providerType: AuthProviderType;
|
|
609
1994
|
input: RecoveryInput;
|
|
610
1995
|
didFromPrivateKey?: (privateKey: string) => Promise<string>;
|
|
1996
|
+
signDidAuthVp?: DidAuthVpSigner;
|
|
611
1997
|
}): Promise<RecoveryResult> {
|
|
612
|
-
const
|
|
1998
|
+
const storage = guardedStorage();
|
|
1999
|
+
const { token, providerType, input, didFromPrivateKey, signDidAuthVp } = params;
|
|
2000
|
+
|
|
2001
|
+
if (input.method === 'escrow' || input.method === 'escrow-pin') {
|
|
2002
|
+
if (!didFromPrivateKey)
|
|
2003
|
+
throw new Error('DID verification is required for escrow recovery');
|
|
2004
|
+
// Each PIN call owns a fresh, single-use hold and in-memory key pair.
|
|
2005
|
+
// Never persist this key or retry completion after a burned attempt.
|
|
2006
|
+
const start =
|
|
2007
|
+
input.method === 'escrow-pin'
|
|
2008
|
+
? await this.startEscrowRecovery!({
|
|
2009
|
+
token,
|
|
2010
|
+
providerType,
|
|
2011
|
+
options: { releasePolicy: 'pin' },
|
|
2012
|
+
})
|
|
2013
|
+
: input;
|
|
2014
|
+
let pinProof: string | undefined;
|
|
2015
|
+
if (input.method === 'escrow-pin') {
|
|
2016
|
+
if (!('pinSalt' in start) || !start.pinSalt || !start.resumeToken)
|
|
2017
|
+
throw new Error('PIN recovery response is missing required material');
|
|
2018
|
+
pinProof = await derivePinProof(input.pin, start.pinSalt);
|
|
2019
|
+
}
|
|
2020
|
+
const material = await escrowRequest<
|
|
2021
|
+
IdentityRecoveryMaterialResponse & { sealedShare: unknown }
|
|
2022
|
+
>(
|
|
2023
|
+
'/complete',
|
|
2024
|
+
{
|
|
2025
|
+
method: 'POST',
|
|
2026
|
+
headers: buildHeaders('', undefined, tenantId),
|
|
2027
|
+
body: JSON.stringify({
|
|
2028
|
+
holdId: start.holdId,
|
|
2029
|
+
resumeToken: start.resumeToken,
|
|
2030
|
+
...(pinProof ? { pinProof } : {}),
|
|
2031
|
+
}),
|
|
2032
|
+
},
|
|
2033
|
+
input.method === 'escrow-pin'
|
|
2034
|
+
);
|
|
2035
|
+
const plaintext = await openEscrowRelease(
|
|
2036
|
+
material.sealedShare,
|
|
2037
|
+
start.clientEphemeralPrivateKey
|
|
2038
|
+
);
|
|
2039
|
+
if (
|
|
2040
|
+
plaintext.holdId !== start.holdId ||
|
|
2041
|
+
plaintext.did !== material.primaryDid ||
|
|
2042
|
+
plaintext.shareVersion !== material.shareVersion
|
|
2043
|
+
) {
|
|
2044
|
+
throw new Error('Escrow recovery material does not match the request');
|
|
2045
|
+
}
|
|
2046
|
+
const authShare = material.authShare.encryptedData;
|
|
2047
|
+
const privateKey = await reconstructFromShares([
|
|
2048
|
+
plaintext.recoveryShare,
|
|
2049
|
+
authShare,
|
|
2050
|
+
]);
|
|
2051
|
+
if (
|
|
2052
|
+
!material.primaryDid ||
|
|
2053
|
+
(await didFromPrivateKey(privateKey)) !== material.primaryDid
|
|
2054
|
+
) {
|
|
2055
|
+
throw new Error(
|
|
2056
|
+
'Recovery produced an incorrect key. Try another recovery method.'
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
pendingIdentityRecovery = {
|
|
2060
|
+
privateKey,
|
|
2061
|
+
primaryDid: material.primaryDid,
|
|
2062
|
+
recoveryShare: plaintext.recoveryShare,
|
|
2063
|
+
authShare,
|
|
2064
|
+
rebindSessionToken: material.rebindSessionToken,
|
|
2065
|
+
};
|
|
2066
|
+
// The OTP path uses exactly this pending state and atomic rebind operation.
|
|
2067
|
+
// Without a provider session the caller completes it after signing in.
|
|
2068
|
+
if (!token) return { privateKey, did: material.primaryDid };
|
|
2069
|
+
return this.completeIdentityRecovery!({ token, providerType, signDidAuthVp });
|
|
2070
|
+
}
|
|
613
2071
|
|
|
614
2072
|
let recoveryShare: string;
|
|
615
2073
|
let recoveryShareVersion: number | undefined;
|
|
@@ -745,18 +2203,45 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
745
2203
|
}
|
|
746
2204
|
}
|
|
747
2205
|
|
|
748
|
-
// Step 3:
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
2206
|
+
// Step 3: Re-split and persist both sides atomically. The old auth
|
|
2207
|
+
// share remains in server history, so existing versioned recovery
|
|
2208
|
+
// methods continue to work while this device advances to a fresh pair.
|
|
2209
|
+
const { shares, shareVersion } = await persistSharesAtomically(
|
|
2210
|
+
privateKey,
|
|
2211
|
+
serverUrl,
|
|
2212
|
+
token,
|
|
2213
|
+
providerType,
|
|
2214
|
+
primaryDid,
|
|
2215
|
+
storage,
|
|
2216
|
+
activeStorageId,
|
|
2217
|
+
didFromPrivateKey,
|
|
2218
|
+
signDidAuthVp,
|
|
2219
|
+
tenantId,
|
|
2220
|
+
recordPromotion(primaryDid),
|
|
2221
|
+
captureActiveUserGuard()
|
|
2222
|
+
);
|
|
754
2223
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
2224
|
+
lastEmailShare = shares.emailShare;
|
|
2225
|
+
lastShareVersion = shareVersion;
|
|
2226
|
+
lastServerSnapshot = {
|
|
2227
|
+
currentVersion: shareVersion,
|
|
2228
|
+
resolvedVersion: shareVersion,
|
|
2229
|
+
authShare: shares.authShare,
|
|
2230
|
+
primaryDid,
|
|
2231
|
+
};
|
|
759
2232
|
|
|
2233
|
+
// Non-escrow methods never exposed the PIN, so let the server carry it to the
|
|
2234
|
+
// new version. Escrow recovery completes via completeIdentityRecovery, which
|
|
2235
|
+
// retires it.
|
|
2236
|
+
await tryEnrollEscrow(
|
|
2237
|
+
token,
|
|
2238
|
+
providerType,
|
|
2239
|
+
privateKey,
|
|
2240
|
+
primaryDid,
|
|
2241
|
+
shares,
|
|
2242
|
+
shareVersion,
|
|
2243
|
+
signDidAuthVp
|
|
2244
|
+
);
|
|
760
2245
|
return { privateKey, did: primaryDid };
|
|
761
2246
|
},
|
|
762
2247
|
|
|
@@ -768,10 +2253,35 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
768
2253
|
privateKey: string;
|
|
769
2254
|
input: RecoverySetupInput;
|
|
770
2255
|
authUser?: AuthUser;
|
|
771
|
-
signDidAuthVp?:
|
|
2256
|
+
signDidAuthVp?: DidAuthVpSigner;
|
|
772
2257
|
}): Promise<RecoverySetupResult> {
|
|
2258
|
+
const storage = guardedStorage();
|
|
773
2259
|
const { token, providerType, privateKey, input, authUser, signDidAuthVp } = params;
|
|
774
2260
|
|
|
2261
|
+
if (input.method === 'escrow' && input.pin !== undefined) {
|
|
2262
|
+
if (!signDidAuthVp)
|
|
2263
|
+
throw new Error('DID proof signing is required for escrow enrollment');
|
|
2264
|
+
const result = await this.enableEscrowRecovery!({
|
|
2265
|
+
token,
|
|
2266
|
+
providerType,
|
|
2267
|
+
privateKey,
|
|
2268
|
+
signDidAuthVp,
|
|
2269
|
+
options: { pin: input.pin },
|
|
2270
|
+
});
|
|
2271
|
+
if (!result.enrolled || !result.changed)
|
|
2272
|
+
throw new Error('Escrow enrollment is disabled');
|
|
2273
|
+
return { method: 'escrow', shareVersion: result.shareVersion };
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
if (input.method === 'escrow' && !config.escrow?.enabled) {
|
|
2277
|
+
throw new Error('Escrow enrollment is disabled');
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
// Escrow pre-flight: attest the enclave BEFORE rotating so a bad
|
|
2281
|
+
// attestation has no side effects (no version bump).
|
|
2282
|
+
const escrowKey =
|
|
2283
|
+
input.method === 'escrow' ? await fetchVerifiedEnclaveKey() : undefined;
|
|
2284
|
+
|
|
775
2285
|
// Passkey pre-flight: create the credential and verify PRF support
|
|
776
2286
|
// BEFORE any split/store/email work. If PRF isn't available, fail
|
|
777
2287
|
// cleanly without side effects (no version bump, no email re-send).
|
|
@@ -788,18 +2298,6 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
788
2298
|
passkeyCredential = await createPasskeyCredential(userId, userName);
|
|
789
2299
|
}
|
|
790
2300
|
|
|
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
2301
|
const serverData = await fetchAuthShareRaw(
|
|
804
2302
|
serverUrl,
|
|
805
2303
|
token,
|
|
@@ -809,52 +2307,48 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
809
2307
|
);
|
|
810
2308
|
const primaryDid = serverData?.primaryDid || '';
|
|
811
2309
|
|
|
812
|
-
|
|
2310
|
+
if (!primaryDid) throw new Error('Cannot set up recovery without a primary DID');
|
|
2311
|
+
|
|
2312
|
+
// Every recovery-method setup rotates device+auth shares through
|
|
2313
|
+
// the verified atomic path. A failed server write restores the
|
|
2314
|
+
// previous device share; an ack-loss skew is repaired on login.
|
|
2315
|
+
const { shares, shareVersion } = await persistSharesAtomically(
|
|
2316
|
+
privateKey,
|
|
813
2317
|
serverUrl,
|
|
814
2318
|
token,
|
|
815
2319
|
providerType,
|
|
816
|
-
shares.authShare,
|
|
817
2320
|
primaryDid,
|
|
818
|
-
|
|
819
|
-
|
|
2321
|
+
storage,
|
|
2322
|
+
activeStorageId,
|
|
2323
|
+
undefined,
|
|
2324
|
+
signDidAuthVp,
|
|
2325
|
+
tenantId,
|
|
2326
|
+
recordPromotion(primaryDid),
|
|
2327
|
+
captureActiveUserGuard()
|
|
820
2328
|
);
|
|
821
2329
|
|
|
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
|
-
}
|
|
2330
|
+
lastEmailShare = shares.emailShare;
|
|
2331
|
+
lastShareVersion = shareVersion;
|
|
2332
|
+
lastServerSnapshot = {
|
|
2333
|
+
currentVersion: shareVersion,
|
|
2334
|
+
resolvedVersion: shareVersion,
|
|
2335
|
+
authShare: shares.authShare,
|
|
2336
|
+
primaryDid,
|
|
2337
|
+
};
|
|
856
2338
|
|
|
857
2339
|
switch (input.method) {
|
|
2340
|
+
case 'escrow':
|
|
2341
|
+
await enrollEscrow(
|
|
2342
|
+
token,
|
|
2343
|
+
providerType,
|
|
2344
|
+
privateKey,
|
|
2345
|
+
primaryDid,
|
|
2346
|
+
shares,
|
|
2347
|
+
shareVersion,
|
|
2348
|
+
signDidAuthVp,
|
|
2349
|
+
escrowKey
|
|
2350
|
+
);
|
|
2351
|
+
return { method: 'escrow', shareVersion };
|
|
858
2352
|
case 'passkey': {
|
|
859
2353
|
// passkeyCredential was created in the pre-flight block above
|
|
860
2354
|
// (before any split/store work) so PRF is already validated.
|
|
@@ -878,10 +2372,44 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
878
2372
|
credentialId: credential.credentialId,
|
|
879
2373
|
shareVersion,
|
|
880
2374
|
},
|
|
881
|
-
|
|
2375
|
+
signDidAuthVp
|
|
2376
|
+
? await requestFreshDidAuthVp(
|
|
2377
|
+
serverUrl,
|
|
2378
|
+
privateKey,
|
|
2379
|
+
primaryDid,
|
|
2380
|
+
signDidAuthVp,
|
|
2381
|
+
tenantId
|
|
2382
|
+
)
|
|
2383
|
+
: undefined,
|
|
2384
|
+
tenantId
|
|
2385
|
+
);
|
|
2386
|
+
|
|
2387
|
+
await postRecoveryConfirmation(
|
|
2388
|
+
serverUrl,
|
|
2389
|
+
token,
|
|
2390
|
+
providerType,
|
|
2391
|
+
{ type: 'passkey', credentialId: credential.credentialId },
|
|
2392
|
+
signDidAuthVp
|
|
2393
|
+
? await requestFreshDidAuthVp(
|
|
2394
|
+
serverUrl,
|
|
2395
|
+
privateKey,
|
|
2396
|
+
primaryDid,
|
|
2397
|
+
signDidAuthVp,
|
|
2398
|
+
tenantId
|
|
2399
|
+
)
|
|
2400
|
+
: undefined,
|
|
882
2401
|
tenantId
|
|
883
2402
|
);
|
|
884
2403
|
|
|
2404
|
+
await tryEnrollEscrow(
|
|
2405
|
+
token,
|
|
2406
|
+
providerType,
|
|
2407
|
+
privateKey,
|
|
2408
|
+
primaryDid,
|
|
2409
|
+
shares,
|
|
2410
|
+
shareVersion,
|
|
2411
|
+
signDidAuthVp
|
|
2412
|
+
);
|
|
885
2413
|
return { method: 'passkey', credentialId: credential.credentialId };
|
|
886
2414
|
}
|
|
887
2415
|
|
|
@@ -899,11 +2427,42 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
899
2427
|
type: 'phrase',
|
|
900
2428
|
shareVersion,
|
|
901
2429
|
},
|
|
902
|
-
|
|
2430
|
+
signDidAuthVp
|
|
2431
|
+
? await requestFreshDidAuthVp(
|
|
2432
|
+
serverUrl,
|
|
2433
|
+
privateKey,
|
|
2434
|
+
primaryDid,
|
|
2435
|
+
signDidAuthVp,
|
|
2436
|
+
tenantId
|
|
2437
|
+
)
|
|
2438
|
+
: undefined,
|
|
903
2439
|
tenantId
|
|
904
2440
|
);
|
|
905
2441
|
|
|
906
|
-
|
|
2442
|
+
const challengeWordIndices = selectRecoveryPhraseChallengeIndices(
|
|
2443
|
+
phrase.split(/\s+/).length
|
|
2444
|
+
);
|
|
2445
|
+
const challengeWordOptions = await buildRecoveryPhraseChallengeOptions(
|
|
2446
|
+
phrase,
|
|
2447
|
+
challengeWordIndices
|
|
2448
|
+
);
|
|
2449
|
+
pendingPhraseConfirmation = { phrase, challengeWordIndices };
|
|
2450
|
+
|
|
2451
|
+
await tryEnrollEscrow(
|
|
2452
|
+
token,
|
|
2453
|
+
providerType,
|
|
2454
|
+
privateKey,
|
|
2455
|
+
primaryDid,
|
|
2456
|
+
shares,
|
|
2457
|
+
shareVersion,
|
|
2458
|
+
signDidAuthVp
|
|
2459
|
+
);
|
|
2460
|
+
return {
|
|
2461
|
+
method: 'phrase',
|
|
2462
|
+
phrase,
|
|
2463
|
+
challengeWordIndices,
|
|
2464
|
+
challengeWordOptions,
|
|
2465
|
+
};
|
|
907
2466
|
}
|
|
908
2467
|
|
|
909
2468
|
case 'backup': {
|
|
@@ -917,6 +2476,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
917
2476
|
createdAt: new Date().toISOString(),
|
|
918
2477
|
primaryDid: input.did,
|
|
919
2478
|
shareVersion,
|
|
2479
|
+
shareChecksum: await calculateShareChecksum(shares.recoveryShare),
|
|
920
2480
|
encryptedShare: {
|
|
921
2481
|
ciphertext: encrypted.ciphertext,
|
|
922
2482
|
iv: encrypted.iv,
|
|
@@ -935,47 +2495,160 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
935
2495
|
type: 'backup',
|
|
936
2496
|
shareVersion,
|
|
937
2497
|
},
|
|
938
|
-
|
|
2498
|
+
signDidAuthVp
|
|
2499
|
+
? await requestFreshDidAuthVp(
|
|
2500
|
+
serverUrl,
|
|
2501
|
+
privateKey,
|
|
2502
|
+
primaryDid,
|
|
2503
|
+
signDidAuthVp,
|
|
2504
|
+
tenantId
|
|
2505
|
+
)
|
|
2506
|
+
: undefined,
|
|
939
2507
|
tenantId
|
|
940
2508
|
);
|
|
941
2509
|
|
|
2510
|
+
pendingBackupConfirmation = {
|
|
2511
|
+
backupFile,
|
|
2512
|
+
recoveryShare: shares.recoveryShare,
|
|
2513
|
+
};
|
|
2514
|
+
|
|
2515
|
+
await tryEnrollEscrow(
|
|
2516
|
+
token,
|
|
2517
|
+
providerType,
|
|
2518
|
+
privateKey,
|
|
2519
|
+
primaryDid,
|
|
2520
|
+
shares,
|
|
2521
|
+
shareVersion,
|
|
2522
|
+
signDidAuthVp
|
|
2523
|
+
);
|
|
942
2524
|
return { method: 'backup', backupFile };
|
|
943
2525
|
}
|
|
944
2526
|
|
|
945
2527
|
case 'email': {
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
2528
|
+
const didAuthVp = signDidAuthVp
|
|
2529
|
+
? await requestFreshDidAuthVp(
|
|
2530
|
+
serverUrl,
|
|
2531
|
+
privateKey,
|
|
2532
|
+
primaryDid,
|
|
2533
|
+
signDidAuthVp,
|
|
2534
|
+
tenantId
|
|
2535
|
+
)
|
|
2536
|
+
: undefined;
|
|
2537
|
+
|
|
2538
|
+
await sendEmailBackupShare(
|
|
950
2539
|
serverUrl,
|
|
951
2540
|
token,
|
|
952
2541
|
providerType,
|
|
953
2542
|
shares.emailShare,
|
|
2543
|
+
input.email,
|
|
2544
|
+
escrowRelayPublicKey,
|
|
2545
|
+
escrowRelayKeyId,
|
|
2546
|
+
emailBranding,
|
|
954
2547
|
shareVersion,
|
|
2548
|
+
didAuthVp,
|
|
955
2549
|
tenantId
|
|
956
2550
|
);
|
|
957
2551
|
|
|
958
|
-
|
|
959
|
-
// getAvailableRecoveryMethods includes it.
|
|
960
|
-
await postRecoveryMethod(
|
|
961
|
-
serverUrl,
|
|
2552
|
+
await tryEnrollEscrow(
|
|
962
2553
|
token,
|
|
963
2554
|
providerType,
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
tenantId
|
|
2555
|
+
privateKey,
|
|
2556
|
+
primaryDid,
|
|
2557
|
+
shares,
|
|
2558
|
+
shareVersion,
|
|
2559
|
+
signDidAuthVp
|
|
970
2560
|
);
|
|
2561
|
+
return { method: 'email' };
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
},
|
|
971
2565
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
2566
|
+
async confirmRecoveryMethod(params: {
|
|
2567
|
+
token: string;
|
|
2568
|
+
providerType: AuthProviderType;
|
|
2569
|
+
privateKey: string;
|
|
2570
|
+
input: RecoveryConfirmationInput;
|
|
2571
|
+
signDidAuthVp?: DidAuthVpSigner;
|
|
2572
|
+
}): Promise<void> {
|
|
2573
|
+
const { token, providerType, privateKey, input, signDidAuthVp } = params;
|
|
2574
|
+
const primaryDid = lastServerSnapshot?.primaryDid;
|
|
975
2575
|
|
|
976
|
-
|
|
2576
|
+
if (!primaryDid) throw new Error('Cannot confirm recovery without a primary DID');
|
|
2577
|
+
|
|
2578
|
+
const confirmationBody: Record<string, unknown> = { type: input.method };
|
|
2579
|
+
|
|
2580
|
+
if (input.method === 'phrase') {
|
|
2581
|
+
const pending = pendingPhraseConfirmation;
|
|
2582
|
+
|
|
2583
|
+
if (!pending) throw new Error('Generate a new recovery phrase to continue');
|
|
2584
|
+
|
|
2585
|
+
const phraseWords = pending.phrase.trim().toLowerCase().split(/\s+/);
|
|
2586
|
+
const matches =
|
|
2587
|
+
input.challengeWords.length === pending.challengeWordIndices.length &&
|
|
2588
|
+
pending.challengeWordIndices.every(
|
|
2589
|
+
(wordIndex, answerIndex) =>
|
|
2590
|
+
input.challengeWords[answerIndex]?.trim().toLowerCase() ===
|
|
2591
|
+
phraseWords[wordIndex]
|
|
2592
|
+
);
|
|
2593
|
+
|
|
2594
|
+
if (!matches) {
|
|
2595
|
+
throw new Error('Those words do not match. Check your phrase and try again.');
|
|
2596
|
+
}
|
|
2597
|
+
} else if (input.method === 'backup') {
|
|
2598
|
+
const pending = pendingBackupConfirmation;
|
|
2599
|
+
|
|
2600
|
+
if (!pending) throw new Error('Generate a new backup file to continue');
|
|
2601
|
+
|
|
2602
|
+
let suppliedBackup: BackupFile;
|
|
2603
|
+
let decryptedShare: string;
|
|
2604
|
+
|
|
2605
|
+
try {
|
|
2606
|
+
suppliedBackup = JSON.parse(input.fileContents) as BackupFile;
|
|
2607
|
+
decryptedShare = await decryptWithPassword(
|
|
2608
|
+
suppliedBackup.encryptedShare.ciphertext,
|
|
2609
|
+
suppliedBackup.encryptedShare.iv,
|
|
2610
|
+
suppliedBackup.encryptedShare.salt,
|
|
2611
|
+
input.password,
|
|
2612
|
+
suppliedBackup.encryptedShare.kdfParams
|
|
2613
|
+
);
|
|
2614
|
+
} catch {
|
|
2615
|
+
throw new Error('Incorrect password or corrupted data. Please try again.');
|
|
977
2616
|
}
|
|
2617
|
+
|
|
2618
|
+
const checksum = await calculateShareChecksum(decryptedShare);
|
|
2619
|
+
|
|
2620
|
+
if (
|
|
2621
|
+
suppliedBackup.shareChecksum !== checksum ||
|
|
2622
|
+
pending.backupFile.shareChecksum !== checksum ||
|
|
2623
|
+
decryptedShare !== pending.recoveryShare
|
|
2624
|
+
) {
|
|
2625
|
+
throw new Error('This backup file could not be verified. Please try again.');
|
|
2626
|
+
}
|
|
2627
|
+
} else {
|
|
2628
|
+
confirmationBody.code = input.code;
|
|
978
2629
|
}
|
|
2630
|
+
|
|
2631
|
+
const didAuthVp = signDidAuthVp
|
|
2632
|
+
? await requestFreshDidAuthVp(
|
|
2633
|
+
serverUrl,
|
|
2634
|
+
privateKey,
|
|
2635
|
+
primaryDid,
|
|
2636
|
+
signDidAuthVp,
|
|
2637
|
+
tenantId
|
|
2638
|
+
)
|
|
2639
|
+
: undefined;
|
|
2640
|
+
|
|
2641
|
+
await postRecoveryConfirmation(
|
|
2642
|
+
serverUrl,
|
|
2643
|
+
token,
|
|
2644
|
+
providerType,
|
|
2645
|
+
confirmationBody,
|
|
2646
|
+
didAuthVp,
|
|
2647
|
+
tenantId
|
|
2648
|
+
);
|
|
2649
|
+
|
|
2650
|
+
if (input.method === 'phrase') pendingPhraseConfirmation = undefined;
|
|
2651
|
+
if (input.method === 'backup') pendingBackupConfirmation = undefined;
|
|
979
2652
|
},
|
|
980
2653
|
|
|
981
2654
|
async getAvailableRecoveryMethods(
|
|
@@ -990,26 +2663,323 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
990
2663
|
undefined,
|
|
991
2664
|
tenantId
|
|
992
2665
|
);
|
|
993
|
-
const methods = serverData?.recoveryMethods || []
|
|
2666
|
+
const methods = (serverData?.recoveryMethods || []) as Array<
|
|
2667
|
+
RecoveryMethodInfo & {
|
|
2668
|
+
confirmedAt?: Date | string;
|
|
2669
|
+
confirmationStatus?: 'pending' | 'confirmed';
|
|
2670
|
+
}
|
|
2671
|
+
>;
|
|
2672
|
+
const activationState = serverData?.sssActivationState ?? 'active';
|
|
994
2673
|
|
|
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
|
-
}
|
|
2674
|
+
return methods.filter(method => {
|
|
2675
|
+
if (method.confirmedAt) return true;
|
|
2676
|
+
if (method.confirmationStatus) return false;
|
|
1005
2677
|
|
|
1006
|
-
|
|
2678
|
+
return activationState === 'active';
|
|
2679
|
+
});
|
|
1007
2680
|
} catch (e) {
|
|
1008
2681
|
console.error('Error getting recovery methods:', e);
|
|
1009
2682
|
return [];
|
|
1010
2683
|
}
|
|
1011
2684
|
},
|
|
1012
2685
|
|
|
2686
|
+
async startIdentityRecovery(email: string): Promise<void> {
|
|
2687
|
+
await postJson<{ success: true }>(
|
|
2688
|
+
`${serverUrl}/keys/recovery-session/start`,
|
|
2689
|
+
{ email },
|
|
2690
|
+
buildHeaders('', undefined, tenantId)
|
|
2691
|
+
);
|
|
2692
|
+
},
|
|
2693
|
+
|
|
2694
|
+
async verifyIdentityRecovery(
|
|
2695
|
+
email: string,
|
|
2696
|
+
code: string
|
|
2697
|
+
): Promise<IdentityRecoverySession> {
|
|
2698
|
+
const result = await postJson<IdentityRecoverySessionResponse>(
|
|
2699
|
+
`${serverUrl}/keys/recovery-session/verify`,
|
|
2700
|
+
{ email, code },
|
|
2701
|
+
buildHeaders('', undefined, tenantId)
|
|
2702
|
+
);
|
|
2703
|
+
|
|
2704
|
+
return {
|
|
2705
|
+
...result,
|
|
2706
|
+
recoveryMethods: result.recoveryMethods.map(method => ({
|
|
2707
|
+
...method,
|
|
2708
|
+
createdAt: new Date(method.createdAt),
|
|
2709
|
+
})),
|
|
2710
|
+
};
|
|
2711
|
+
},
|
|
2712
|
+
|
|
2713
|
+
async prepareIdentityRecovery(params): Promise<RecoveryResult> {
|
|
2714
|
+
const { input } = params;
|
|
2715
|
+
if (input.method === 'escrow') {
|
|
2716
|
+
throw new Error('Use executeRecovery to complete an escrow hold');
|
|
2717
|
+
}
|
|
2718
|
+
let recoveryShare: string | undefined;
|
|
2719
|
+
|
|
2720
|
+
switch (input.method) {
|
|
2721
|
+
case 'passkey':
|
|
2722
|
+
// Passkey decryption requires the encrypted server material.
|
|
2723
|
+
break;
|
|
2724
|
+
case 'phrase':
|
|
2725
|
+
if (!(await validateRecoveryPhrase(input.phrase))) {
|
|
2726
|
+
throw new Error('Invalid recovery phrase');
|
|
2727
|
+
}
|
|
2728
|
+
recoveryShare = await recoveryPhraseToShare(input.phrase);
|
|
2729
|
+
break;
|
|
2730
|
+
case 'backup': {
|
|
2731
|
+
const backup = JSON.parse(input.fileContents) as BackupFile;
|
|
2732
|
+
if (backup.version !== 1) throw new Error('Unsupported backup file version');
|
|
2733
|
+
recoveryShare = await decryptWithPassword(
|
|
2734
|
+
backup.encryptedShare.ciphertext,
|
|
2735
|
+
backup.encryptedShare.iv,
|
|
2736
|
+
backup.encryptedShare.salt,
|
|
2737
|
+
input.password,
|
|
2738
|
+
backup.encryptedShare.kdfParams
|
|
2739
|
+
);
|
|
2740
|
+
break;
|
|
2741
|
+
}
|
|
2742
|
+
case 'email':
|
|
2743
|
+
recoveryShare = parseVersionedEmailShare(input.emailShare.trim()).share;
|
|
2744
|
+
break;
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
try {
|
|
2748
|
+
const material = await postJson<IdentityRecoveryMaterialResponse>(
|
|
2749
|
+
`${serverUrl}/keys/recovery-session/recover`,
|
|
2750
|
+
{
|
|
2751
|
+
recoverySessionToken: params.recoverySessionToken,
|
|
2752
|
+
type: input.method,
|
|
2753
|
+
...(input.method === 'passkey' ? { credentialId: input.credentialId } : {}),
|
|
2754
|
+
},
|
|
2755
|
+
buildHeaders('', undefined, tenantId)
|
|
2756
|
+
);
|
|
2757
|
+
|
|
2758
|
+
if (input.method === 'passkey') {
|
|
2759
|
+
if (!material.encryptedShare) {
|
|
2760
|
+
throw new Error('No passkey recovery data found');
|
|
2761
|
+
}
|
|
2762
|
+
recoveryShare = await decryptShareWithPasskey({
|
|
2763
|
+
encryptedData: material.encryptedShare.encryptedData,
|
|
2764
|
+
iv: material.encryptedShare.iv,
|
|
2765
|
+
credentialId: input.credentialId,
|
|
2766
|
+
});
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
if (!recoveryShare) throw new Error('Recovery data was empty');
|
|
2770
|
+
|
|
2771
|
+
const authShare = material.authShare.encryptedData;
|
|
2772
|
+
let privateKey: string;
|
|
2773
|
+
|
|
2774
|
+
try {
|
|
2775
|
+
privateKey = await reconstructFromShares([recoveryShare, authShare]);
|
|
2776
|
+
} catch {
|
|
2777
|
+
throw new Error(
|
|
2778
|
+
'Recovery produced an incorrect key. Try another recovery method.'
|
|
2779
|
+
);
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
const derivedDid = await params.didFromPrivateKey(privateKey);
|
|
2783
|
+
if (!derivedDid || derivedDid !== material.primaryDid) {
|
|
2784
|
+
throw new Error(
|
|
2785
|
+
'Recovery produced an incorrect key. Try another recovery method.'
|
|
2786
|
+
);
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
pendingIdentityRecovery = {
|
|
2790
|
+
privateKey,
|
|
2791
|
+
primaryDid: material.primaryDid,
|
|
2792
|
+
recoveryShare,
|
|
2793
|
+
authShare,
|
|
2794
|
+
rebindSessionToken: material.rebindSessionToken,
|
|
2795
|
+
};
|
|
2796
|
+
|
|
2797
|
+
return { privateKey, did: material.primaryDid };
|
|
2798
|
+
} catch (error) {
|
|
2799
|
+
const message = error instanceof Error ? error.message : 'Identity recovery failed';
|
|
2800
|
+
|
|
2801
|
+
throw new IdentityRecoverySessionConsumedError(message);
|
|
2802
|
+
}
|
|
2803
|
+
},
|
|
2804
|
+
|
|
2805
|
+
hasPendingIdentityRecovery(): boolean {
|
|
2806
|
+
return Boolean(pendingIdentityRecovery);
|
|
2807
|
+
},
|
|
2808
|
+
|
|
2809
|
+
cancelIdentityRecovery(): void {
|
|
2810
|
+
pendingIdentityRecovery = undefined;
|
|
2811
|
+
},
|
|
2812
|
+
|
|
2813
|
+
async completeIdentityRecovery(params): Promise<RecoveryResult> {
|
|
2814
|
+
const storage = guardedStorage();
|
|
2815
|
+
const generation = storageGeneration;
|
|
2816
|
+
const pending = pendingIdentityRecovery;
|
|
2817
|
+
const signDidAuthVp = params.signDidAuthVp;
|
|
2818
|
+
|
|
2819
|
+
if (!pending) throw new Error('No identity recovery is waiting for a new sign-in');
|
|
2820
|
+
if (!signDidAuthVp) {
|
|
2821
|
+
throw new Error('DID proof signing is required to bind a new sign-in');
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
const storageId = activeStorageId;
|
|
2825
|
+
const { shares, shareVersion } = await withShareUpdateLock(storage, storageId, () =>
|
|
2826
|
+
withRotationLock(async () => {
|
|
2827
|
+
const assertActive = (): void => {
|
|
2828
|
+
if (
|
|
2829
|
+
activeStorageId !== storageId ||
|
|
2830
|
+
pendingIdentityRecovery !== pending ||
|
|
2831
|
+
storageGeneration !== generation
|
|
2832
|
+
) {
|
|
2833
|
+
throw new Error('This recovery request was cancelled.');
|
|
2834
|
+
}
|
|
2835
|
+
};
|
|
2836
|
+
assertActive();
|
|
2837
|
+
const candidates = await readPendingShareCandidates(storage, storageId);
|
|
2838
|
+
if (pending.rebind && pending.rebind.storageId !== storageId) {
|
|
2839
|
+
throw new Error('Active account changed during identity recovery');
|
|
2840
|
+
}
|
|
2841
|
+
if (
|
|
2842
|
+
candidates.some(
|
|
2843
|
+
candidate => candidate.share !== pending.rebind?.shares.deviceShare
|
|
2844
|
+
)
|
|
2845
|
+
) {
|
|
2846
|
+
throw new Error(
|
|
2847
|
+
'Reconcile the pending share update before binding a sign-in'
|
|
2848
|
+
);
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
let shareVersion: number | undefined;
|
|
2852
|
+
const retry = pending.rebind?.attempted ?? false;
|
|
2853
|
+
if (retry && pending.rebind) {
|
|
2854
|
+
const current = await fetchAuthShareRaw(
|
|
2855
|
+
serverUrl,
|
|
2856
|
+
params.token,
|
|
2857
|
+
params.providerType,
|
|
2858
|
+
undefined,
|
|
2859
|
+
tenantId
|
|
2860
|
+
);
|
|
2861
|
+
const authShare = authShareToString(current?.authShare);
|
|
2862
|
+
// prepareIdentityRecovery already verified this key's DID.
|
|
2863
|
+
// Comparing the reconstructed key avoids trusting server DID metadata.
|
|
2864
|
+
if (authShare && current?.shareVersion != null) {
|
|
2865
|
+
const health = await verifyStoredShares(
|
|
2866
|
+
{
|
|
2867
|
+
getDevice: async () => pending.rebind!.shares.deviceShare,
|
|
2868
|
+
getAuth: async () => authShare,
|
|
2869
|
+
},
|
|
2870
|
+
pending.primaryDid,
|
|
2871
|
+
async key => (key === pending.privateKey ? pending.primaryDid : '')
|
|
2872
|
+
);
|
|
2873
|
+
if (health.healthy) shareVersion = current.shareVersion;
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
assertActive();
|
|
2878
|
+
if (shareVersion === undefined) {
|
|
2879
|
+
const didAuthVp = await requestFreshDidAuthVp(
|
|
2880
|
+
serverUrl,
|
|
2881
|
+
pending.privateKey,
|
|
2882
|
+
pending.primaryDid,
|
|
2883
|
+
signDidAuthVp,
|
|
2884
|
+
tenantId
|
|
2885
|
+
);
|
|
2886
|
+
assertActive();
|
|
2887
|
+
pending.rebind ??= {
|
|
2888
|
+
shares: (await splitAndVerify(pending.privateKey)).shares,
|
|
2889
|
+
storageId,
|
|
2890
|
+
attempted: false,
|
|
2891
|
+
};
|
|
2892
|
+
const rebind = pending.rebind;
|
|
2893
|
+
if (
|
|
2894
|
+
!candidates.some(
|
|
2895
|
+
candidate => candidate.share === rebind.shares.deviceShare
|
|
2896
|
+
)
|
|
2897
|
+
) {
|
|
2898
|
+
await addPendingShareCandidate(
|
|
2899
|
+
storage,
|
|
2900
|
+
{ share: rebind.shares.deviceShare, createdAt: Date.now() },
|
|
2901
|
+
storageId
|
|
2902
|
+
);
|
|
2903
|
+
}
|
|
2904
|
+
assertActive();
|
|
2905
|
+
rebind.attempted = true;
|
|
2906
|
+
try {
|
|
2907
|
+
const response = await postJson<{
|
|
2908
|
+
shareVersion: number;
|
|
2909
|
+
recoveryMethodsRequireConfirmation: string[];
|
|
2910
|
+
}>(
|
|
2911
|
+
`${serverUrl}/keys/recovery-session/rebind`,
|
|
2912
|
+
{
|
|
2913
|
+
recoverySessionToken: pending.rebindSessionToken,
|
|
2914
|
+
providerType: params.providerType,
|
|
2915
|
+
primaryDid: pending.primaryDid,
|
|
2916
|
+
authShare: {
|
|
2917
|
+
encryptedData: rebind.shares.authShare,
|
|
2918
|
+
encryptedDek: '',
|
|
2919
|
+
iv: '',
|
|
2920
|
+
},
|
|
2921
|
+
},
|
|
2922
|
+
{
|
|
2923
|
+
...buildHeaders('', didAuthVp, tenantId),
|
|
2924
|
+
'X-Auth-Token': params.token,
|
|
2925
|
+
}
|
|
2926
|
+
);
|
|
2927
|
+
|
|
2928
|
+
shareVersion = response.shareVersion;
|
|
2929
|
+
} catch (error) {
|
|
2930
|
+
// A retry's rejection does not disprove an earlier commit
|
|
2931
|
+
// (the first request may still have been in flight at the read).
|
|
2932
|
+
if (error instanceof ShareWriteRejectedError && !retry) {
|
|
2933
|
+
await removePendingShareCandidate(
|
|
2934
|
+
storage,
|
|
2935
|
+
rebind.shares.deviceShare,
|
|
2936
|
+
storageId
|
|
2937
|
+
);
|
|
2938
|
+
pending.rebind = undefined;
|
|
2939
|
+
}
|
|
2940
|
+
throw error;
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
if (shareVersion === undefined || !pending.rebind) {
|
|
2944
|
+
throw new Error('Server did not confirm the new share');
|
|
2945
|
+
}
|
|
2946
|
+
assertActive();
|
|
2947
|
+
const shares = pending.rebind.shares;
|
|
2948
|
+
await storage.storeDeviceShare(shares.deviceShare, storageId);
|
|
2949
|
+
await storage.storeShareVersion(shareVersion, storageId);
|
|
2950
|
+
await clearPendingShareCandidates(storage, storageId);
|
|
2951
|
+
assertActive();
|
|
2952
|
+
lastEmailShare = shares.emailShare;
|
|
2953
|
+
lastShareVersion = shareVersion;
|
|
2954
|
+
lastServerSnapshot = {
|
|
2955
|
+
currentVersion: shareVersion,
|
|
2956
|
+
resolvedVersion: shareVersion,
|
|
2957
|
+
authShare: shares.authShare,
|
|
2958
|
+
primaryDid: pending.primaryDid,
|
|
2959
|
+
};
|
|
2960
|
+
pendingIdentityRecovery = undefined;
|
|
2961
|
+
return { shares, shareVersion };
|
|
2962
|
+
})
|
|
2963
|
+
);
|
|
2964
|
+
|
|
2965
|
+
await tryEnrollEscrow(
|
|
2966
|
+
params.token,
|
|
2967
|
+
params.providerType,
|
|
2968
|
+
pending.privateKey,
|
|
2969
|
+
pending.primaryDid,
|
|
2970
|
+
shares,
|
|
2971
|
+
shareVersion,
|
|
2972
|
+
signDidAuthVp,
|
|
2973
|
+
undefined,
|
|
2974
|
+
undefined,
|
|
2975
|
+
true
|
|
2976
|
+
);
|
|
2977
|
+
if (activeStorageId !== storageId || storageGeneration !== generation) {
|
|
2978
|
+
throw new Error('This recovery request was cancelled.');
|
|
2979
|
+
}
|
|
2980
|
+
return { privateKey: pending.privateKey, did: pending.primaryDid };
|
|
2981
|
+
},
|
|
2982
|
+
|
|
1013
2983
|
// --- Contact method management ---
|
|
1014
2984
|
|
|
1015
2985
|
async upgradeContactMethod(
|
|
@@ -1050,7 +3020,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1050
3020
|
token: string,
|
|
1051
3021
|
providerType: AuthProviderType,
|
|
1052
3022
|
_privateKey: string,
|
|
1053
|
-
email: string
|
|
3023
|
+
email: string,
|
|
3024
|
+
didAuthVp?: string
|
|
1054
3025
|
): Promise<void> {
|
|
1055
3026
|
if (!enableEmailBackupShare) return;
|
|
1056
3027
|
|
|
@@ -1061,32 +3032,22 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1061
3032
|
return;
|
|
1062
3033
|
}
|
|
1063
3034
|
|
|
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
|
-
|
|
3035
|
+
if (email) {
|
|
1081
3036
|
await sendEmailBackupShare(
|
|
1082
3037
|
serverUrl,
|
|
1083
3038
|
token,
|
|
1084
3039
|
providerType,
|
|
1085
3040
|
lastEmailShare,
|
|
1086
3041
|
email,
|
|
3042
|
+
escrowRelayPublicKey,
|
|
3043
|
+
escrowRelayKeyId,
|
|
3044
|
+
emailBranding,
|
|
1087
3045
|
lastShareVersion,
|
|
3046
|
+
didAuthVp,
|
|
1088
3047
|
tenantId
|
|
1089
3048
|
);
|
|
3049
|
+
} else {
|
|
3050
|
+
throw new Error('Enter a recovery email before sending a recovery key');
|
|
1090
3051
|
}
|
|
1091
3052
|
|
|
1092
3053
|
// Clear after use — one-shot to avoid stale data
|
|
@@ -1111,7 +3072,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1111
3072
|
},
|
|
1112
3073
|
|
|
1113
3074
|
async cleanup(): Promise<void> {
|
|
1114
|
-
|
|
3075
|
+
storageGeneration++;
|
|
3076
|
+
await Promise.allSettled(localWrites);
|
|
1115
3077
|
},
|
|
1116
3078
|
};
|
|
1117
3079
|
}
|