@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.
@@ -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