@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,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
+ });