@learncard/sss-key-manager 0.1.14 → 0.1.16

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,122 @@
1
+ /**
2
+ * Tests for QR Login Crypto Helpers (X25519 ECDH + AES-256-GCM)
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+
7
+ import {
8
+ generateEphemeralKeypair,
9
+ encryptShareForTransfer,
10
+ decryptShareFromTransfer,
11
+ } from './qr-crypto';
12
+
13
+ // Node 20+ supports X25519 in crypto.subtle.
14
+ // jsdom doesn't provide its own Web Crypto — it falls through to Node's.
15
+ // If this environment doesn't support X25519, skip gracefully.
16
+ const supportsX25519 = async (): Promise<boolean> => {
17
+ try {
18
+ await crypto.subtle.generateKey({ name: 'X25519' }, false, ['deriveBits']);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ };
24
+
25
+ describe('QR Crypto (X25519 ECDH + AES-GCM)', async () => {
26
+ const supported = await supportsX25519();
27
+
28
+ it.skipIf(!supported)('generateEphemeralKeypair returns a public key string and private CryptoKey', async () => {
29
+ const keypair = await generateEphemeralKeypair();
30
+
31
+ expect(keypair.publicKey).toBeTruthy();
32
+ expect(typeof keypair.publicKey).toBe('string');
33
+
34
+ // X25519 public keys are 32 bytes → 44 chars in base64
35
+ expect(keypair.publicKey.length).toBeGreaterThanOrEqual(40);
36
+
37
+ expect(keypair.privateKey).toBeTruthy();
38
+ expect(keypair.privateKey.type).toBe('private');
39
+ });
40
+
41
+ it.skipIf(!supported)('generates unique keypairs on each call', async () => {
42
+ const kp1 = await generateEphemeralKeypair();
43
+ const kp2 = await generateEphemeralKeypair();
44
+
45
+ expect(kp1.publicKey).not.toBe(kp2.publicKey);
46
+ });
47
+
48
+ it.skipIf(!supported)('encrypt + decrypt round-trip produces the original share', async () => {
49
+ const deviceShare = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab';
50
+
51
+ // Device B generates keypair
52
+ const deviceB = await generateEphemeralKeypair();
53
+
54
+ // Device A encrypts the share for Device B
55
+ const encrypted = await encryptShareForTransfer(deviceShare, deviceB.publicKey);
56
+
57
+ expect(encrypted.ciphertext).toBeTruthy();
58
+ expect(encrypted.iv).toBeTruthy();
59
+ expect(encrypted.senderPublicKey).toBeTruthy();
60
+
61
+ // Device B decrypts
62
+ const decrypted = await decryptShareFromTransfer(encrypted, deviceB.privateKey);
63
+
64
+ expect(decrypted).toBe(deviceShare);
65
+ });
66
+
67
+ it.skipIf(!supported)('decryption with wrong private key fails', async () => {
68
+ const deviceShare = 'secret-share-data-12345';
69
+
70
+ const deviceB = await generateEphemeralKeypair();
71
+ const wrongKey = await generateEphemeralKeypair();
72
+
73
+ const encrypted = await encryptShareForTransfer(deviceShare, deviceB.publicKey);
74
+
75
+ // Try to decrypt with a different private key — should fail
76
+ await expect(
77
+ decryptShareFromTransfer(encrypted, wrongKey.privateKey)
78
+ ).rejects.toThrow();
79
+ });
80
+
81
+ it.skipIf(!supported)('encrypted payload is different each time (unique IV + sender key)', async () => {
82
+ const deviceShare = 'some-share';
83
+
84
+ const deviceB = await generateEphemeralKeypair();
85
+
86
+ const enc1 = await encryptShareForTransfer(deviceShare, deviceB.publicKey);
87
+ const enc2 = await encryptShareForTransfer(deviceShare, deviceB.publicKey);
88
+
89
+ // Different IVs
90
+ expect(enc1.iv).not.toBe(enc2.iv);
91
+
92
+ // Different sender keys (each call generates a new ephemeral pair)
93
+ expect(enc1.senderPublicKey).not.toBe(enc2.senderPublicKey);
94
+
95
+ // Different ciphertexts
96
+ expect(enc1.ciphertext).not.toBe(enc2.ciphertext);
97
+
98
+ // But both decrypt to the same share
99
+ const dec1 = await decryptShareFromTransfer(enc1, deviceB.privateKey);
100
+ const dec2 = await decryptShareFromTransfer(enc2, deviceB.privateKey);
101
+
102
+ expect(dec1).toBe(deviceShare);
103
+ expect(dec2).toBe(deviceShare);
104
+ });
105
+
106
+ it.skipIf(!supported)('handles various share lengths correctly', async () => {
107
+ const shares = [
108
+ 'short',
109
+ 'a'.repeat(66), // typical SSS hex share
110
+ 'a'.repeat(1000), // unusually long
111
+ '🔐🔑', // unicode
112
+ ];
113
+
114
+ for (const share of shares) {
115
+ const deviceB = await generateEphemeralKeypair();
116
+ const encrypted = await encryptShareForTransfer(share, deviceB.publicKey);
117
+ const decrypted = await decryptShareFromTransfer(encrypted, deviceB.privateKey);
118
+
119
+ expect(decrypted).toBe(share);
120
+ }
121
+ });
122
+ });
@@ -0,0 +1,206 @@
1
+ /**
2
+ * QR Login Crypto Helpers
3
+ *
4
+ * X25519 ECDH key exchange + AES-256-GCM encryption for cross-device
5
+ * share transfer. Uses the Web Crypto API exclusively — no external deps.
6
+ *
7
+ * Flow:
8
+ * Device B generates an ephemeral X25519 keypair, shares the public key.
9
+ * Device A derives a shared secret via ECDH, encrypts the device share
10
+ * with AES-256-GCM, and sends the ciphertext.
11
+ * Device B decrypts with the same derived shared secret.
12
+ */
13
+
14
+ import { bufferToBase64, base64ToBuffer } from './crypto';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface EphemeralKeypair {
21
+ /** Base64-encoded X25519 public key (sent to server / encoded in QR) */
22
+ publicKey: string;
23
+
24
+ /** Raw CryptoKey — kept in memory on Device B, never leaves the device */
25
+ privateKey: CryptoKey;
26
+ }
27
+
28
+ export interface EncryptedSharePayload {
29
+ /** Base64-encoded AES-256-GCM ciphertext */
30
+ ciphertext: string;
31
+
32
+ /** Base64-encoded 12-byte IV */
33
+ iv: string;
34
+
35
+ /** Base64-encoded ephemeral public key of the *sender* (Device A) */
36
+ senderPublicKey: string;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Key Generation
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Generate an ephemeral X25519 keypair for the new device (Device B).
45
+ * The public key is shared via QR / short code; the private key stays in memory.
46
+ */
47
+ export const generateEphemeralKeypair = async (): Promise<EphemeralKeypair> => {
48
+ if (typeof crypto === 'undefined' || !crypto.subtle) {
49
+ throw new Error(
50
+ 'Web Crypto API is not available. This feature requires a secure context (HTTPS). ' +
51
+ 'If you are running a Capacitor dev server over HTTP, use a production build or configure HTTPS.'
52
+ );
53
+ }
54
+
55
+ const keyPair = await crypto.subtle.generateKey(
56
+ { name: 'X25519' },
57
+ false,
58
+ ['deriveBits']
59
+ ) as CryptoKeyPair;
60
+
61
+ const rawPublicKey = await crypto.subtle.exportKey('raw', keyPair.publicKey);
62
+
63
+ return {
64
+ publicKey: bufferToBase64(rawPublicKey),
65
+ privateKey: keyPair.privateKey,
66
+ };
67
+ };
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Shared Secret Derivation
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /**
74
+ * Import a raw X25519 public key from base64.
75
+ */
76
+ const importPublicKey = async (base64Key: string): Promise<CryptoKey> => {
77
+ const keyBytes = base64ToBuffer(base64Key);
78
+
79
+ return crypto.subtle.importKey(
80
+ 'raw',
81
+ keyBytes,
82
+ { name: 'X25519' },
83
+ false,
84
+ []
85
+ );
86
+ };
87
+
88
+ /**
89
+ * Derive a 256-bit AES-GCM key from our private key + their public key
90
+ * using X25519 ECDH → HKDF-SHA256.
91
+ *
92
+ * The raw ECDH output is fed through HKDF with a context-specific info
93
+ * string to produce the final encryption key (NIST SP 800-56C compliant).
94
+ */
95
+ const deriveSharedAesKey = async (
96
+ ourPrivateKey: CryptoKey,
97
+ theirPublicKeyBase64: string
98
+ ): Promise<CryptoKey> => {
99
+ const theirPublicKey = await importPublicKey(theirPublicKeyBase64);
100
+
101
+ const sharedBits = await crypto.subtle.deriveBits(
102
+ { name: 'X25519', public: theirPublicKey },
103
+ ourPrivateKey,
104
+ 256
105
+ );
106
+
107
+ // Import raw ECDH output as HKDF key material
108
+ const hkdfKey = await crypto.subtle.importKey(
109
+ 'raw',
110
+ sharedBits,
111
+ 'HKDF',
112
+ false,
113
+ ['deriveKey']
114
+ );
115
+
116
+ // Derive the final AES-256-GCM key via HKDF-SHA256
117
+ const encoder = new TextEncoder();
118
+
119
+ return crypto.subtle.deriveKey(
120
+ {
121
+ name: 'HKDF',
122
+ hash: 'SHA-256',
123
+ salt: new Uint8Array(32), // fixed empty salt (ephemeral keys provide freshness)
124
+ info: encoder.encode('learncard-qr-login-v1'),
125
+ },
126
+ hkdfKey,
127
+ { name: 'AES-GCM', length: 256 },
128
+ false,
129
+ ['encrypt', 'decrypt']
130
+ );
131
+ };
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Encrypt / Decrypt
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Encrypt a device share for transfer to Device B.
139
+ *
140
+ * Called by Device A (the logged-in device):
141
+ * 1. Generates its own ephemeral X25519 keypair
142
+ * 2. Derives a shared secret from its private key + Device B's public key
143
+ * 3. Encrypts the share with AES-256-GCM
144
+ * 4. Returns the ciphertext + IV + Device A's ephemeral public key
145
+ *
146
+ * @param deviceShare - Plaintext device share to encrypt
147
+ * @param recipientPublicKey - Base64-encoded X25519 public key from Device B
148
+ */
149
+ export const encryptShareForTransfer = async (
150
+ deviceShare: string,
151
+ recipientPublicKey: string
152
+ ): Promise<EncryptedSharePayload> => {
153
+ // Generate sender's ephemeral keypair
154
+ const senderKeypair = await generateEphemeralKeypair();
155
+
156
+ // Derive shared AES key
157
+ const aesKey = await deriveSharedAesKey(senderKeypair.privateKey, recipientPublicKey);
158
+
159
+ // Encrypt
160
+ const iv = crypto.getRandomValues(new Uint8Array(12));
161
+ const encoder = new TextEncoder();
162
+
163
+ const ciphertextBuffer = await crypto.subtle.encrypt(
164
+ { name: 'AES-GCM', iv },
165
+ aesKey,
166
+ encoder.encode(deviceShare)
167
+ );
168
+
169
+ return {
170
+ ciphertext: bufferToBase64(ciphertextBuffer),
171
+ iv: bufferToBase64(iv.buffer),
172
+ senderPublicKey: senderKeypair.publicKey,
173
+ };
174
+ };
175
+
176
+ /**
177
+ * Decrypt a device share received from Device A.
178
+ *
179
+ * Called by Device B (the new device):
180
+ * 1. Derives the shared secret from its private key + Device A's public key
181
+ * 2. Decrypts the ciphertext with AES-256-GCM
182
+ *
183
+ * @param payload - The encrypted payload from Device A
184
+ * @param recipientPrivateKey - Device B's ephemeral private CryptoKey
185
+ */
186
+ export const decryptShareFromTransfer = async (
187
+ payload: EncryptedSharePayload,
188
+ recipientPrivateKey: CryptoKey
189
+ ): Promise<string> => {
190
+ // Derive shared AES key
191
+ const aesKey = await deriveSharedAesKey(recipientPrivateKey, payload.senderPublicKey);
192
+
193
+ // Decrypt
194
+ const ivBytes = base64ToBuffer(payload.iv);
195
+ const ciphertextBytes = base64ToBuffer(payload.ciphertext);
196
+
197
+ const plaintextBuffer = await crypto.subtle.decrypt(
198
+ { name: 'AES-GCM', iv: ivBytes },
199
+ aesKey,
200
+ ciphertextBytes
201
+ );
202
+
203
+ const decoder = new TextDecoder();
204
+
205
+ return decoder.decode(plaintextBuffer);
206
+ };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Tests for QR Login Push Notification helper
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6
+
7
+ import { notifyDevicesForQrSession } from './qr-login';
8
+
9
+ const SERVER_URL = 'https://api.example.com';
10
+
11
+ let mockFetch: ReturnType<typeof vi.fn>;
12
+
13
+ describe('notifyDevicesForQrSession', () => {
14
+ beforeEach(() => {
15
+ mockFetch = vi.fn();
16
+ vi.stubGlobal('fetch', mockFetch);
17
+ });
18
+
19
+ afterEach(() => {
20
+ vi.unstubAllGlobals();
21
+ });
22
+
23
+ it('sends a POST to /qr-login/notify with correct body', async () => {
24
+ mockFetch.mockResolvedValueOnce(
25
+ new Response(JSON.stringify({ sent: true, deviceCount: 2 }), { status: 200 })
26
+ );
27
+
28
+ const result = await notifyDevicesForQrSession(
29
+ { serverUrl: SERVER_URL },
30
+ 'session-123',
31
+ '12345678',
32
+ 'firebase-token-abc',
33
+ 'firebase'
34
+ );
35
+
36
+ expect(result).toEqual({ sent: true, deviceCount: 2 });
37
+
38
+ expect(mockFetch).toHaveBeenCalledOnce();
39
+
40
+ const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
41
+
42
+ expect(url).toBe(`${SERVER_URL}/qr-login/notify`);
43
+ expect(init.method).toBe('POST');
44
+
45
+ const body = JSON.parse(init.body as string);
46
+
47
+ expect(body.authToken).toBe('firebase-token-abc');
48
+ expect(body.providerType).toBe('firebase');
49
+ expect(body.sessionId).toBe('session-123');
50
+ expect(body.shortCode).toBe('12345678');
51
+ });
52
+
53
+ it('defaults providerType to firebase', async () => {
54
+ mockFetch.mockResolvedValueOnce(
55
+ new Response(JSON.stringify({ sent: true, deviceCount: 1 }), { status: 200 })
56
+ );
57
+
58
+ await notifyDevicesForQrSession(
59
+ { serverUrl: SERVER_URL },
60
+ 'session-456',
61
+ '87654321',
62
+ 'token-xyz'
63
+ );
64
+
65
+ const body = JSON.parse((mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string);
66
+
67
+ expect(body.providerType).toBe('firebase');
68
+ });
69
+
70
+ it('returns { sent: false, deviceCount: 0 } on HTTP error', async () => {
71
+ mockFetch.mockResolvedValueOnce(new Response('Server Error', { status: 500 }));
72
+
73
+ const result = await notifyDevicesForQrSession(
74
+ { serverUrl: SERVER_URL },
75
+ 'session-err',
76
+ '00000000',
77
+ 'token'
78
+ );
79
+
80
+ expect(result).toEqual({ sent: false, deviceCount: 0 });
81
+ });
82
+
83
+ it('returns { sent: false, deviceCount: 0 } on network failure', async () => {
84
+ mockFetch.mockRejectedValueOnce(new TypeError('Network error'));
85
+
86
+ const result = await notifyDevicesForQrSession(
87
+ { serverUrl: SERVER_URL },
88
+ 'session-net',
89
+ '11111111',
90
+ 'token'
91
+ );
92
+
93
+ expect(result).toEqual({ sent: false, deviceCount: 0 });
94
+ });
95
+ });