@learncard/sss-key-manager 0.1.13 → 0.1.15
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 +17 -17
- package/dist/sss-key-manager.cjs.development.js +4497 -2517
- package/dist/sss-key-manager.cjs.development.js.map +3 -3
- package/dist/sss-key-manager.cjs.production.min.js +56 -14
- package/dist/sss-key-manager.cjs.production.min.js.map +3 -3
- package/dist/sss-key-manager.esm.js +4497 -2517
- package/dist/sss-key-manager.esm.js.map +3 -3
- package/package.json +62 -52
- package/src/api-client.ts +257 -0
- package/src/atomic-operations.test.ts +327 -0
- package/src/atomic-operations.ts +275 -0
- package/src/auth-coordinator.test.ts +13 -0
- package/src/auth-coordinator.ts +12 -0
- package/src/critical-paths.test.ts +380 -0
- package/src/crypto.test.ts +214 -0
- package/src/crypto.ts +203 -0
- package/src/index.ts +146 -0
- package/src/key-manager.test.ts +330 -0
- package/src/key-manager.ts +323 -0
- package/src/passkey.test.ts +59 -0
- package/src/passkey.ts +222 -0
- package/src/qr-crypto.test.ts +122 -0
- package/src/qr-crypto.ts +206 -0
- package/src/qr-login-notify.test.ts +95 -0
- package/src/qr-login.test.ts +548 -0
- package/src/qr-login.ts +339 -0
- package/src/recovery-phrase.test.ts +287 -0
- package/src/recovery-phrase.ts +131 -0
- package/src/sss-strategy.test.ts +1956 -0
- package/src/sss-strategy.ts +1119 -0
- package/src/sss.test.ts +242 -0
- package/src/sss.ts +49 -0
- package/src/storage.test.ts +530 -0
- package/src/storage.ts +467 -0
- package/src/types.ts +200 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic Operations Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for split verification, atomic updates, and rollback behavior.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
splitAndVerify,
|
|
11
|
+
atomicShareUpdate,
|
|
12
|
+
verifyStoredShares,
|
|
13
|
+
atomicRecovery,
|
|
14
|
+
ShareVerificationError,
|
|
15
|
+
AtomicUpdateError,
|
|
16
|
+
type StorageOperations,
|
|
17
|
+
} from './atomic-operations';
|
|
18
|
+
import { generateEd25519PrivateKey } from './crypto';
|
|
19
|
+
import { splitPrivateKey, reconstructFromShares } from './sss';
|
|
20
|
+
|
|
21
|
+
describe('splitAndVerify', () => {
|
|
22
|
+
|
|
23
|
+
it('should return verified shares that reconstruct the key', async () => {
|
|
24
|
+
const privateKey = await generateEd25519PrivateKey();
|
|
25
|
+
|
|
26
|
+
const result = await splitAndVerify(privateKey);
|
|
27
|
+
|
|
28
|
+
expect(result.verified).toBe(true);
|
|
29
|
+
expect(result.privateKey).toBe(privateKey);
|
|
30
|
+
expect(result.shares.deviceShare).toBeDefined();
|
|
31
|
+
expect(result.shares.authShare).toBeDefined();
|
|
32
|
+
expect(result.shares.recoveryShare).toBeDefined();
|
|
33
|
+
|
|
34
|
+
// Verify all combinations work
|
|
35
|
+
const fromDeviceAuth = await reconstructFromShares([
|
|
36
|
+
result.shares.deviceShare,
|
|
37
|
+
result.shares.authShare,
|
|
38
|
+
]);
|
|
39
|
+
expect(fromDeviceAuth).toBe(privateKey);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should work for 100 random keys', async () => {
|
|
43
|
+
for (let i = 0; i < 100; i++) {
|
|
44
|
+
const privateKey = await generateEd25519PrivateKey();
|
|
45
|
+
const result = await splitAndVerify(privateKey);
|
|
46
|
+
|
|
47
|
+
expect(result.verified).toBe(true);
|
|
48
|
+
|
|
49
|
+
const reconstructed = await reconstructFromShares([
|
|
50
|
+
result.shares.deviceShare,
|
|
51
|
+
result.shares.authShare,
|
|
52
|
+
]);
|
|
53
|
+
expect(reconstructed).toBe(privateKey);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('atomicShareUpdate', () => {
|
|
59
|
+
|
|
60
|
+
const createMockStorage = (): StorageOperations & {
|
|
61
|
+
deviceShare: string | null;
|
|
62
|
+
authShare: string | null;
|
|
63
|
+
} => ({
|
|
64
|
+
deviceShare: null,
|
|
65
|
+
authShare: null,
|
|
66
|
+
|
|
67
|
+
storeDevice: vi.fn(async function(this: any, share: string) {
|
|
68
|
+
this.deviceShare = share;
|
|
69
|
+
}),
|
|
70
|
+
|
|
71
|
+
storeAuth: vi.fn(async function(this: any, share: string) {
|
|
72
|
+
this.authShare = share;
|
|
73
|
+
}),
|
|
74
|
+
|
|
75
|
+
getDevice: vi.fn(async function(this: any) {
|
|
76
|
+
return this.deviceShare;
|
|
77
|
+
}),
|
|
78
|
+
|
|
79
|
+
getAuth: vi.fn(async function(this: any) {
|
|
80
|
+
return this.authShare;
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should store both device and auth shares on success', async () => {
|
|
85
|
+
const privateKey = await generateEd25519PrivateKey();
|
|
86
|
+
const storage = createMockStorage();
|
|
87
|
+
|
|
88
|
+
const shares = await atomicShareUpdate(privateKey, storage);
|
|
89
|
+
|
|
90
|
+
expect(storage.storeDevice).toHaveBeenCalledWith(shares.deviceShare);
|
|
91
|
+
expect(storage.storeAuth).toHaveBeenCalledWith(shares.authShare);
|
|
92
|
+
expect(storage.deviceShare).toBe(shares.deviceShare);
|
|
93
|
+
expect(storage.authShare).toBe(shares.authShare);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should rollback device share if auth storage fails', async () => {
|
|
97
|
+
const privateKey = await generateEd25519PrivateKey();
|
|
98
|
+
const previousDeviceShare = 'previous-device-share-hex';
|
|
99
|
+
|
|
100
|
+
const storage = createMockStorage();
|
|
101
|
+
storage.deviceShare = previousDeviceShare;
|
|
102
|
+
|
|
103
|
+
// Make auth storage fail
|
|
104
|
+
storage.storeAuth = vi.fn().mockRejectedValue(new Error('Network error'));
|
|
105
|
+
|
|
106
|
+
const onRollback = vi.fn();
|
|
107
|
+
|
|
108
|
+
await expect(
|
|
109
|
+
atomicShareUpdate(privateKey, storage, {
|
|
110
|
+
previousDeviceShare,
|
|
111
|
+
onRollback,
|
|
112
|
+
})
|
|
113
|
+
).rejects.toThrow(AtomicUpdateError);
|
|
114
|
+
|
|
115
|
+
// Device share should be rolled back to previous value
|
|
116
|
+
expect(storage.deviceShare).toBe(previousDeviceShare);
|
|
117
|
+
expect(onRollback).toHaveBeenCalled();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should throw AtomicUpdateError with correct phase on device storage failure', async () => {
|
|
121
|
+
const privateKey = await generateEd25519PrivateKey();
|
|
122
|
+
const storage = createMockStorage();
|
|
123
|
+
|
|
124
|
+
storage.storeDevice = vi.fn().mockRejectedValue(new Error('IndexedDB error'));
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
await atomicShareUpdate(privateKey, storage);
|
|
128
|
+
expect.fail('Should have thrown');
|
|
129
|
+
} catch (e) {
|
|
130
|
+
expect(e).toBeInstanceOf(AtomicUpdateError);
|
|
131
|
+
const error = e as AtomicUpdateError;
|
|
132
|
+
expect(error.phase).toBe('store_device');
|
|
133
|
+
expect(error.rolledBack).toBe(false);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should throw AtomicUpdateError with correct phase on auth storage failure', async () => {
|
|
138
|
+
const privateKey = await generateEd25519PrivateKey();
|
|
139
|
+
const storage = createMockStorage();
|
|
140
|
+
|
|
141
|
+
storage.storeAuth = vi.fn().mockRejectedValue(new Error('Server error'));
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
await atomicShareUpdate(privateKey, storage, {
|
|
145
|
+
previousDeviceShare: 'previous',
|
|
146
|
+
});
|
|
147
|
+
expect.fail('Should have thrown');
|
|
148
|
+
} catch (e) {
|
|
149
|
+
expect(e).toBeInstanceOf(AtomicUpdateError);
|
|
150
|
+
const error = e as AtomicUpdateError;
|
|
151
|
+
expect(error.phase).toBe('store_auth');
|
|
152
|
+
expect(error.rolledBack).toBe(true);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should return verified shares on success', async () => {
|
|
157
|
+
const privateKey = await generateEd25519PrivateKey();
|
|
158
|
+
const storage = createMockStorage();
|
|
159
|
+
|
|
160
|
+
const shares = await atomicShareUpdate(privateKey, storage);
|
|
161
|
+
|
|
162
|
+
// Verify the returned shares can reconstruct the key
|
|
163
|
+
const reconstructed = await reconstructFromShares([
|
|
164
|
+
shares.deviceShare,
|
|
165
|
+
shares.authShare,
|
|
166
|
+
]);
|
|
167
|
+
expect(reconstructed).toBe(privateKey);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('verifyStoredShares', () => {
|
|
172
|
+
|
|
173
|
+
it('should return healthy=true when shares match expected DID', async () => {
|
|
174
|
+
const privateKey = await generateEd25519PrivateKey();
|
|
175
|
+
const shares = await splitPrivateKey(privateKey);
|
|
176
|
+
const expectedDid = `did:key:${privateKey.slice(0, 16)}`;
|
|
177
|
+
|
|
178
|
+
const storage = {
|
|
179
|
+
getDevice: vi.fn().mockResolvedValue(shares.deviceShare),
|
|
180
|
+
getAuth: vi.fn().mockResolvedValue(shares.authShare),
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const didFromPrivateKey = vi.fn().mockResolvedValue(expectedDid);
|
|
184
|
+
|
|
185
|
+
const result = await verifyStoredShares(storage, expectedDid, didFromPrivateKey);
|
|
186
|
+
|
|
187
|
+
expect(result.healthy).toBe(true);
|
|
188
|
+
expect(result.hasDeviceShare).toBe(true);
|
|
189
|
+
expect(result.hasAuthShare).toBe(true);
|
|
190
|
+
expect(result.didMatches).toBe(true);
|
|
191
|
+
expect(result.error).toBeUndefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should return healthy=false when device share is missing', async () => {
|
|
195
|
+
const storage = {
|
|
196
|
+
getDevice: vi.fn().mockResolvedValue(null),
|
|
197
|
+
getAuth: vi.fn().mockResolvedValue('some-auth-share'),
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const result = await verifyStoredShares(
|
|
201
|
+
storage,
|
|
202
|
+
'did:key:expected',
|
|
203
|
+
async () => 'did:key:any'
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
expect(result.healthy).toBe(false);
|
|
207
|
+
expect(result.hasDeviceShare).toBe(false);
|
|
208
|
+
expect(result.error).toBe('No device share found');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should return healthy=false when auth share is missing', async () => {
|
|
212
|
+
const storage = {
|
|
213
|
+
getDevice: vi.fn().mockResolvedValue('some-device-share'),
|
|
214
|
+
getAuth: vi.fn().mockResolvedValue(null),
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const result = await verifyStoredShares(
|
|
218
|
+
storage,
|
|
219
|
+
'did:key:expected',
|
|
220
|
+
async () => 'did:key:any'
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
expect(result.healthy).toBe(false);
|
|
224
|
+
expect(result.hasDeviceShare).toBe(true);
|
|
225
|
+
expect(result.hasAuthShare).toBe(false);
|
|
226
|
+
expect(result.error).toBe('No auth share found');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should return healthy=false when DID does not match', async () => {
|
|
230
|
+
const privateKey = await generateEd25519PrivateKey();
|
|
231
|
+
const shares = await splitPrivateKey(privateKey);
|
|
232
|
+
|
|
233
|
+
const storage = {
|
|
234
|
+
getDevice: vi.fn().mockResolvedValue(shares.deviceShare),
|
|
235
|
+
getAuth: vi.fn().mockResolvedValue(shares.authShare),
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const result = await verifyStoredShares(
|
|
239
|
+
storage,
|
|
240
|
+
'did:key:expected',
|
|
241
|
+
async () => 'did:key:different'
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
expect(result.healthy).toBe(false);
|
|
245
|
+
expect(result.hasDeviceShare).toBe(true);
|
|
246
|
+
expect(result.hasAuthShare).toBe(true);
|
|
247
|
+
expect(result.didMatches).toBe(false);
|
|
248
|
+
expect(result.error).toContain('DID mismatch');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should handle reconstruction errors gracefully', async () => {
|
|
252
|
+
const storage = {
|
|
253
|
+
getDevice: vi.fn().mockResolvedValue('invalid-share'),
|
|
254
|
+
getAuth: vi.fn().mockResolvedValue('also-invalid'),
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const result = await verifyStoredShares(
|
|
258
|
+
storage,
|
|
259
|
+
'did:key:expected',
|
|
260
|
+
async () => 'did:key:any'
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
expect(result.healthy).toBe(false);
|
|
264
|
+
expect(result.error).toBeDefined();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe('atomicRecovery', () => {
|
|
269
|
+
|
|
270
|
+
it('should reconstruct key and generate new shares', async () => {
|
|
271
|
+
const privateKey = await generateEd25519PrivateKey();
|
|
272
|
+
const originalShares = await splitPrivateKey(privateKey);
|
|
273
|
+
|
|
274
|
+
const storage = {
|
|
275
|
+
deviceShare: null as string | null,
|
|
276
|
+
authShare: null as string | null,
|
|
277
|
+
|
|
278
|
+
storeDevice: vi.fn(async function(this: any, share: string) {
|
|
279
|
+
this.deviceShare = share;
|
|
280
|
+
}),
|
|
281
|
+
|
|
282
|
+
storeAuth: vi.fn(async function(this: any, share: string) {
|
|
283
|
+
this.authShare = share;
|
|
284
|
+
}),
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const result = await atomicRecovery(
|
|
288
|
+
originalShares.recoveryShare,
|
|
289
|
+
originalShares.authShare,
|
|
290
|
+
storage
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
// Should reconstruct the correct private key
|
|
294
|
+
expect(result.privateKey).toBe(privateKey);
|
|
295
|
+
|
|
296
|
+
// Should generate new shares
|
|
297
|
+
expect(result.newShares.deviceShare).toBeDefined();
|
|
298
|
+
expect(result.newShares.authShare).toBeDefined();
|
|
299
|
+
expect(result.newShares.recoveryShare).toBeDefined();
|
|
300
|
+
|
|
301
|
+
// New shares should also reconstruct the key
|
|
302
|
+
const reconstructed = await reconstructFromShares([
|
|
303
|
+
result.newShares.deviceShare,
|
|
304
|
+
result.newShares.authShare,
|
|
305
|
+
]);
|
|
306
|
+
expect(reconstructed).toBe(privateKey);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should store new device and auth shares', async () => {
|
|
310
|
+
const privateKey = await generateEd25519PrivateKey();
|
|
311
|
+
const originalShares = await splitPrivateKey(privateKey);
|
|
312
|
+
|
|
313
|
+
const storage = {
|
|
314
|
+
storeDevice: vi.fn(),
|
|
315
|
+
storeAuth: vi.fn(),
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const result = await atomicRecovery(
|
|
319
|
+
originalShares.recoveryShare,
|
|
320
|
+
originalShares.authShare,
|
|
321
|
+
storage
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
expect(storage.storeDevice).toHaveBeenCalledWith(result.newShares.deviceShare);
|
|
325
|
+
expect(storage.storeAuth).toHaveBeenCalledWith(result.newShares.authShare);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic Share Operations
|
|
3
|
+
*
|
|
4
|
+
* Provides verified split operations and atomic updates with rollback capability.
|
|
5
|
+
* These functions ensure that private keys can NEVER be lost due to partial failures.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { splitPrivateKey, reconstructFromShares, type SSSShares } from './sss';
|
|
9
|
+
|
|
10
|
+
export interface AtomicSplitResult {
|
|
11
|
+
privateKey: string;
|
|
12
|
+
shares: SSSShares;
|
|
13
|
+
verified: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AtomicUpdateOptions {
|
|
17
|
+
previousDeviceShare?: string;
|
|
18
|
+
previousAuthShare?: string;
|
|
19
|
+
onRollback?: (reason: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface StorageOperations {
|
|
23
|
+
storeDevice: (share: string) => Promise<void>;
|
|
24
|
+
storeAuth: (share: string) => Promise<void>;
|
|
25
|
+
getDevice?: () => Promise<string | null>;
|
|
26
|
+
getAuth?: () => Promise<string | null>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ShareVerificationError extends Error {
|
|
30
|
+
constructor(
|
|
31
|
+
message: string,
|
|
32
|
+
public readonly combination: string,
|
|
33
|
+
public readonly expected: string,
|
|
34
|
+
public readonly got: string
|
|
35
|
+
) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'ShareVerificationError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class AtomicUpdateError extends Error {
|
|
42
|
+
constructor(
|
|
43
|
+
message: string,
|
|
44
|
+
public readonly phase: 'split' | 'verify' | 'store_device' | 'store_auth' | 'verify_stored',
|
|
45
|
+
public readonly rolledBack: boolean,
|
|
46
|
+
public readonly cause?: Error
|
|
47
|
+
) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = 'AtomicUpdateError';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Split a private key into shares and verify ALL combinations reconstruct correctly.
|
|
55
|
+
*
|
|
56
|
+
* This function will NOT return until verification passes. If verification fails,
|
|
57
|
+
* it throws an error - no shares are ever returned that don't reconstruct the key.
|
|
58
|
+
*
|
|
59
|
+
* @param privateKey - The private key to split (hex string)
|
|
60
|
+
* @returns Verified shares that are guaranteed to reconstruct the key
|
|
61
|
+
* @throws ShareVerificationError if any share combination fails verification
|
|
62
|
+
*/
|
|
63
|
+
export async function splitAndVerify(privateKey: string): Promise<AtomicSplitResult> {
|
|
64
|
+
const shares = await splitPrivateKey(privateKey);
|
|
65
|
+
|
|
66
|
+
// Verify ALL six combinations (4 shares, threshold 2 → C(4,2) = 6)
|
|
67
|
+
const combinations: [string, string, string][] = [
|
|
68
|
+
['device+auth', shares.deviceShare, shares.authShare],
|
|
69
|
+
['device+recovery', shares.deviceShare, shares.recoveryShare],
|
|
70
|
+
['device+email', shares.deviceShare, shares.emailShare],
|
|
71
|
+
['auth+recovery', shares.authShare, shares.recoveryShare],
|
|
72
|
+
['auth+email', shares.authShare, shares.emailShare],
|
|
73
|
+
['recovery+email', shares.recoveryShare, shares.emailShare],
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
for (const [name, share1, share2] of combinations) {
|
|
77
|
+
const reconstructed = await reconstructFromShares([share1, share2]);
|
|
78
|
+
|
|
79
|
+
if (reconstructed !== privateKey) {
|
|
80
|
+
throw new ShareVerificationError(
|
|
81
|
+
`Share verification failed for ${name}: reconstruction mismatch`,
|
|
82
|
+
name,
|
|
83
|
+
privateKey,
|
|
84
|
+
reconstructed
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
privateKey,
|
|
91
|
+
shares,
|
|
92
|
+
verified: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Atomically update shares with rollback on failure.
|
|
98
|
+
*
|
|
99
|
+
* This function ensures that either:
|
|
100
|
+
* 1. Both device and auth shares are updated successfully, OR
|
|
101
|
+
* 2. The previous state is restored (rollback)
|
|
102
|
+
*
|
|
103
|
+
* The operation flow:
|
|
104
|
+
* 1. Generate and verify new shares
|
|
105
|
+
* 2. Store device share locally
|
|
106
|
+
* 3. Store auth share on server
|
|
107
|
+
* 4. If step 3 fails, rollback step 2
|
|
108
|
+
*
|
|
109
|
+
* @param privateKey - The private key to split
|
|
110
|
+
* @param storage - Storage operations for device and auth shares
|
|
111
|
+
* @param options - Options including previous shares for rollback
|
|
112
|
+
* @returns The new verified shares
|
|
113
|
+
* @throws AtomicUpdateError with rollback status
|
|
114
|
+
*/
|
|
115
|
+
export async function atomicShareUpdate(
|
|
116
|
+
privateKey: string,
|
|
117
|
+
storage: StorageOperations,
|
|
118
|
+
options: AtomicUpdateOptions = {}
|
|
119
|
+
): Promise<SSSShares> {
|
|
120
|
+
let newShares: SSSShares | null = null;
|
|
121
|
+
let deviceStored = false;
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
// Phase 1: Split and verify
|
|
125
|
+
const result = await splitAndVerify(privateKey);
|
|
126
|
+
newShares = result.shares;
|
|
127
|
+
} catch (e) {
|
|
128
|
+
throw new AtomicUpdateError(
|
|
129
|
+
'Failed to split and verify shares',
|
|
130
|
+
'split',
|
|
131
|
+
false,
|
|
132
|
+
e instanceof Error ? e : undefined
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
// Phase 2: Store device share locally
|
|
138
|
+
await storage.storeDevice(newShares.deviceShare);
|
|
139
|
+
deviceStored = true;
|
|
140
|
+
} catch (e) {
|
|
141
|
+
throw new AtomicUpdateError(
|
|
142
|
+
'Failed to store device share locally',
|
|
143
|
+
'store_device',
|
|
144
|
+
false,
|
|
145
|
+
e instanceof Error ? e : undefined
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
// Phase 3: Store auth share on server
|
|
151
|
+
await storage.storeAuth(newShares.authShare);
|
|
152
|
+
} catch (e) {
|
|
153
|
+
// ROLLBACK: Restore previous device share if we have it
|
|
154
|
+
if (options.previousDeviceShare && deviceStored) {
|
|
155
|
+
try {
|
|
156
|
+
await storage.storeDevice(options.previousDeviceShare);
|
|
157
|
+
options.onRollback?.('Server storage failed, restored previous device share');
|
|
158
|
+
} catch (rollbackError) {
|
|
159
|
+
// Rollback failed - this is a critical error
|
|
160
|
+
// The user may be in an inconsistent state
|
|
161
|
+
console.error('CRITICAL: Rollback failed after auth share storage failure', rollbackError);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
throw new AtomicUpdateError(
|
|
166
|
+
'Failed to store auth share on server, rolled back device share',
|
|
167
|
+
'store_auth',
|
|
168
|
+
!!options.previousDeviceShare,
|
|
169
|
+
e instanceof Error ? e : undefined
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return newShares;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Verify that stored shares can reconstruct the expected private key.
|
|
178
|
+
*
|
|
179
|
+
* This is a health check that should be run after recovery or on login
|
|
180
|
+
* to ensure the user's shares are in a consistent state.
|
|
181
|
+
*
|
|
182
|
+
* @param storage - Storage operations to retrieve shares
|
|
183
|
+
* @param expectedDid - The expected DID (used to verify the reconstructed key)
|
|
184
|
+
* @param didFromPrivateKey - Function to derive DID from private key
|
|
185
|
+
* @returns Object with health status and details
|
|
186
|
+
*/
|
|
187
|
+
export async function verifyStoredShares(
|
|
188
|
+
storage: Pick<StorageOperations, 'getDevice' | 'getAuth'>,
|
|
189
|
+
expectedDid: string,
|
|
190
|
+
didFromPrivateKey: (privateKey: string) => Promise<string>
|
|
191
|
+
): Promise<{
|
|
192
|
+
healthy: boolean;
|
|
193
|
+
hasDeviceShare: boolean;
|
|
194
|
+
hasAuthShare: boolean;
|
|
195
|
+
didMatches: boolean;
|
|
196
|
+
error?: string;
|
|
197
|
+
}> {
|
|
198
|
+
const result = {
|
|
199
|
+
healthy: false,
|
|
200
|
+
hasDeviceShare: false,
|
|
201
|
+
hasAuthShare: false,
|
|
202
|
+
didMatches: false,
|
|
203
|
+
error: undefined as string | undefined,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
// Check device share
|
|
208
|
+
const deviceShare = await storage.getDevice?.();
|
|
209
|
+
result.hasDeviceShare = !!deviceShare;
|
|
210
|
+
|
|
211
|
+
if (!deviceShare) {
|
|
212
|
+
result.error = 'No device share found';
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Check auth share
|
|
217
|
+
const authShare = await storage.getAuth?.();
|
|
218
|
+
result.hasAuthShare = !!authShare;
|
|
219
|
+
|
|
220
|
+
if (!authShare) {
|
|
221
|
+
result.error = 'No auth share found';
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Reconstruct and verify DID
|
|
226
|
+
const privateKey = await reconstructFromShares([deviceShare, authShare]);
|
|
227
|
+
const derivedDid = await didFromPrivateKey(privateKey);
|
|
228
|
+
|
|
229
|
+
result.didMatches = derivedDid === expectedDid;
|
|
230
|
+
|
|
231
|
+
if (!result.didMatches) {
|
|
232
|
+
result.error = `DID mismatch: expected ${expectedDid}, got ${derivedDid}`;
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
result.healthy = true;
|
|
237
|
+
return result;
|
|
238
|
+
} catch (e) {
|
|
239
|
+
result.error = e instanceof Error ? e.message : 'Unknown error during verification';
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Create a recovery operation that atomically updates all shares.
|
|
246
|
+
*
|
|
247
|
+
* This is used during recovery when we reconstruct from recovery+auth shares
|
|
248
|
+
* and need to generate a new device share.
|
|
249
|
+
*
|
|
250
|
+
* @param recoveryShare - The decrypted recovery share
|
|
251
|
+
* @param authShareData - The auth share from server
|
|
252
|
+
* @param storage - Storage operations
|
|
253
|
+
* @param options - Atomic update options
|
|
254
|
+
* @returns The reconstructed private key and new shares
|
|
255
|
+
*/
|
|
256
|
+
export async function atomicRecovery(
|
|
257
|
+
recoveryShare: string,
|
|
258
|
+
authShareData: string,
|
|
259
|
+
storage: StorageOperations,
|
|
260
|
+
options: AtomicUpdateOptions = {}
|
|
261
|
+
): Promise<{
|
|
262
|
+
privateKey: string;
|
|
263
|
+
newShares: SSSShares;
|
|
264
|
+
}> {
|
|
265
|
+
// Reconstruct the private key from recovery + auth
|
|
266
|
+
const privateKey = await reconstructFromShares([recoveryShare, authShareData]);
|
|
267
|
+
|
|
268
|
+
// Generate new shares and store atomically
|
|
269
|
+
const newShares = await atomicShareUpdate(privateKey, storage, options);
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
privateKey,
|
|
273
|
+
newShares,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deprecated AuthCoordinator has moved to learn-card-base/auth-coordinator.
|
|
3
|
+
* These tests are no longer valid for this package.
|
|
4
|
+
* See learn-card-base for the canonical AuthCoordinator tests.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
|
|
9
|
+
describe('AuthCoordinator (deprecated)', () => {
|
|
10
|
+
it('has moved to learn-card-base', () => {
|
|
11
|
+
expect(true).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deprecated AuthCoordinator has moved to learn-card-base/auth-coordinator.
|
|
3
|
+
* This file is kept only as a stub during migration.
|
|
4
|
+
* Import from 'learn-card-base' instead:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { AuthCoordinator, createAuthCoordinator } from 'learn-card-base';
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// This file intentionally left empty.
|
|
12
|
+
// The AuthCoordinator class now lives in learn-card-base/src/auth-coordinator/AuthCoordinator.ts
|