@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
package/src/qr-login.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QR Login Client
|
|
3
|
+
*
|
|
4
|
+
* High-level orchestrator for cross-device login via QR code or short code.
|
|
5
|
+
* Coordinates between the relay API and the ECDH crypto layer.
|
|
6
|
+
*
|
|
7
|
+
* Two roles:
|
|
8
|
+
* - **Requester** (Device B, new device): creates session, renders QR, polls
|
|
9
|
+
* - **Approver** (Device A, logged-in device): reads QR/code, encrypts share, approves
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { AuthProvider } from './types';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
generateEphemeralKeypair,
|
|
16
|
+
encryptShareForTransfer,
|
|
17
|
+
decryptShareFromTransfer,
|
|
18
|
+
} from './qr-crypto';
|
|
19
|
+
|
|
20
|
+
import type { EphemeralKeypair, EncryptedSharePayload } from './qr-crypto';
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Types
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export interface QrLoginSession {
|
|
27
|
+
sessionId: string;
|
|
28
|
+
shortCode: string;
|
|
29
|
+
expiresInSeconds: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface QrLoginSessionInfo {
|
|
33
|
+
sessionId: string;
|
|
34
|
+
publicKey: string;
|
|
35
|
+
status: 'pending' | 'approved';
|
|
36
|
+
encryptedPayload?: string;
|
|
37
|
+
approverDid?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface QrLoginClientConfig {
|
|
41
|
+
serverUrl: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface QrPayload {
|
|
45
|
+
/** Session ID for the relay */
|
|
46
|
+
sessionId: string;
|
|
47
|
+
|
|
48
|
+
/** Base64-encoded ephemeral X25519 public key */
|
|
49
|
+
publicKey: string;
|
|
50
|
+
|
|
51
|
+
/** Server URL for the relay */
|
|
52
|
+
serverUrl: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Result of polling — either still waiting or the device share is ready */
|
|
56
|
+
export type PollResult =
|
|
57
|
+
| { status: 'pending' }
|
|
58
|
+
| { status: 'approved'; deviceShare: string; approverDid: string; accountHint?: string; shareVersion?: number };
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// API helpers
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
const buildHeaders = (token?: string): Record<string, string> => {
|
|
65
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
66
|
+
|
|
67
|
+
if (token) {
|
|
68
|
+
headers.Authorization = `Bearer ${token}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return headers;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Requester (Device B — the new device)
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create a QR login session and generate the ephemeral keypair.
|
|
80
|
+
*
|
|
81
|
+
* Returns everything Device B needs to display a QR and start polling.
|
|
82
|
+
* The ephemeral private key is kept in memory — never serialized.
|
|
83
|
+
*/
|
|
84
|
+
export const createQrLoginSession = async (
|
|
85
|
+
config: QrLoginClientConfig
|
|
86
|
+
): Promise<{
|
|
87
|
+
session: QrLoginSession;
|
|
88
|
+
ephemeralKeypair: EphemeralKeypair;
|
|
89
|
+
qrPayload: QrPayload;
|
|
90
|
+
}> => {
|
|
91
|
+
const ephemeralKeypair = await generateEphemeralKeypair();
|
|
92
|
+
|
|
93
|
+
const response = await fetch(`${config.serverUrl}/qr-login/session`, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: buildHeaders(),
|
|
96
|
+
body: JSON.stringify({ publicKey: ephemeralKeypair.publicKey }),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
throw new Error(`Failed to create QR login session: ${response.statusText}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const session: QrLoginSession = await response.json();
|
|
104
|
+
|
|
105
|
+
const qrPayload: QrPayload = {
|
|
106
|
+
sessionId: session.sessionId,
|
|
107
|
+
publicKey: ephemeralKeypair.publicKey,
|
|
108
|
+
serverUrl: config.serverUrl,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return { session, ephemeralKeypair, qrPayload };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Poll a QR login session for approval.
|
|
116
|
+
*
|
|
117
|
+
* When Device A approves, this returns the decrypted device share.
|
|
118
|
+
*
|
|
119
|
+
* @param config - Server config
|
|
120
|
+
* @param sessionId - The session to poll
|
|
121
|
+
* @param ephemeralPrivateKey - Device B's ephemeral private key (for decryption)
|
|
122
|
+
*/
|
|
123
|
+
export const pollQrLoginSession = async (
|
|
124
|
+
config: QrLoginClientConfig,
|
|
125
|
+
sessionId: string,
|
|
126
|
+
ephemeralPrivateKey: CryptoKey
|
|
127
|
+
): Promise<PollResult> => {
|
|
128
|
+
const response = await fetch(`${config.serverUrl}/qr-login/session/${sessionId}`, {
|
|
129
|
+
method: 'GET',
|
|
130
|
+
headers: buildHeaders(),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
throw new Error(`Failed to poll QR login session: ${response.statusText}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const info: QrLoginSessionInfo = await response.json();
|
|
138
|
+
|
|
139
|
+
if (info.status === 'pending') {
|
|
140
|
+
return { status: 'pending' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!info.encryptedPayload || !info.approverDid) {
|
|
144
|
+
throw new Error('Session approved but missing payload');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Parse the encrypted payload and decrypt
|
|
148
|
+
const payload: EncryptedSharePayload = JSON.parse(info.encryptedPayload);
|
|
149
|
+
|
|
150
|
+
const plaintext = await decryptShareFromTransfer(payload, ephemeralPrivateKey);
|
|
151
|
+
|
|
152
|
+
const parsed = JSON.parse(plaintext) as { d: string; h?: string; v?: number };
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
status: 'approved',
|
|
156
|
+
deviceShare: parsed.d,
|
|
157
|
+
approverDid: info.approverDid,
|
|
158
|
+
accountHint: parsed.h,
|
|
159
|
+
shareVersion: parsed.v,
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Convenience: poll in a loop until approved or timeout.
|
|
165
|
+
*
|
|
166
|
+
* @param config - Server config
|
|
167
|
+
* @param sessionId - The session to poll
|
|
168
|
+
* @param ephemeralPrivateKey - Device B's ephemeral private key
|
|
169
|
+
* @param intervalMs - Polling interval (default 2000ms)
|
|
170
|
+
* @param timeoutMs - Total timeout (default 120000ms)
|
|
171
|
+
* @param onPoll - Optional callback on each poll (for UI updates)
|
|
172
|
+
*/
|
|
173
|
+
export const pollUntilApproved = async (
|
|
174
|
+
config: QrLoginClientConfig,
|
|
175
|
+
sessionId: string,
|
|
176
|
+
ephemeralPrivateKey: CryptoKey,
|
|
177
|
+
options?: {
|
|
178
|
+
intervalMs?: number;
|
|
179
|
+
timeoutMs?: number;
|
|
180
|
+
onPoll?: (attempt: number) => void;
|
|
181
|
+
signal?: AbortSignal;
|
|
182
|
+
}
|
|
183
|
+
): Promise<{ deviceShare: string; approverDid: string; accountHint?: string; shareVersion?: number }> => {
|
|
184
|
+
const intervalMs = options?.intervalMs ?? 2000;
|
|
185
|
+
const timeoutMs = options?.timeoutMs ?? 120_000;
|
|
186
|
+
|
|
187
|
+
const deadline = Date.now() + timeoutMs;
|
|
188
|
+
let attempt = 0;
|
|
189
|
+
|
|
190
|
+
while (Date.now() < deadline) {
|
|
191
|
+
if (options?.signal?.aborted) {
|
|
192
|
+
throw new Error('QR login polling aborted');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
attempt++;
|
|
196
|
+
options?.onPoll?.(attempt);
|
|
197
|
+
|
|
198
|
+
const result = await pollQrLoginSession(config, sessionId, ephemeralPrivateKey);
|
|
199
|
+
|
|
200
|
+
if (result.status === 'approved') {
|
|
201
|
+
return {
|
|
202
|
+
deviceShare: result.deviceShare,
|
|
203
|
+
approverDid: result.approverDid,
|
|
204
|
+
accountHint: result.accountHint,
|
|
205
|
+
shareVersion: result.shareVersion,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Wait before next poll
|
|
210
|
+
await new Promise<void>((resolve, reject) => {
|
|
211
|
+
const timer = setTimeout(resolve, intervalMs);
|
|
212
|
+
|
|
213
|
+
if (options?.signal) {
|
|
214
|
+
options.signal.addEventListener('abort', () => {
|
|
215
|
+
clearTimeout(timer);
|
|
216
|
+
reject(new Error('QR login polling aborted'));
|
|
217
|
+
}, { once: true });
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
throw new Error('QR login session timed out');
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Approver (Device A — the logged-in device)
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Fetch a QR login session's public key (for Device A to encrypt against).
|
|
231
|
+
*
|
|
232
|
+
* @param config - Server config
|
|
233
|
+
* @param lookup - Session ID or 6-digit short code
|
|
234
|
+
*/
|
|
235
|
+
export const getQrLoginSessionInfo = async (
|
|
236
|
+
config: QrLoginClientConfig,
|
|
237
|
+
lookup: string
|
|
238
|
+
): Promise<QrLoginSessionInfo> => {
|
|
239
|
+
const response = await fetch(`${config.serverUrl}/qr-login/session/${lookup}`, {
|
|
240
|
+
method: 'GET',
|
|
241
|
+
headers: buildHeaders(),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (!response.ok) {
|
|
245
|
+
throw new Error(`Session not found or expired`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return response.json();
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Approve a QR login session by encrypting and pushing the device share.
|
|
253
|
+
*
|
|
254
|
+
* Called by Device A (the logged-in device).
|
|
255
|
+
*
|
|
256
|
+
* @param config - Server config
|
|
257
|
+
* @param sessionId - The session to approve
|
|
258
|
+
* @param deviceShare - Plaintext device share from Device A's local storage
|
|
259
|
+
* @param approverDid - DID of the approving device
|
|
260
|
+
* @param recipientPublicKey - Base64 X25519 public key from the session
|
|
261
|
+
* @param accountHint - Optional email or phone of the approver's account (sent to Device B as a login hint)
|
|
262
|
+
* @param shareVersion - Optional share version so Device B can fetch the matching auth share
|
|
263
|
+
*/
|
|
264
|
+
export const approveQrLoginSession = async (
|
|
265
|
+
config: QrLoginClientConfig,
|
|
266
|
+
sessionId: string,
|
|
267
|
+
deviceShare: string,
|
|
268
|
+
approverDid: string,
|
|
269
|
+
recipientPublicKey: string,
|
|
270
|
+
accountHint?: string,
|
|
271
|
+
shareVersion?: number
|
|
272
|
+
): Promise<void> => {
|
|
273
|
+
// Wrap the device share + account hint + version in a JSON envelope
|
|
274
|
+
const plaintext = JSON.stringify({ d: deviceShare, h: accountHint, v: shareVersion });
|
|
275
|
+
|
|
276
|
+
// Encrypt with ECDH
|
|
277
|
+
const encryptedPayload = await encryptShareForTransfer(plaintext, recipientPublicKey);
|
|
278
|
+
|
|
279
|
+
const response = await fetch(`${config.serverUrl}/qr-login/session/${sessionId}/approve`, {
|
|
280
|
+
method: 'POST',
|
|
281
|
+
headers: buildHeaders(),
|
|
282
|
+
body: JSON.stringify({
|
|
283
|
+
sessionId,
|
|
284
|
+
encryptedPayload: JSON.stringify(encryptedPayload),
|
|
285
|
+
approverDid,
|
|
286
|
+
}),
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
if (!response.ok) {
|
|
290
|
+
throw new Error(`Failed to approve QR login session: ${response.statusText}`);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// Push notification helper
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
export interface NotifyDevicesResult {
|
|
299
|
+
sent: boolean;
|
|
300
|
+
deviceCount: number;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Send a push notification to the authenticated user's other devices,
|
|
305
|
+
* prompting them to open the approver flow for the given QR session.
|
|
306
|
+
*
|
|
307
|
+
* Called by Device B (in needs_recovery) after creating a session.
|
|
308
|
+
* Requires the user's Firebase (or other auth provider) token.
|
|
309
|
+
*
|
|
310
|
+
* This is fire-and-forget — failure does not block the QR login flow.
|
|
311
|
+
*/
|
|
312
|
+
export const notifyDevicesForQrSession = async (
|
|
313
|
+
config: QrLoginClientConfig,
|
|
314
|
+
sessionId: string,
|
|
315
|
+
shortCode: string,
|
|
316
|
+
authToken: string,
|
|
317
|
+
providerType: string = 'firebase'
|
|
318
|
+
): Promise<NotifyDevicesResult> => {
|
|
319
|
+
try {
|
|
320
|
+
const response = await fetch(`${config.serverUrl}/qr-login/notify`, {
|
|
321
|
+
method: 'POST',
|
|
322
|
+
headers: buildHeaders(),
|
|
323
|
+
body: JSON.stringify({
|
|
324
|
+
authToken,
|
|
325
|
+
providerType,
|
|
326
|
+
sessionId,
|
|
327
|
+
shortCode,
|
|
328
|
+
}),
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
if (!response.ok) {
|
|
332
|
+
return { sent: false, deviceCount: 0 };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return (await response.json()) as NotifyDevicesResult;
|
|
336
|
+
} catch {
|
|
337
|
+
return { sent: false, deviceCount: 0 };
|
|
338
|
+
}
|
|
339
|
+
};
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
shareToRecoveryPhrase,
|
|
4
|
+
recoveryPhraseToShare,
|
|
5
|
+
validateRecoveryPhrase,
|
|
6
|
+
countWords,
|
|
7
|
+
} from './recovery-phrase';
|
|
8
|
+
import { splitPrivateKey, reconstructFromShares } from './sss';
|
|
9
|
+
|
|
10
|
+
describe('Recovery Phrase utilities', () => {
|
|
11
|
+
const testShareHex = 'abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab';
|
|
12
|
+
|
|
13
|
+
describe('shareToRecoveryPhrase', () => {
|
|
14
|
+
it('should convert a hex share to a recovery phrase', async () => {
|
|
15
|
+
const phrase = await shareToRecoveryPhrase(testShareHex);
|
|
16
|
+
|
|
17
|
+
expect(typeof phrase).toBe('string');
|
|
18
|
+
expect(phrase.length).toBeGreaterThan(0);
|
|
19
|
+
|
|
20
|
+
const words = phrase.split(' ');
|
|
21
|
+
expect(words.length).toBeGreaterThanOrEqual(12);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should produce consistent output for same input', async () => {
|
|
25
|
+
const phrase1 = await shareToRecoveryPhrase(testShareHex);
|
|
26
|
+
const phrase2 = await shareToRecoveryPhrase(testShareHex);
|
|
27
|
+
|
|
28
|
+
expect(phrase1).toBe(phrase2);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should produce different phrases for different shares', async () => {
|
|
32
|
+
const share1 = 'a'.repeat(64);
|
|
33
|
+
const share2 = 'b'.repeat(64);
|
|
34
|
+
|
|
35
|
+
const phrase1 = await shareToRecoveryPhrase(share1);
|
|
36
|
+
const phrase2 = await shareToRecoveryPhrase(share2);
|
|
37
|
+
|
|
38
|
+
expect(phrase1).not.toBe(phrase2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should only contain valid BIP39 words', async () => {
|
|
42
|
+
const phrase = await shareToRecoveryPhrase(testShareHex);
|
|
43
|
+
const words = phrase.split(' ');
|
|
44
|
+
|
|
45
|
+
for (const word of words) {
|
|
46
|
+
expect(word.length).toBeGreaterThan(0);
|
|
47
|
+
expect(/^[a-z]+$/.test(word)).toBe(true);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('recoveryPhraseToShare', () => {
|
|
53
|
+
it('should convert a recovery phrase back to the original share', async () => {
|
|
54
|
+
const phrase = await shareToRecoveryPhrase(testShareHex);
|
|
55
|
+
const recoveredShare = await recoveryPhraseToShare(phrase);
|
|
56
|
+
|
|
57
|
+
expect(recoveredShare).toBe(testShareHex);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should handle phrases with extra whitespace', async () => {
|
|
61
|
+
const phrase = await shareToRecoveryPhrase(testShareHex);
|
|
62
|
+
const paddedPhrase = ` ${phrase.split(' ').join(' ')} `;
|
|
63
|
+
|
|
64
|
+
const recoveredShare = await recoveryPhraseToShare(paddedPhrase);
|
|
65
|
+
|
|
66
|
+
expect(recoveredShare).toBe(testShareHex);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should handle uppercase words', async () => {
|
|
70
|
+
const phrase = await shareToRecoveryPhrase(testShareHex);
|
|
71
|
+
const upperPhrase = phrase.toUpperCase();
|
|
72
|
+
|
|
73
|
+
const recoveredShare = await recoveryPhraseToShare(upperPhrase);
|
|
74
|
+
|
|
75
|
+
expect(recoveredShare).toBe(testShareHex);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should handle mixed case words', async () => {
|
|
79
|
+
const phrase = await shareToRecoveryPhrase(testShareHex);
|
|
80
|
+
const mixedPhrase = phrase.split(' ').map((w, i) =>
|
|
81
|
+
i % 2 === 0 ? w.toUpperCase() : w
|
|
82
|
+
).join(' ');
|
|
83
|
+
|
|
84
|
+
const recoveredShare = await recoveryPhraseToShare(mixedPhrase);
|
|
85
|
+
|
|
86
|
+
expect(recoveredShare).toBe(testShareHex);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should reject phrases with too few words', async () => {
|
|
90
|
+
await expect(
|
|
91
|
+
recoveryPhraseToShare('abandon ability able')
|
|
92
|
+
).rejects.toThrow('must be 12-27 words');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should reject phrases with invalid words', async () => {
|
|
96
|
+
const phrase = await shareToRecoveryPhrase(testShareHex);
|
|
97
|
+
const words = phrase.split(' ');
|
|
98
|
+
words[0] = 'notavalidword';
|
|
99
|
+
|
|
100
|
+
await expect(
|
|
101
|
+
recoveryPhraseToShare(words.join(' '))
|
|
102
|
+
).rejects.toThrow('Invalid word');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should reject phrases with invalid checksum', async () => {
|
|
106
|
+
const phrase = await shareToRecoveryPhrase(testShareHex);
|
|
107
|
+
const words = phrase.split(' ');
|
|
108
|
+
|
|
109
|
+
const firstWord = words[0];
|
|
110
|
+
words[0] = words[1];
|
|
111
|
+
words[1] = firstWord;
|
|
112
|
+
|
|
113
|
+
await expect(
|
|
114
|
+
recoveryPhraseToShare(words.join(' '))
|
|
115
|
+
).rejects.toThrow('checksum');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('validateRecoveryPhrase', () => {
|
|
120
|
+
it('should return true for valid phrase', async () => {
|
|
121
|
+
const phrase = await shareToRecoveryPhrase(testShareHex);
|
|
122
|
+
const isValid = await validateRecoveryPhrase(phrase);
|
|
123
|
+
|
|
124
|
+
expect(isValid).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should return false for invalid phrase', async () => {
|
|
128
|
+
const isValid = await validateRecoveryPhrase('invalid phrase that is not valid');
|
|
129
|
+
|
|
130
|
+
expect(isValid).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should return false for phrase with wrong checksum', async () => {
|
|
134
|
+
const phrase = await shareToRecoveryPhrase(testShareHex);
|
|
135
|
+
const words = phrase.split(' ');
|
|
136
|
+
const temp = words[0];
|
|
137
|
+
words[0] = words[1];
|
|
138
|
+
words[1] = temp;
|
|
139
|
+
|
|
140
|
+
const isValid = await validateRecoveryPhrase(words.join(' '));
|
|
141
|
+
|
|
142
|
+
expect(isValid).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should return false for empty string', async () => {
|
|
146
|
+
const isValid = await validateRecoveryPhrase('');
|
|
147
|
+
|
|
148
|
+
expect(isValid).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should return false for phrase with too few words', async () => {
|
|
152
|
+
const isValid = await validateRecoveryPhrase('abandon ability able about');
|
|
153
|
+
|
|
154
|
+
expect(isValid).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('countWords', () => {
|
|
159
|
+
it('should count words correctly', () => {
|
|
160
|
+
expect(countWords('one two three')).toBe(3);
|
|
161
|
+
expect(countWords('single')).toBe(1);
|
|
162
|
+
expect(countWords('')).toBe(0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should handle extra whitespace', () => {
|
|
166
|
+
expect(countWords(' one two three ')).toBe(3);
|
|
167
|
+
expect(countWords('\tone\ttwo\t')).toBe(2);
|
|
168
|
+
expect(countWords('\n\n')).toBe(0);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should handle 12-word phrase', () => {
|
|
172
|
+
const phrase = 'abandon ability able about above absent absorb abstract absurd abuse access accident';
|
|
173
|
+
expect(countWords(phrase)).toBe(12);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should handle 24-word phrase', () => {
|
|
177
|
+
const phrase = 'abandon ability able about above absent absorb abstract absurd abuse access accident account accuse achieve acid acoustic acquire across act action actor actress actual';
|
|
178
|
+
expect(countWords(phrase)).toBe(24);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('Round-trip conversion', () => {
|
|
183
|
+
it('should round-trip various share lengths', async () => {
|
|
184
|
+
const shares = [
|
|
185
|
+
'00'.repeat(16),
|
|
186
|
+
'ff'.repeat(16),
|
|
187
|
+
'abcdef'.repeat(8),
|
|
188
|
+
'123456789abcdef0'.repeat(4),
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
for (const share of shares) {
|
|
192
|
+
const phrase = await shareToRecoveryPhrase(share);
|
|
193
|
+
const recovered = await recoveryPhraseToShare(phrase);
|
|
194
|
+
expect(recovered).toBe(share);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should handle 32-byte (64 hex char) shares correctly', async () => {
|
|
199
|
+
const share32bytes = 'a'.repeat(64);
|
|
200
|
+
|
|
201
|
+
const phrase = await shareToRecoveryPhrase(share32bytes);
|
|
202
|
+
const recovered = await recoveryPhraseToShare(phrase);
|
|
203
|
+
|
|
204
|
+
expect(recovered).toBe(share32bytes);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('Full SSS + Recovery Phrase Integration', () => {
|
|
210
|
+
|
|
211
|
+
it('should generate phrase from SSS recovery share and reconstruct original key', async () => {
|
|
212
|
+
// 1. Start with a private key (32 bytes = 64 hex chars)
|
|
213
|
+
const originalPrivateKey = 'abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab';
|
|
214
|
+
|
|
215
|
+
// 2. Split into SSS shares
|
|
216
|
+
const shares = await splitPrivateKey(originalPrivateKey);
|
|
217
|
+
console.log('Device share length:', shares.deviceShare.length);
|
|
218
|
+
console.log('Auth share length:', shares.authShare.length);
|
|
219
|
+
console.log('Recovery share length:', shares.recoveryShare.length);
|
|
220
|
+
|
|
221
|
+
// 3. Convert recovery share to phrase
|
|
222
|
+
const phrase = await shareToRecoveryPhrase(shares.recoveryShare);
|
|
223
|
+
const wordCount = phrase.split(' ').length;
|
|
224
|
+
console.log('Generated phrase word count:', wordCount);
|
|
225
|
+
console.log('Phrase:', phrase);
|
|
226
|
+
|
|
227
|
+
// 4. Validate the phrase
|
|
228
|
+
const isValid = await validateRecoveryPhrase(phrase);
|
|
229
|
+
expect(isValid).toBe(true);
|
|
230
|
+
|
|
231
|
+
// 5. Convert phrase back to share
|
|
232
|
+
const recoveredShare = await recoveryPhraseToShare(phrase);
|
|
233
|
+
expect(recoveredShare).toBe(shares.recoveryShare);
|
|
234
|
+
|
|
235
|
+
// 6. Reconstruct private key using recovery share + auth share
|
|
236
|
+
const reconstructedKey = await reconstructFromShares([
|
|
237
|
+
recoveredShare,
|
|
238
|
+
shares.authShare,
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
expect(reconstructedKey).toBe(originalPrivateKey);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should work with randomly generated private keys', async () => {
|
|
245
|
+
// Generate a few random 32-byte keys
|
|
246
|
+
for (let i = 0; i < 5; i++) {
|
|
247
|
+
const randomKey = Array.from({ length: 64 }, () =>
|
|
248
|
+
Math.floor(Math.random() * 16).toString(16)
|
|
249
|
+
).join('');
|
|
250
|
+
|
|
251
|
+
const shares = await splitPrivateKey(randomKey);
|
|
252
|
+
const phrase = await shareToRecoveryPhrase(shares.recoveryShare);
|
|
253
|
+
|
|
254
|
+
// Validate
|
|
255
|
+
const isValid = await validateRecoveryPhrase(phrase);
|
|
256
|
+
expect(isValid).toBe(true);
|
|
257
|
+
|
|
258
|
+
// Round-trip
|
|
259
|
+
const recoveredShare = await recoveryPhraseToShare(phrase);
|
|
260
|
+
expect(recoveredShare).toBe(shares.recoveryShare);
|
|
261
|
+
|
|
262
|
+
// Reconstruct
|
|
263
|
+
const reconstructed = await reconstructFromShares([
|
|
264
|
+
recoveredShare,
|
|
265
|
+
shares.authShare,
|
|
266
|
+
]);
|
|
267
|
+
expect(reconstructed).toBe(randomKey);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should produce consistent word count for SSS shares', async () => {
|
|
272
|
+
const privateKey = 'abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab';
|
|
273
|
+
const shares = await splitPrivateKey(privateKey);
|
|
274
|
+
|
|
275
|
+
const phrase = await shareToRecoveryPhrase(shares.recoveryShare);
|
|
276
|
+
const wordCount = phrase.split(' ').length;
|
|
277
|
+
|
|
278
|
+
// SSS shares from shamir-secret-sharing include an index byte
|
|
279
|
+
// 33 bytes = 264 bits + 8 checksum = 272 bits / 11 = ~25 words
|
|
280
|
+
console.log('SSS recovery share hex length:', shares.recoveryShare.length);
|
|
281
|
+
console.log('SSS recovery share byte length:', shares.recoveryShare.length / 2);
|
|
282
|
+
console.log('Word count:', wordCount);
|
|
283
|
+
|
|
284
|
+
expect(wordCount).toBeGreaterThanOrEqual(24);
|
|
285
|
+
expect(wordCount).toBeLessThanOrEqual(27);
|
|
286
|
+
});
|
|
287
|
+
});
|