@learncard/sss-key-manager 0.1.24 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -95
- package/dist/api-client.d.ts +44 -7
- package/dist/api-client.d.ts.map +1 -1
- package/dist/atomic-operations.d.ts +10 -2
- package/dist/atomic-operations.d.ts.map +1 -1
- package/dist/email-relay-crypto.d.ts +49 -0
- package/dist/email-relay-crypto.d.ts.map +1 -0
- package/dist/escrow-attestation.d.ts +12 -0
- package/dist/escrow-attestation.d.ts.map +1 -0
- package/dist/escrow-crypto.d.ts +52 -0
- package/dist/escrow-crypto.d.ts.map +1 -0
- package/dist/escrow-nitro-attestation.d.ts +32 -0
- package/dist/escrow-nitro-attestation.d.ts.map +1 -0
- package/dist/escrow-pin.d.ts +22 -0
- package/dist/escrow-pin.d.ts.map +1 -0
- package/dist/index.d.ts +15 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/key-manager.d.ts +2 -0
- package/dist/key-manager.d.ts.map +1 -1
- package/dist/qr-login.d.ts.map +1 -1
- package/dist/recovery-phrase.d.ts +6 -0
- package/dist/recovery-phrase.d.ts.map +1 -1
- package/dist/sss-key-manager.cjs.development.js +5564 -353
- package/dist/sss-key-manager.cjs.development.js.map +4 -4
- package/dist/sss-key-manager.cjs.production.min.js +33 -33
- package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
- package/dist/sss-key-manager.esm.js +3494 -354
- package/dist/sss-key-manager.esm.js.map +4 -4
- package/dist/sss-strategy.d.ts +74 -4
- package/dist/sss-strategy.d.ts.map +1 -1
- package/dist/storage.d.ts +1 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/types.d.ts +98 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -3
- package/src/__fixtures__/escrow-vectors-rust.json +82 -0
- package/src/__fixtures__/escrow-vectors.json +103 -0
- package/src/__fixtures__/nitro-attestation/bad-signature.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/debug-zero-pcrs.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/legacy-user-data.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/manifest.json +60 -0
- package/src/__fixtures__/nitro-attestation/nonce-mismatch.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/root.pem +12 -0
- package/src/__fixtures__/nitro-attestation/stale-timestamp.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/tampered-previous-key-ids.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/truncated-chain.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/valid.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/wrong-pcr0.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/wrong-root.cbor.b64 +1 -0
- package/src/api-client.test.ts +122 -0
- package/src/api-client.ts +107 -116
- package/src/atomic-operations.test.ts +56 -16
- package/src/atomic-operations.ts +55 -19
- package/src/critical-paths.test.ts +51 -1
- package/src/email-relay-crypto.test.ts +87 -0
- package/src/email-relay-crypto.ts +293 -0
- package/src/escrow-attestation.test.ts +90 -0
- package/src/escrow-attestation.ts +125 -0
- package/src/escrow-crypto.test.ts +381 -0
- package/src/escrow-crypto.ts +319 -0
- package/src/escrow-nitro-attestation.test.ts +204 -0
- package/src/escrow-nitro-attestation.ts +286 -0
- package/src/escrow-pin.test.ts +119 -0
- package/src/escrow-pin.ts +83 -0
- package/src/escrow-vectors.test.ts +198 -0
- package/src/index.ts +70 -1
- package/src/key-manager.ts +4 -9
- package/src/qr-login.ts +22 -15
- package/src/recovery-phrase.ts +40 -0
- package/src/sss-session-writes.test.ts +109 -0
- package/src/sss-strategy.test.ts +2736 -111
- package/src/sss-strategy.ts +2226 -224
- package/src/storage.test.ts +66 -8
- package/src/storage.ts +41 -29
- package/src/types.ts +118 -6
|
@@ -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
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* Run these tests before any release that touches SSS code.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import
|
|
12
|
+
import 'fake-indexeddb/auto';
|
|
13
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
14
|
+
import { createSSSStrategy } from './sss-strategy';
|
|
13
15
|
|
|
14
16
|
import { splitPrivateKey, reconstructFromShares, SSS_THRESHOLD } from './sss';
|
|
15
17
|
import { generateEd25519PrivateKey, encryptWithPassword, decryptWithPassword } from './crypto';
|
|
@@ -20,6 +22,54 @@ import {
|
|
|
20
22
|
} from './recovery-phrase';
|
|
21
23
|
|
|
22
24
|
describe('Critical: Key must NEVER be lost', () => {
|
|
25
|
+
it('recovers a fresh account from encrypted IndexedDB after the auth-share reply is lost', async () => {
|
|
26
|
+
const config = { serverUrl: 'https://test.invalid/api' };
|
|
27
|
+
const strategy = createSSSStrategy(config);
|
|
28
|
+
strategy.setActiveUser!('indexeddb-lost-response');
|
|
29
|
+
const privateKey = await generateEd25519PrivateKey();
|
|
30
|
+
const expectedDid = 'did:key:test-owner';
|
|
31
|
+
let authShare = '';
|
|
32
|
+
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
33
|
+
if (init?.method === 'PUT') {
|
|
34
|
+
authShare = JSON.parse(String(init.body)).authShare.encryptedData;
|
|
35
|
+
throw new TypeError('Lost reply');
|
|
36
|
+
}
|
|
37
|
+
return new Response(
|
|
38
|
+
JSON.stringify({ authShare, shareVersion: 1, primaryDid: expectedDid })
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await expect(
|
|
44
|
+
strategy.atomicUpdateShares!({
|
|
45
|
+
token: 'token',
|
|
46
|
+
providerType: 'firebase',
|
|
47
|
+
privateKey,
|
|
48
|
+
did: expectedDid,
|
|
49
|
+
})
|
|
50
|
+
).rejects.toMatchObject({ rolledBack: false });
|
|
51
|
+
|
|
52
|
+
const reloaded = createSSSStrategy(config);
|
|
53
|
+
reloaded.setActiveUser!('indexeddb-lost-response');
|
|
54
|
+
expect(await reloaded.hasLocalKey()).toBe(true);
|
|
55
|
+
const result = await reloaded.reconcileShares!({
|
|
56
|
+
token: 'token',
|
|
57
|
+
providerType: 'firebase',
|
|
58
|
+
expectedDid,
|
|
59
|
+
didFromPrivateKey: async key =>
|
|
60
|
+
key === privateKey ? expectedDid : 'did:key:wrong',
|
|
61
|
+
});
|
|
62
|
+
expect(result).toEqual({ privateKey, did: expectedDid });
|
|
63
|
+
expect(await reloaded.getLocalShareVersion!()).toBe(1);
|
|
64
|
+
expect(await reconstructFromShares([(await reloaded.getLocalKey())!, authShare])).toBe(
|
|
65
|
+
privateKey
|
|
66
|
+
);
|
|
67
|
+
} finally {
|
|
68
|
+
fetchMock.mockRestore();
|
|
69
|
+
await strategy.clearLocalKeys();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
23
73
|
describe('Share split verification (fuzz test)', () => {
|
|
24
74
|
it('should verify all 6 share combinations reconstruct the key (100 iterations)', async () => {
|
|
25
75
|
for (let i = 0; i < 100; i++) {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { bufferToBase64 } from './crypto';
|
|
4
|
+
import {
|
|
5
|
+
decryptEmailRelayPayload,
|
|
6
|
+
encryptEmailRelayPayload,
|
|
7
|
+
generateEmailRelayConfirmationCode,
|
|
8
|
+
} from './email-relay-crypto';
|
|
9
|
+
|
|
10
|
+
describe('email relay encryption', () => {
|
|
11
|
+
let publicKeyBase64 = '';
|
|
12
|
+
let privateKeyBase64 = '';
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
const keyPair = await crypto.subtle.generateKey(
|
|
16
|
+
{ name: 'ECDH', namedCurve: 'P-256' },
|
|
17
|
+
true,
|
|
18
|
+
['deriveBits']
|
|
19
|
+
);
|
|
20
|
+
const [publicKey, privateKey] = await Promise.all([
|
|
21
|
+
crypto.subtle.exportKey('spki', keyPair.publicKey),
|
|
22
|
+
crypto.subtle.exportKey('pkcs8', keyPair.privateKey),
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
publicKeyBase64 = bufferToBase64(publicKey);
|
|
26
|
+
privateKeyBase64 = bufferToBase64(privateKey);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('sends no raw recovery key and round-trips with the relay private key', async () => {
|
|
30
|
+
const recoveryKey = `0001${'ab'.repeat(48)}`;
|
|
31
|
+
const confirmationCode = '483920';
|
|
32
|
+
const envelope = await encryptEmailRelayPayload(
|
|
33
|
+
{
|
|
34
|
+
targetEmail: 'Recovery@Example.com',
|
|
35
|
+
recoveryKey,
|
|
36
|
+
confirmationCode,
|
|
37
|
+
branding: { brandName: 'Test Tenant', fromDomain: 'example.com' },
|
|
38
|
+
},
|
|
39
|
+
publicKeyBase64,
|
|
40
|
+
'test-key-1'
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
expect(JSON.stringify(envelope)).not.toContain(recoveryKey);
|
|
44
|
+
expect(JSON.stringify(envelope)).not.toContain(confirmationCode);
|
|
45
|
+
await expect(decryptEmailRelayPayload(envelope, privateKeyBase64)).resolves.toEqual({
|
|
46
|
+
version: 1,
|
|
47
|
+
targetEmail: 'recovery@example.com',
|
|
48
|
+
recoveryKey,
|
|
49
|
+
confirmationCode,
|
|
50
|
+
branding: { brandName: 'Test Tenant', fromDomain: 'example.com' },
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('authenticates envelope metadata and ciphertext', async () => {
|
|
55
|
+
const envelope = await encryptEmailRelayPayload(
|
|
56
|
+
{
|
|
57
|
+
targetEmail: 'recovery@example.com',
|
|
58
|
+
recoveryKey: `0002${'cd'.repeat(48)}`,
|
|
59
|
+
confirmationCode: '123456',
|
|
60
|
+
},
|
|
61
|
+
publicKeyBase64,
|
|
62
|
+
'test-key-1'
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
await expect(
|
|
66
|
+
decryptEmailRelayPayload({ ...envelope, keyId: 'other-key' }, privateKeyBase64)
|
|
67
|
+
).rejects.toThrow();
|
|
68
|
+
|
|
69
|
+
const lastCharacter = envelope.ciphertext.at(-1);
|
|
70
|
+
const tamperedCiphertext = `${envelope.ciphertext.slice(0, -1)}${
|
|
71
|
+
lastCharacter === 'A' ? 'B' : 'A'
|
|
72
|
+
}`;
|
|
73
|
+
|
|
74
|
+
await expect(
|
|
75
|
+
decryptEmailRelayPayload(
|
|
76
|
+
{ ...envelope, ciphertext: tamperedCiphertext },
|
|
77
|
+
privateKeyBase64
|
|
78
|
+
)
|
|
79
|
+
).rejects.toThrow();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('generates exactly six decimal digits', () => {
|
|
83
|
+
const codes = Array.from({ length: 100 }, generateEmailRelayConfirmationCode);
|
|
84
|
+
|
|
85
|
+
expect(codes.every(code => /^\d{6}$/.test(code))).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
});
|