@learncard/sss-key-manager 0.1.24 → 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 +2891 -355
- 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 +2891 -355
- 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/api-client.ts
CHANGED
|
@@ -5,12 +5,21 @@
|
|
|
5
5
|
import type {
|
|
6
6
|
AuthProvider,
|
|
7
7
|
AuthProviderType,
|
|
8
|
-
ContactMethod,
|
|
9
8
|
ServerEncryptedShare,
|
|
10
9
|
RecoveryMethodInfo,
|
|
11
10
|
EncryptedShare,
|
|
12
11
|
SecurityLevel,
|
|
12
|
+
SssActivationState,
|
|
13
|
+
IdentityRecoverySession,
|
|
13
14
|
} from './types';
|
|
15
|
+
import type { EmailRelayBranding } from './email-relay-crypto';
|
|
16
|
+
|
|
17
|
+
const DID_CHALLENGE_REQUIRED_ERROR =
|
|
18
|
+
'This operation requires the DID-challenge flow; use createSSSStrategy';
|
|
19
|
+
|
|
20
|
+
const throwDidChallengeRequired = (): never => {
|
|
21
|
+
throw new Error(DID_CHALLENGE_REQUIRED_ERROR);
|
|
22
|
+
};
|
|
14
23
|
|
|
15
24
|
export interface GetAuthShareResponse {
|
|
16
25
|
authShare: ServerEncryptedShare | null;
|
|
@@ -18,13 +27,16 @@ export interface GetAuthShareResponse {
|
|
|
18
27
|
securityLevel: SecurityLevel;
|
|
19
28
|
recoveryMethods: RecoveryMethodInfo[];
|
|
20
29
|
keyProvider: 'web3auth' | 'sss';
|
|
30
|
+
shareVersion: number;
|
|
21
31
|
maskedRecoveryEmail?: string | null;
|
|
32
|
+
sssActivationState: SssActivationState;
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
export interface StoreAuthShareInput {
|
|
25
36
|
authShare: ServerEncryptedShare;
|
|
26
37
|
primaryDid: string;
|
|
27
38
|
securityLevel?: SecurityLevel;
|
|
39
|
+
sssActivationState?: 'provisional';
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
export interface StoreRecoveryShareInput {
|
|
@@ -34,11 +46,23 @@ export interface StoreRecoveryShareInput {
|
|
|
34
46
|
shareVersion?: number;
|
|
35
47
|
}
|
|
36
48
|
|
|
49
|
+
export interface RecoverySessionMaterial {
|
|
50
|
+
authShare: ServerEncryptedShare;
|
|
51
|
+
encryptedShare?: EncryptedShare;
|
|
52
|
+
primaryDid: string;
|
|
53
|
+
shareVersion: number;
|
|
54
|
+
rebindSessionToken: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
37
57
|
export interface ApiClientConfig {
|
|
38
58
|
serverUrl: string;
|
|
39
59
|
authProvider: AuthProvider;
|
|
60
|
+
escrowRelayPublicKey?: string;
|
|
61
|
+
escrowRelayKeyId?: string;
|
|
62
|
+
emailBranding?: EmailRelayBranding;
|
|
40
63
|
}
|
|
41
64
|
|
|
65
|
+
/** @deprecated Use `createSSSStrategy`, which implements the hardened DID-challenge flow. */
|
|
42
66
|
export class SSSApiClient {
|
|
43
67
|
private serverUrl: string;
|
|
44
68
|
private authProvider: AuthProvider;
|
|
@@ -52,23 +76,10 @@ export class SSSApiClient {
|
|
|
52
76
|
const token = await this.authProvider.getIdToken();
|
|
53
77
|
return {
|
|
54
78
|
'Content-Type': 'application/json',
|
|
55
|
-
|
|
79
|
+
'X-Auth-Token': token,
|
|
56
80
|
};
|
|
57
81
|
}
|
|
58
82
|
|
|
59
|
-
private async getContactMethodFromUser(): Promise<ContactMethod | null> {
|
|
60
|
-
const user = await this.authProvider.getCurrentUser();
|
|
61
|
-
if (!user) return null;
|
|
62
|
-
|
|
63
|
-
if (user.email) {
|
|
64
|
-
return { type: 'email' as const, value: user.email.toLowerCase() };
|
|
65
|
-
}
|
|
66
|
-
if (user.phone) {
|
|
67
|
-
return { type: 'phone' as const, value: user.phone };
|
|
68
|
-
}
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
83
|
async getAuthShare(): Promise<GetAuthShareResponse | null> {
|
|
73
84
|
const headers = await this.getAuthHeaders();
|
|
74
85
|
const providerType = this.authProvider.getProviderType();
|
|
@@ -89,54 +100,28 @@ export class SSSApiClient {
|
|
|
89
100
|
return response.json();
|
|
90
101
|
}
|
|
91
102
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const response = await fetch(`${this.serverUrl}/keys/auth-share`, {
|
|
97
|
-
method: 'PUT',
|
|
98
|
-
headers,
|
|
99
|
-
body: JSON.stringify({
|
|
100
|
-
...input,
|
|
101
|
-
providerType,
|
|
102
|
-
}),
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
if (!response.ok) {
|
|
106
|
-
throw new Error(`Failed to store auth share: ${response.statusText}`);
|
|
107
|
-
}
|
|
103
|
+
/** @deprecated Requires DID-challenge authorization. Use `createSSSStrategy`. */
|
|
104
|
+
async storeAuthShare(_input: StoreAuthShareInput): Promise<void> {
|
|
105
|
+
throwDidChallengeRequired();
|
|
108
106
|
}
|
|
109
107
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const response = await fetch(`${this.serverUrl}/keys/recovery`, {
|
|
115
|
-
method: 'POST',
|
|
116
|
-
headers,
|
|
117
|
-
body: JSON.stringify({
|
|
118
|
-
...input,
|
|
119
|
-
providerType,
|
|
120
|
-
}),
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
if (!response.ok) {
|
|
124
|
-
throw new Error(`Failed to add recovery method: ${response.statusText}`);
|
|
125
|
-
}
|
|
108
|
+
/** @deprecated Requires DID-challenge authorization. Use `createSSSStrategy`. */
|
|
109
|
+
async addRecoveryMethod(_input: StoreRecoveryShareInput): Promise<void> {
|
|
110
|
+
throwDidChallengeRequired();
|
|
126
111
|
}
|
|
127
112
|
|
|
128
113
|
async getRecoveryShare(
|
|
129
114
|
type: 'passkey' | 'backup' | 'phrase' | 'email',
|
|
130
115
|
credentialId?: string
|
|
131
116
|
): Promise<{ encryptedShare?: EncryptedShare; shareVersion?: number } | null> {
|
|
117
|
+
// P0-4: authToken travels via the X-Auth-Token header — never as a
|
|
118
|
+
// query param, which would land in proxy/ALB access logs. A
|
|
119
|
+
// dedicated header (not Authorization) avoids colliding with
|
|
120
|
+
// clients that send a DID-Auth VP as Authorization for this route.
|
|
132
121
|
const token = await this.authProvider.getIdToken();
|
|
133
122
|
const providerType = this.authProvider.getProviderType();
|
|
134
123
|
|
|
135
|
-
const params = new URLSearchParams({
|
|
136
|
-
type,
|
|
137
|
-
providerType,
|
|
138
|
-
authToken: token,
|
|
139
|
-
});
|
|
124
|
+
const params = new URLSearchParams({ type, providerType });
|
|
140
125
|
|
|
141
126
|
if (credentialId) {
|
|
142
127
|
params.append('credentialId', credentialId);
|
|
@@ -144,9 +129,7 @@ export class SSSApiClient {
|
|
|
144
129
|
|
|
145
130
|
const response = await fetch(`${this.serverUrl}/keys/recovery?${params}`, {
|
|
146
131
|
method: 'GET',
|
|
147
|
-
headers: {
|
|
148
|
-
'Content-Type': 'application/json',
|
|
149
|
-
},
|
|
132
|
+
headers: { 'Content-Type': 'application/json', 'X-Auth-Token': token },
|
|
150
133
|
});
|
|
151
134
|
|
|
152
135
|
if (!response.ok) {
|
|
@@ -159,99 +142,107 @@ export class SSSApiClient {
|
|
|
159
142
|
return response.json();
|
|
160
143
|
}
|
|
161
144
|
|
|
162
|
-
async
|
|
163
|
-
const
|
|
164
|
-
const providerType = this.authProvider.getProviderType();
|
|
165
|
-
|
|
166
|
-
const response = await fetch(`${this.serverUrl}/keys/migrate`, {
|
|
145
|
+
async startIdentityRecovery(email: string): Promise<void> {
|
|
146
|
+
const response = await fetch(`${this.serverUrl}/keys/recovery-session/start`, {
|
|
167
147
|
method: 'POST',
|
|
168
|
-
headers,
|
|
169
|
-
body: JSON.stringify({
|
|
148
|
+
headers: { 'Content-Type': 'application/json' },
|
|
149
|
+
body: JSON.stringify({ email }),
|
|
170
150
|
});
|
|
171
151
|
|
|
172
152
|
if (!response.ok) {
|
|
173
|
-
throw new Error(`Failed to
|
|
153
|
+
throw new Error(`Failed to send recovery code: ${response.statusText}`);
|
|
174
154
|
}
|
|
175
155
|
}
|
|
176
156
|
|
|
177
|
-
async
|
|
178
|
-
const
|
|
179
|
-
const providerType = this.authProvider.getProviderType();
|
|
180
|
-
|
|
181
|
-
let email = overrideEmail;
|
|
182
|
-
|
|
183
|
-
if (!email) {
|
|
184
|
-
const user = await this.authProvider.getCurrentUser();
|
|
185
|
-
email = user?.email;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (!email) {
|
|
189
|
-
console.warn('Cannot send email backup share: no email address available');
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const response = await fetch(`${this.serverUrl}/keys/email-backup`, {
|
|
157
|
+
async verifyIdentityRecovery(email: string, code: string): Promise<IdentityRecoverySession> {
|
|
158
|
+
const response = await fetch(`${this.serverUrl}/keys/recovery-session/verify`, {
|
|
194
159
|
method: 'POST',
|
|
195
|
-
headers,
|
|
196
|
-
body: JSON.stringify({
|
|
197
|
-
providerType,
|
|
198
|
-
emailShare,
|
|
199
|
-
email,
|
|
200
|
-
}),
|
|
160
|
+
headers: { 'Content-Type': 'application/json' },
|
|
161
|
+
body: JSON.stringify({ email, code }),
|
|
201
162
|
});
|
|
202
163
|
|
|
203
164
|
if (!response.ok) {
|
|
204
|
-
|
|
205
|
-
console.warn(`Failed to send email backup share: ${response.statusText}`);
|
|
165
|
+
throw new Error(`Failed to verify recovery code: ${response.statusText}`);
|
|
206
166
|
}
|
|
207
|
-
}
|
|
208
167
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const providerType = this.authProvider.getProviderType();
|
|
168
|
+
return response.json();
|
|
169
|
+
}
|
|
212
170
|
|
|
213
|
-
|
|
171
|
+
async useIdentityRecoverySession(
|
|
172
|
+
recoverySessionToken: string,
|
|
173
|
+
type: StoreRecoveryShareInput['type'],
|
|
174
|
+
credentialId?: string
|
|
175
|
+
): Promise<RecoverySessionMaterial> {
|
|
176
|
+
const response = await fetch(`${this.serverUrl}/keys/recovery-session/recover`, {
|
|
214
177
|
method: 'POST',
|
|
215
178
|
headers: { 'Content-Type': 'application/json' },
|
|
216
|
-
body: JSON.stringify({
|
|
179
|
+
body: JSON.stringify({ recoverySessionToken, type, credentialId }),
|
|
217
180
|
});
|
|
218
181
|
|
|
219
182
|
if (!response.ok) {
|
|
220
|
-
|
|
221
|
-
throw new Error(data?.message || `Failed to add recovery email: ${response.statusText}`);
|
|
183
|
+
throw new Error(`Failed to retrieve recovery data: ${response.statusText}`);
|
|
222
184
|
}
|
|
223
|
-
}
|
|
224
185
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const providerType = this.authProvider.getProviderType();
|
|
186
|
+
return response.json();
|
|
187
|
+
}
|
|
228
188
|
|
|
229
|
-
|
|
189
|
+
async completeIdentityRecoveryRebind(input: {
|
|
190
|
+
recoverySessionToken: string;
|
|
191
|
+
newAuthToken: string;
|
|
192
|
+
providerType: AuthProviderType;
|
|
193
|
+
primaryDid: string;
|
|
194
|
+
authShare: ServerEncryptedShare;
|
|
195
|
+
didAuthVp: string;
|
|
196
|
+
}): Promise<{ shareVersion: number; recoveryMethodsRequireConfirmation: string[] }> {
|
|
197
|
+
const response = await fetch(`${this.serverUrl}/keys/recovery-session/rebind`, {
|
|
230
198
|
method: 'POST',
|
|
231
|
-
headers: {
|
|
232
|
-
|
|
199
|
+
headers: {
|
|
200
|
+
'Content-Type': 'application/json',
|
|
201
|
+
Authorization: `Bearer ${input.didAuthVp}`,
|
|
202
|
+
'X-Auth-Token': input.newAuthToken,
|
|
203
|
+
},
|
|
204
|
+
body: JSON.stringify({
|
|
205
|
+
recoverySessionToken: input.recoverySessionToken,
|
|
206
|
+
providerType: input.providerType,
|
|
207
|
+
primaryDid: input.primaryDid,
|
|
208
|
+
authShare: input.authShare,
|
|
209
|
+
}),
|
|
233
210
|
});
|
|
234
211
|
|
|
235
212
|
if (!response.ok) {
|
|
236
|
-
|
|
237
|
-
throw new Error(data?.message || `Failed to verify recovery email: ${response.statusText}`);
|
|
213
|
+
throw new Error(`Failed to bind the new sign-in: ${response.statusText}`);
|
|
238
214
|
}
|
|
239
215
|
|
|
240
216
|
return response.json();
|
|
241
217
|
}
|
|
242
218
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
219
|
+
/** @deprecated Requires DID-challenge authorization. Use `createSSSStrategy`. */
|
|
220
|
+
async markMigrated(): Promise<void> {
|
|
221
|
+
throwDidChallengeRequired();
|
|
222
|
+
}
|
|
246
223
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
});
|
|
224
|
+
/** @deprecated Requires DID-challenge authorization. Use `createSSSStrategy`. */
|
|
225
|
+
async activate(): Promise<void> {
|
|
226
|
+
throwDidChallengeRequired();
|
|
227
|
+
}
|
|
252
228
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
229
|
+
/** @deprecated Requires DID-challenge authorization. Use `createSSSStrategy`. */
|
|
230
|
+
async sendEmailBackupShare(_emailShare: string, _overrideEmail?: string): Promise<void> {
|
|
231
|
+
throwDidChallengeRequired();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** @deprecated Requires DID-challenge authorization. Use `createSSSStrategy`. */
|
|
235
|
+
async addRecoveryEmail(_email: string): Promise<void> {
|
|
236
|
+
throwDidChallengeRequired();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** @deprecated Requires DID-challenge authorization. Use `createSSSStrategy`. */
|
|
240
|
+
async verifyRecoveryEmail(_code: string): Promise<{ maskedEmail: string }> {
|
|
241
|
+
return throwDidChallengeRequired();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** @deprecated Requires DID-challenge authorization. Use `createSSSStrategy`. */
|
|
245
|
+
async deleteUserKey(): Promise<void> {
|
|
246
|
+
throwDidChallengeRequired();
|
|
256
247
|
}
|
|
257
248
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Atomic Operations Tests
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* Tests for split verification, atomic updates, and rollback behavior.
|
|
5
5
|
*/
|
|
6
6
|
|
|
@@ -13,13 +13,15 @@ import {
|
|
|
13
13
|
atomicRecovery,
|
|
14
14
|
ShareVerificationError,
|
|
15
15
|
AtomicUpdateError,
|
|
16
|
+
ShareWriteRejectedError,
|
|
16
17
|
type StorageOperations,
|
|
17
18
|
} from './atomic-operations';
|
|
18
19
|
import { generateEd25519PrivateKey } from './crypto';
|
|
19
20
|
import { splitPrivateKey, reconstructFromShares } from './sss';
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
type MockShareState = { deviceShare: string | null; authShare: string | null };
|
|
22
23
|
|
|
24
|
+
describe('splitAndVerify', () => {
|
|
23
25
|
it('should return verified shares that reconstruct the key', async () => {
|
|
24
26
|
const privateKey = await generateEd25519PrivateKey();
|
|
25
27
|
|
|
@@ -56,6 +58,33 @@ describe('splitAndVerify', () => {
|
|
|
56
58
|
});
|
|
57
59
|
|
|
58
60
|
describe('atomicShareUpdate', () => {
|
|
61
|
+
it.each([false, true])(
|
|
62
|
+
'preserves the new share after a lost reply (existing=%s)',
|
|
63
|
+
async existing => {
|
|
64
|
+
const privateKey = await generateEd25519PrivateKey();
|
|
65
|
+
const previous = existing ? (await splitPrivateKey(privateKey)).deviceShare : undefined;
|
|
66
|
+
const storage = createMockStorage();
|
|
67
|
+
storage.deviceShare = previous ?? null;
|
|
68
|
+
storage.clearDevice = vi.fn(async () => {
|
|
69
|
+
storage.deviceShare = null;
|
|
70
|
+
});
|
|
71
|
+
storage.storeAuth = vi.fn(async share => {
|
|
72
|
+
storage.authShare = share;
|
|
73
|
+
throw new TypeError('Connection closed after commit');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await expect(
|
|
77
|
+
atomicShareUpdate(privateKey, storage, {
|
|
78
|
+
previousDeviceShare: previous,
|
|
79
|
+
})
|
|
80
|
+
).rejects.toMatchObject({ phase: 'store_auth', rolledBack: false });
|
|
81
|
+
|
|
82
|
+
expect(storage.clearDevice).not.toHaveBeenCalled();
|
|
83
|
+
expect(await reconstructFromShares([storage.deviceShare!, storage.authShare!])).toBe(
|
|
84
|
+
privateKey
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
);
|
|
59
88
|
|
|
60
89
|
const createMockStorage = (): StorageOperations & {
|
|
61
90
|
deviceShare: string | null;
|
|
@@ -64,19 +93,19 @@ describe('atomicShareUpdate', () => {
|
|
|
64
93
|
deviceShare: null,
|
|
65
94
|
authShare: null,
|
|
66
95
|
|
|
67
|
-
storeDevice: vi.fn(async function(this:
|
|
96
|
+
storeDevice: vi.fn(async function (this: MockShareState, share: string) {
|
|
68
97
|
this.deviceShare = share;
|
|
69
98
|
}),
|
|
70
99
|
|
|
71
|
-
storeAuth: vi.fn(async function(this:
|
|
100
|
+
storeAuth: vi.fn(async function (this: MockShareState, share: string) {
|
|
72
101
|
this.authShare = share;
|
|
73
102
|
}),
|
|
74
103
|
|
|
75
|
-
getDevice: vi.fn(async function(this:
|
|
104
|
+
getDevice: vi.fn(async function (this: MockShareState) {
|
|
76
105
|
return this.deviceShare;
|
|
77
106
|
}),
|
|
78
107
|
|
|
79
|
-
getAuth: vi.fn(async function(this:
|
|
108
|
+
getAuth: vi.fn(async function (this: MockShareState) {
|
|
80
109
|
return this.authShare;
|
|
81
110
|
}),
|
|
82
111
|
});
|
|
@@ -101,7 +130,7 @@ describe('atomicShareUpdate', () => {
|
|
|
101
130
|
storage.deviceShare = previousDeviceShare;
|
|
102
131
|
|
|
103
132
|
// Make auth storage fail
|
|
104
|
-
storage.storeAuth = vi.fn().mockRejectedValue(new
|
|
133
|
+
storage.storeAuth = vi.fn().mockRejectedValue(new ShareWriteRejectedError('Rejected'));
|
|
105
134
|
|
|
106
135
|
const onRollback = vi.fn();
|
|
107
136
|
|
|
@@ -117,6 +146,20 @@ describe('atomicShareUpdate', () => {
|
|
|
117
146
|
expect(onRollback).toHaveBeenCalled();
|
|
118
147
|
});
|
|
119
148
|
|
|
149
|
+
it('removes a fresh device share after an explicit rejection', async () => {
|
|
150
|
+
const storage = createMockStorage();
|
|
151
|
+
storage.clearDevice = vi.fn(async () => {
|
|
152
|
+
storage.deviceShare = null;
|
|
153
|
+
});
|
|
154
|
+
storage.storeAuth = vi.fn().mockRejectedValue(new ShareWriteRejectedError('Rejected'));
|
|
155
|
+
|
|
156
|
+
await expect(
|
|
157
|
+
atomicShareUpdate(await generateEd25519PrivateKey(), storage)
|
|
158
|
+
).rejects.toMatchObject({ phase: 'store_auth', rolledBack: true });
|
|
159
|
+
expect(storage.deviceShare).toBeNull();
|
|
160
|
+
expect(storage.clearDevice).toHaveBeenCalledOnce();
|
|
161
|
+
});
|
|
162
|
+
|
|
120
163
|
it('should throw AtomicUpdateError with correct phase on device storage failure', async () => {
|
|
121
164
|
const privateKey = await generateEd25519PrivateKey();
|
|
122
165
|
const storage = createMockStorage();
|
|
@@ -138,7 +181,9 @@ describe('atomicShareUpdate', () => {
|
|
|
138
181
|
const privateKey = await generateEd25519PrivateKey();
|
|
139
182
|
const storage = createMockStorage();
|
|
140
183
|
|
|
141
|
-
storage.storeAuth = vi
|
|
184
|
+
storage.storeAuth = vi
|
|
185
|
+
.fn()
|
|
186
|
+
.mockRejectedValue(new ShareWriteRejectedError('Server rejection'));
|
|
142
187
|
|
|
143
188
|
try {
|
|
144
189
|
await atomicShareUpdate(privateKey, storage, {
|
|
@@ -160,16 +205,12 @@ describe('atomicShareUpdate', () => {
|
|
|
160
205
|
const shares = await atomicShareUpdate(privateKey, storage);
|
|
161
206
|
|
|
162
207
|
// Verify the returned shares can reconstruct the key
|
|
163
|
-
const reconstructed = await reconstructFromShares([
|
|
164
|
-
shares.deviceShare,
|
|
165
|
-
shares.authShare,
|
|
166
|
-
]);
|
|
208
|
+
const reconstructed = await reconstructFromShares([shares.deviceShare, shares.authShare]);
|
|
167
209
|
expect(reconstructed).toBe(privateKey);
|
|
168
210
|
});
|
|
169
211
|
});
|
|
170
212
|
|
|
171
213
|
describe('verifyStoredShares', () => {
|
|
172
|
-
|
|
173
214
|
it('should return healthy=true when shares match expected DID', async () => {
|
|
174
215
|
const privateKey = await generateEd25519PrivateKey();
|
|
175
216
|
const shares = await splitPrivateKey(privateKey);
|
|
@@ -266,7 +307,6 @@ describe('verifyStoredShares', () => {
|
|
|
266
307
|
});
|
|
267
308
|
|
|
268
309
|
describe('atomicRecovery', () => {
|
|
269
|
-
|
|
270
310
|
it('should reconstruct key and generate new shares', async () => {
|
|
271
311
|
const privateKey = await generateEd25519PrivateKey();
|
|
272
312
|
const originalShares = await splitPrivateKey(privateKey);
|
|
@@ -275,11 +315,11 @@ describe('atomicRecovery', () => {
|
|
|
275
315
|
deviceShare: null as string | null,
|
|
276
316
|
authShare: null as string | null,
|
|
277
317
|
|
|
278
|
-
storeDevice: vi.fn(async function(this:
|
|
318
|
+
storeDevice: vi.fn(async function (this: MockShareState, share: string) {
|
|
279
319
|
this.deviceShare = share;
|
|
280
320
|
}),
|
|
281
321
|
|
|
282
|
-
storeAuth: vi.fn(async function(this:
|
|
322
|
+
storeAuth: vi.fn(async function (this: MockShareState, share: string) {
|
|
283
323
|
this.authShare = share;
|
|
284
324
|
}),
|
|
285
325
|
};
|
package/src/atomic-operations.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Atomic Share Operations
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* Provides verified split operations and atomic updates with rollback capability.
|
|
5
5
|
* These functions ensure that private keys can NEVER be lost due to partial failures.
|
|
6
6
|
*/
|
|
@@ -22,6 +22,7 @@ export interface AtomicUpdateOptions {
|
|
|
22
22
|
export interface StorageOperations {
|
|
23
23
|
storeDevice: (share: string) => Promise<void>;
|
|
24
24
|
storeAuth: (share: string) => Promise<void>;
|
|
25
|
+
clearDevice?: () => Promise<void>;
|
|
25
26
|
getDevice?: () => Promise<string | null>;
|
|
26
27
|
getAuth?: () => Promise<string | null>;
|
|
27
28
|
}
|
|
@@ -50,12 +51,20 @@ export class AtomicUpdateError extends Error {
|
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/** A write was explicitly rejected, rather than its outcome being unknown. */
|
|
55
|
+
export class ShareWriteRejectedError extends Error {
|
|
56
|
+
constructor(message: string) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = 'ShareWriteRejectedError';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
53
62
|
/**
|
|
54
63
|
* Split a private key into shares and verify ALL combinations reconstruct correctly.
|
|
55
|
-
*
|
|
64
|
+
*
|
|
56
65
|
* This function will NOT return until verification passes. If verification fails,
|
|
57
66
|
* it throws an error - no shares are ever returned that don't reconstruct the key.
|
|
58
|
-
*
|
|
67
|
+
*
|
|
59
68
|
* @param privateKey - The private key to split (hex string)
|
|
60
69
|
* @returns Verified shares that are guaranteed to reconstruct the key
|
|
61
70
|
* @throws ShareVerificationError if any share combination fails verification
|
|
@@ -95,17 +104,20 @@ export async function splitAndVerify(privateKey: string): Promise<AtomicSplitRes
|
|
|
95
104
|
|
|
96
105
|
/**
|
|
97
106
|
* Atomically update shares with rollback on failure.
|
|
98
|
-
*
|
|
107
|
+
*
|
|
99
108
|
* This function ensures that either:
|
|
100
109
|
* 1. Both device and auth shares are updated successfully, OR
|
|
101
|
-
* 2.
|
|
102
|
-
*
|
|
110
|
+
* 2. A rejected write restores the previous state (rollback), OR
|
|
111
|
+
* 3. An indeterminate write preserves the new device share for reconciliation
|
|
112
|
+
*
|
|
103
113
|
* The operation flow:
|
|
104
114
|
* 1. Generate and verify new shares
|
|
105
115
|
* 2. Store device share locally
|
|
106
116
|
* 3. Store auth share on server
|
|
107
|
-
* 4.
|
|
108
|
-
*
|
|
117
|
+
* 4. Roll back only if step 3 throws ShareWriteRejectedError. Network failures
|
|
118
|
+
* may happen after commit and must never destroy the new device share.
|
|
119
|
+
* Callers can stage storeDevice in a separate entry to retain both versions.
|
|
120
|
+
*
|
|
109
121
|
* @param privateKey - The private key to split
|
|
110
122
|
* @param storage - Storage operations for device and auth shares
|
|
111
123
|
* @param options - Options including previous shares for rollback
|
|
@@ -150,22 +162,46 @@ export async function atomicShareUpdate(
|
|
|
150
162
|
// Phase 3: Store auth share on server
|
|
151
163
|
await storage.storeAuth(newShares.authShare);
|
|
152
164
|
} catch (e) {
|
|
153
|
-
|
|
154
|
-
|
|
165
|
+
if (!(e instanceof ShareWriteRejectedError)) {
|
|
166
|
+
throw new AtomicUpdateError(
|
|
167
|
+
'Auth share write outcome unknown; device share preserved for reconciliation',
|
|
168
|
+
'store_auth',
|
|
169
|
+
false,
|
|
170
|
+
e instanceof Error ? e : undefined
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let rolledBack = false;
|
|
175
|
+
|
|
176
|
+
// ROLLBACK: restore the previous share, or remove the newly-written
|
|
177
|
+
// share when this was an initial setup with no previous local state.
|
|
178
|
+
if (deviceStored) {
|
|
155
179
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
180
|
+
if (options.previousDeviceShare !== undefined) {
|
|
181
|
+
await storage.storeDevice(options.previousDeviceShare);
|
|
182
|
+
options.onRollback?.('Server storage failed, restored previous device share');
|
|
183
|
+
rolledBack = true;
|
|
184
|
+
} else if (storage.clearDevice) {
|
|
185
|
+
await storage.clearDevice();
|
|
186
|
+
options.onRollback?.('Server storage failed, removed new device share');
|
|
187
|
+
rolledBack = true;
|
|
188
|
+
}
|
|
158
189
|
} catch (rollbackError) {
|
|
159
190
|
// Rollback failed - this is a critical error
|
|
160
191
|
// The user may be in an inconsistent state
|
|
161
|
-
console.error(
|
|
192
|
+
console.error(
|
|
193
|
+
'CRITICAL: Rollback failed after auth share storage failure',
|
|
194
|
+
rollbackError
|
|
195
|
+
);
|
|
162
196
|
}
|
|
163
197
|
}
|
|
164
198
|
|
|
165
199
|
throw new AtomicUpdateError(
|
|
166
|
-
|
|
200
|
+
rolledBack
|
|
201
|
+
? 'Failed to store auth share on server, rolled back device share'
|
|
202
|
+
: 'Failed to store auth share on server; rollback did not complete',
|
|
167
203
|
'store_auth',
|
|
168
|
-
|
|
204
|
+
rolledBack,
|
|
169
205
|
e instanceof Error ? e : undefined
|
|
170
206
|
);
|
|
171
207
|
}
|
|
@@ -175,10 +211,10 @@ export async function atomicShareUpdate(
|
|
|
175
211
|
|
|
176
212
|
/**
|
|
177
213
|
* Verify that stored shares can reconstruct the expected private key.
|
|
178
|
-
*
|
|
214
|
+
*
|
|
179
215
|
* This is a health check that should be run after recovery or on login
|
|
180
216
|
* to ensure the user's shares are in a consistent state.
|
|
181
|
-
*
|
|
217
|
+
*
|
|
182
218
|
* @param storage - Storage operations to retrieve shares
|
|
183
219
|
* @param expectedDid - The expected DID (used to verify the reconstructed key)
|
|
184
220
|
* @param didFromPrivateKey - Function to derive DID from private key
|
|
@@ -243,10 +279,10 @@ export async function verifyStoredShares(
|
|
|
243
279
|
|
|
244
280
|
/**
|
|
245
281
|
* Create a recovery operation that atomically updates all shares.
|
|
246
|
-
*
|
|
282
|
+
*
|
|
247
283
|
* This is used during recovery when we reconstruct from recovery+auth shares
|
|
248
284
|
* and need to generate a new device share.
|
|
249
|
-
*
|
|
285
|
+
*
|
|
250
286
|
* @param recoveryShare - The decrypted recovery share
|
|
251
287
|
* @param authShareData - The auth share from server
|
|
252
288
|
* @param storage - Storage operations
|