qr-secure-send 1.7.2 → 1.7.3

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.
Files changed (3) hide show
  1. package/index.html +2 -2
  2. package/package.json +3 -2
  3. package/test.js +447 -0
package/index.html CHANGED
@@ -159,7 +159,7 @@
159
159
  <header>
160
160
  <h1>QR Secure Send</h1>
161
161
  <p>Encrypt and transfer secrets via QR code</p>
162
- <p style="font-size:0.7rem;color:#484f58;margin-top:0.2rem">v1.7.2</p>
162
+ <p style="font-size:0.7rem;color:#484f58;margin-top:0.2rem">v1.7.3</p>
163
163
  </header>
164
164
 
165
165
  <div class="tabs">
@@ -1019,7 +1019,7 @@
1019
1019
  // PAYLOAD HELPERS
1020
1020
  // =========================================================================
1021
1021
 
1022
- const APP_VERSION = "1.7.2";
1022
+ const APP_VERSION = "1.7.3";
1023
1023
  const GH_PAGES_URL = "https://degenddy.github.io/qr-secure-send/?v=" + APP_VERSION;
1024
1024
  const METAMASK_DEEP_URL = "https://link.metamask.io/dapp/degenddy.github.io/qr-secure-send/?v=" + APP_VERSION;
1025
1025
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qr-secure-send",
3
- "version": "1.7.2",
3
+ "version": "1.7.3",
4
4
  "description": "Encrypt and transfer secrets via QR code",
5
5
  "keywords": ["qr", "qrcode", "encryption", "password", "secure", "transfer"],
6
6
  "author": "",
@@ -13,6 +13,7 @@
13
13
  "qr-secure-send": "cli.js"
14
14
  },
15
15
  "scripts": {
16
- "start": "node cli.js"
16
+ "start": "node cli.js",
17
+ "test": "node --test test.js"
17
18
  }
18
19
  }
package/test.js ADDED
@@ -0,0 +1,447 @@
1
+ const { describe, it } = require('node:test');
2
+ const assert = require('node:assert');
3
+ const { webcrypto } = require('crypto');
4
+ const vm = require('vm');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Setup: extract JS from index.html and evaluate in a sandboxed VM context
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');
13
+ const scriptMatch = html.match(/<script>([\s\S]*?)<\/script>/);
14
+ let jsCode = scriptMatch[1];
15
+
16
+ // Expose QRGen.encode for testing
17
+ jsCode = jsCode.replace('return { toCanvas };', 'return { toCanvas, encode };');
18
+
19
+ // Make const/let top-level declarations accessible from the sandbox
20
+ jsCode = jsCode.replace('const QRGen =', 'var QRGen =');
21
+ jsCode = jsCode.replace('const QRScan =', 'var QRScan =');
22
+ jsCode = jsCode.replace(/^ const (BRUTE_FORCE_NOTE|GH_PAGES_URL|METAMASK_DEEP_URL|APP_VERSION|WALLET_SIGN_MSG) =/gm,
23
+ ' var $1 =');
24
+
25
+ // Truncate at UI LOGIC to avoid DOM-dependent code
26
+ jsCode = jsCode.split('// UI LOGIC')[0];
27
+
28
+ const sandbox = {
29
+ crypto: webcrypto,
30
+ TextEncoder,
31
+ TextDecoder,
32
+ btoa,
33
+ atob,
34
+ URL,
35
+ console,
36
+ performance,
37
+ window: { ethereum: undefined },
38
+ document: {
39
+ createElement: () => ({
40
+ getContext: () => ({ fillStyle: '', fillRect: () => {} }),
41
+ width: 0, height: 0, style: {},
42
+ }),
43
+ },
44
+ navigator: {},
45
+ location: { hash: '' },
46
+ requestAnimationFrame: () => {},
47
+ cancelAnimationFrame: () => {},
48
+ };
49
+
50
+ vm.createContext(sandbox);
51
+ vm.runInContext(jsCode, sandbox);
52
+
53
+ const {
54
+ QRGen, encryptSecret, decryptSecret, deriveKey,
55
+ evaluatePassphraseStrength, extractPayload, buildKeyInput,
56
+ WALLET_SIGN_MSG,
57
+ } = sandbox;
58
+
59
+ // =========================================================================
60
+ // extractPayload
61
+ // =========================================================================
62
+
63
+ describe('extractPayload', () => {
64
+ it('parses raw QRSEC: prefix', () => {
65
+ const r = extractPayload('QRSEC:abc123');
66
+ assert.strictEqual(r.data, 'abc123'); assert.strictEqual(r.wallet, false);
67
+ });
68
+
69
+ it('parses raw QRSECW: prefix', () => {
70
+ const r = extractPayload('QRSECW:abc123');
71
+ assert.strictEqual(r.data, 'abc123'); assert.strictEqual(r.wallet, true);
72
+ });
73
+
74
+ it('parses URL with QRSEC: in hash', () => {
75
+ const r = extractPayload('https://example.com/page#QRSEC:xyz');
76
+ assert.strictEqual(r.data, 'xyz'); assert.strictEqual(r.wallet, false);
77
+ });
78
+
79
+ it('parses URL with QRSECW: in hash', () => {
80
+ const r = extractPayload('https://example.com/page#QRSECW:xyz');
81
+ assert.strictEqual(r.data, 'xyz'); assert.strictEqual(r.wallet, true);
82
+ });
83
+
84
+ it('returns null for unrelated text', () => {
85
+ assert.strictEqual(extractPayload('hello world'), null);
86
+ });
87
+
88
+ it('returns null for URL without valid hash', () => {
89
+ assert.strictEqual(extractPayload('https://example.com/page#other'), null);
90
+ });
91
+
92
+ it('returns null for empty string', () => {
93
+ assert.strictEqual(extractPayload(''), null);
94
+ });
95
+
96
+ it('handles URL with query params and hash', () => {
97
+ const r = extractPayload('https://example.com?v=1#QRSEC:data');
98
+ assert.strictEqual(r.data, 'data'); assert.strictEqual(r.wallet, false);
99
+ });
100
+ });
101
+
102
+ // =========================================================================
103
+ // buildKeyInput
104
+ // =========================================================================
105
+
106
+ describe('buildKeyInput', () => {
107
+ it('returns passphrase alone when no wallet sig', () => {
108
+ assert.strictEqual(buildKeyInput('mypass', null), 'mypass');
109
+ assert.strictEqual(buildKeyInput('mypass', undefined), 'mypass');
110
+ });
111
+
112
+ it('returns passphrase alone when wallet sig is empty string', () => {
113
+ assert.strictEqual(buildKeyInput('mypass', ''), 'mypass');
114
+ });
115
+
116
+ it('concatenates passphrase and wallet sig with colon', () => {
117
+ assert.strictEqual(buildKeyInput('mypass', '0xabc'), 'mypass:0xabc');
118
+ });
119
+
120
+ it('works with empty passphrase and wallet sig', () => {
121
+ assert.strictEqual(buildKeyInput('', '0xabc'), ':0xabc');
122
+ });
123
+
124
+ it('works with empty passphrase and no wallet sig', () => {
125
+ assert.strictEqual(buildKeyInput('', null), '');
126
+ });
127
+ });
128
+
129
+ // =========================================================================
130
+ // evaluatePassphraseStrength
131
+ // =========================================================================
132
+
133
+ describe('evaluatePassphraseStrength', () => {
134
+ it('returns none for empty/falsy', () => {
135
+ assert.strictEqual(evaluatePassphraseStrength('').level, 'none');
136
+ assert.strictEqual(evaluatePassphraseStrength(null).level, 'none');
137
+ assert.strictEqual(evaluatePassphraseStrength(undefined).level, 'none');
138
+ });
139
+
140
+ it('returns weak for short lowercase', () => {
141
+ const r = evaluatePassphraseStrength('abc');
142
+ assert.strictEqual(r.level, 'weak');
143
+ assert.ok(r.bits < 35);
144
+ });
145
+
146
+ it('returns weak for repeated character', () => {
147
+ const r = evaluatePassphraseStrength('aaaaaaaaaaaa');
148
+ assert.strictEqual(r.level, 'weak');
149
+ assert.ok(r.bits <= 10, `Expected very low bits for repeated char, got ${r.bits}`);
150
+ });
151
+
152
+ it('returns weak for short numeric (< 6 chars)', () => {
153
+ const r = evaluatePassphraseStrength('12345');
154
+ assert.strictEqual(r.level, 'weak');
155
+ assert.ok(r.bits <= 20);
156
+ });
157
+
158
+ it('returns moderate for medium mixed-case + digit', () => {
159
+ const r = evaluatePassphraseStrength('Hello1ab');
160
+ assert.strictEqual(r.level, 'moderate');
161
+ });
162
+
163
+ it('returns strong for long mixed with symbols', () => {
164
+ const r = evaluatePassphraseStrength('C0mpl3x!P@ssw0rd#2024');
165
+ assert.strictEqual(r.level, 'strong');
166
+ assert.ok(r.bits >= 50);
167
+ });
168
+
169
+ it('weak message mentions rate-limiting', () => {
170
+ const r = evaluatePassphraseStrength('abc');
171
+ assert.ok(r.message.includes('rate-limiting'));
172
+ });
173
+
174
+ it('all-lowercase gets 0.7 factor applied', () => {
175
+ const r = evaluatePassphraseStrength('abcdefghij');
176
+ assert.ok(r.bits < 35);
177
+ });
178
+ });
179
+
180
+ // =========================================================================
181
+ // Encryption round-trip
182
+ // =========================================================================
183
+
184
+ describe('Encryption round-trip', () => {
185
+ it('encrypts and decrypts with passphrase', async () => {
186
+ const encrypted = await encryptSecret('hello world', 'mypassphrase');
187
+ const decrypted = await decryptSecret(encrypted, 'mypassphrase');
188
+ assert.strictEqual(decrypted, 'hello world');
189
+ });
190
+
191
+ it('encrypts and decrypts with empty passphrase', async () => {
192
+ const encrypted = await encryptSecret('secret data', '');
193
+ const decrypted = await decryptSecret(encrypted, '');
194
+ assert.strictEqual(decrypted, 'secret data');
195
+ });
196
+
197
+ it('encrypts and decrypts with simulated wallet signature only', async () => {
198
+ const walletSig = '0x' + 'ab'.repeat(65);
199
+ const keyInput = buildKeyInput('', walletSig);
200
+ const encrypted = await encryptSecret('wallet-protected', keyInput);
201
+ const decrypted = await decryptSecret(encrypted, keyInput);
202
+ assert.strictEqual(decrypted, 'wallet-protected');
203
+ });
204
+
205
+ it('encrypts and decrypts with passphrase + wallet signature', async () => {
206
+ const walletSig = '0x' + 'cd'.repeat(65);
207
+ const keyInput = buildKeyInput('mypass', walletSig);
208
+ const encrypted = await encryptSecret('dual-protected', keyInput);
209
+ const decrypted = await decryptSecret(encrypted, keyInput);
210
+ assert.strictEqual(decrypted, 'dual-protected');
211
+ });
212
+
213
+ it('handles unicode and emoji secrets', async () => {
214
+ const secret = 'Hello \u4e16\u754c \ud83d\udd10\ud83d\udee1\ufe0f';
215
+ const encrypted = await encryptSecret(secret, 'pass');
216
+ const decrypted = await decryptSecret(encrypted, 'pass');
217
+ assert.strictEqual(decrypted, secret);
218
+ });
219
+
220
+ it('handles empty secret', async () => {
221
+ const encrypted = await encryptSecret('', 'pass');
222
+ const decrypted = await decryptSecret(encrypted, 'pass');
223
+ assert.strictEqual(decrypted, '');
224
+ });
225
+
226
+ it('handles special characters in passphrase', async () => {
227
+ const pass = "p@$$w0rd!#%^&*()_+-=[]{}|;':\",./<>?";
228
+ const encrypted = await encryptSecret('test', pass);
229
+ const decrypted = await decryptSecret(encrypted, pass);
230
+ assert.strictEqual(decrypted, 'test');
231
+ });
232
+
233
+ it('handles very long secret (1500+ chars)', async () => {
234
+ const secret = 'A'.repeat(1500);
235
+ const encrypted = await encryptSecret(secret, 'pass');
236
+ const decrypted = await decryptSecret(encrypted, 'pass');
237
+ assert.strictEqual(decrypted, secret);
238
+ });
239
+
240
+ it('handles binary-like content', async () => {
241
+ const secret = Array.from({ length: 256 }, (_, i) => String.fromCharCode(i)).join('');
242
+ const encrypted = await encryptSecret(secret, 'pass');
243
+ const decrypted = await decryptSecret(encrypted, 'pass');
244
+ assert.strictEqual(decrypted, secret);
245
+ });
246
+ });
247
+
248
+ // =========================================================================
249
+ // Security / attack resistance
250
+ // =========================================================================
251
+
252
+ describe('Security / attack resistance', () => {
253
+ it('wrong passphrase fails decryption', async () => {
254
+ const encrypted = await encryptSecret('secret', 'correct-pass');
255
+ await assert.rejects(() => decryptSecret(encrypted, 'wrong-pass'));
256
+ });
257
+
258
+ it('wrong wallet signature fails decryption', async () => {
259
+ const keyEnc = buildKeyInput('pass', '0xaaa');
260
+ const keyDec = buildKeyInput('pass', '0xbbb');
261
+ const encrypted = await encryptSecret('secret', keyEnc);
262
+ await assert.rejects(() => decryptSecret(encrypted, keyDec));
263
+ });
264
+
265
+ it('tampered ciphertext fails GCM authentication', async () => {
266
+ const encrypted = await encryptSecret('secret', 'pass');
267
+ const raw = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0));
268
+ raw[30] ^= 0xff;
269
+ const tampered = btoa(String.fromCharCode(...raw));
270
+ await assert.rejects(() => decryptSecret(tampered, 'pass'));
271
+ });
272
+
273
+ it('tampered salt fails', async () => {
274
+ const encrypted = await encryptSecret('secret', 'pass');
275
+ const raw = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0));
276
+ raw[0] ^= 0xff;
277
+ const tampered = btoa(String.fromCharCode(...raw));
278
+ await assert.rejects(() => decryptSecret(tampered, 'pass'));
279
+ });
280
+
281
+ it('tampered IV fails', async () => {
282
+ const encrypted = await encryptSecret('secret', 'pass');
283
+ const raw = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0));
284
+ raw[16] ^= 0xff;
285
+ const tampered = btoa(String.fromCharCode(...raw));
286
+ await assert.rejects(() => decryptSecret(tampered, 'pass'));
287
+ });
288
+
289
+ it('single bit-flip in middle of ciphertext fails', async () => {
290
+ const encrypted = await encryptSecret('secret message here', 'pass');
291
+ const raw = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0));
292
+ const mid = Math.floor((28 + raw.length) / 2);
293
+ raw[mid] ^= 0x01;
294
+ const tampered = btoa(String.fromCharCode(...raw));
295
+ await assert.rejects(() => decryptSecret(tampered, 'pass'));
296
+ });
297
+
298
+ it('truncated ciphertext (salt+iv only) fails', async () => {
299
+ const encrypted = await encryptSecret('secret', 'pass');
300
+ const raw = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0));
301
+ const truncated = btoa(String.fromCharCode(...raw.slice(0, 28)));
302
+ await assert.rejects(() => decryptSecret(truncated, 'pass'));
303
+ });
304
+
305
+ it('empty ciphertext string throws', async () => {
306
+ await assert.rejects(() => decryptSecret('', 'pass'));
307
+ });
308
+
309
+ it('same secret + passphrase produces different ciphertext (random salt/IV)', async () => {
310
+ const e1 = await encryptSecret('same', 'same');
311
+ const e2 = await encryptSecret('same', 'same');
312
+ assert.notStrictEqual(e1, e2);
313
+ });
314
+
315
+ it('different secrets with same passphrase produce different ciphertext', async () => {
316
+ const e1 = await encryptSecret('secret1', 'pass');
317
+ const e2 = await encryptSecret('secret2', 'pass');
318
+ assert.notStrictEqual(e1, e2);
319
+ });
320
+
321
+ it('PBKDF2 key derivation takes meaningful time (>10ms)', async () => {
322
+ const salt = new Uint8Array(16);
323
+ const start = performance.now();
324
+ await deriveKey('test', salt);
325
+ const elapsed = performance.now() - start;
326
+ assert.ok(elapsed > 10, `Key derivation too fast (${elapsed.toFixed(1)}ms) — iterations may be too low`);
327
+ });
328
+
329
+ it('similar passphrases produce different keys / fail cross-decrypt', async () => {
330
+ const e1 = await encryptSecret('test', 'password1');
331
+ await assert.rejects(() => decryptSecret(e1, 'password2'));
332
+ });
333
+
334
+ it('no plaintext leakage in encrypted output', async () => {
335
+ const secret = 'UNIQUE_PLAINTEXT_MARKER_12345';
336
+ const encrypted = await encryptSecret(secret, 'pass');
337
+ assert.ok(!encrypted.includes('UNIQUE_PLAINTEXT_MARKER'));
338
+ const raw = atob(encrypted);
339
+ assert.ok(!raw.includes('UNIQUE_PLAINTEXT_MARKER'));
340
+ });
341
+
342
+ it('timing consistency across encryptions', async () => {
343
+ const times = [];
344
+ for (let i = 0; i < 5; i++) {
345
+ const start = performance.now();
346
+ await encryptSecret('timing test', 'pass');
347
+ times.push(performance.now() - start);
348
+ }
349
+ const min = Math.min(...times);
350
+ const max = Math.max(...times);
351
+ assert.ok(max < min * 5, `Timing variance too high: min=${min.toFixed(1)}ms, max=${max.toFixed(1)}ms`);
352
+ });
353
+ });
354
+
355
+ // =========================================================================
356
+ // QR encoding
357
+ // =========================================================================
358
+
359
+ describe('QR encoding', () => {
360
+ it('encodes short text as version 1 (21x21)', () => {
361
+ const { matrix, size } = QRGen.encode('Hi');
362
+ assert.strictEqual(size, 21);
363
+ assert.strictEqual(matrix.length, 21);
364
+ assert.strictEqual(matrix[0].length, 21);
365
+ });
366
+
367
+ it('uses higher version for longer text', () => {
368
+ const { size: s1 } = QRGen.encode('A');
369
+ const { size: s2 } = QRGen.encode('A'.repeat(100));
370
+ assert.ok(s2 > s1, `Expected larger QR for longer text: got ${s1} vs ${s2}`);
371
+ });
372
+
373
+ it('matrix contains only 1 (black) and 2 (white)', () => {
374
+ const { matrix, size } = QRGen.encode('test data');
375
+ for (let r = 0; r < size; r++)
376
+ for (let c = 0; c < size; c++)
377
+ assert.ok(matrix[r][c] === 1 || matrix[r][c] === 2,
378
+ `Invalid value ${matrix[r][c]} at [${r}][${c}]`);
379
+ });
380
+
381
+ it('throws for data exceeding QR capacity', () => {
382
+ assert.throws(() => QRGen.encode('A'.repeat(3000)), /too large/i);
383
+ });
384
+
385
+ it('handles version 2+ (alignment patterns)', () => {
386
+ const { size } = QRGen.encode('A'.repeat(25));
387
+ assert.ok(size >= 25);
388
+ });
389
+
390
+ it('produces deterministic output for same input', () => {
391
+ const r1 = QRGen.encode('deterministic');
392
+ const r2 = QRGen.encode('deterministic');
393
+ assert.strictEqual(r1.size, r2.size);
394
+ for (let r = 0; r < r1.size; r++)
395
+ for (let c = 0; c < r1.size; c++)
396
+ assert.strictEqual(r1.matrix[r][c], r2.matrix[r][c],
397
+ `Mismatch at [${r}][${c}]`);
398
+ });
399
+ });
400
+
401
+ // =========================================================================
402
+ // Payload format integration (full flow)
403
+ // =========================================================================
404
+
405
+ describe('Payload format integration', () => {
406
+ it('full flow: encrypt -> QRSEC: -> extractPayload -> decrypt', async () => {
407
+ const encrypted = await encryptSecret('my secret', 'mypass');
408
+ const payload = 'QRSEC:' + encrypted;
409
+ const extracted = extractPayload(payload);
410
+ assert.strictEqual(extracted.wallet, false);
411
+ const decrypted = await decryptSecret(extracted.data, 'mypass');
412
+ assert.strictEqual(decrypted, 'my secret');
413
+ });
414
+
415
+ it('full flow with QRSECW: prefix', async () => {
416
+ const keyInput = buildKeyInput('pass', '0xfakewalletsig');
417
+ const encrypted = await encryptSecret('wallet secret', keyInput);
418
+ const payload = 'QRSECW:' + encrypted;
419
+ const extracted = extractPayload(payload);
420
+ assert.strictEqual(extracted.wallet, true);
421
+ const decrypted = await decryptSecret(extracted.data, keyInput);
422
+ assert.strictEqual(decrypted, 'wallet secret');
423
+ });
424
+
425
+ it('URL wrapping with GitHub Pages URL', async () => {
426
+ const encrypted = await encryptSecret('via link', 'pass');
427
+ const url = 'https://degenddy.github.io/qr-secure-send/?v=1.7.2#QRSEC:' + encrypted;
428
+ const extracted = extractPayload(url);
429
+ assert.strictEqual(extracted.wallet, false);
430
+ const decrypted = await decryptSecret(extracted.data, 'pass');
431
+ assert.strictEqual(decrypted, 'via link');
432
+ });
433
+
434
+ it('encrypted output is valid base64', async () => {
435
+ const encrypted = await encryptSecret('test', 'pass');
436
+ assert.doesNotThrow(() => atob(encrypted));
437
+ assert.ok(/^[A-Za-z0-9+/]+=*$/.test(encrypted));
438
+ });
439
+
440
+ it('encrypted output has correct structure (salt + IV + ciphertext)', async () => {
441
+ const encrypted = await encryptSecret('test', 'pass');
442
+ const raw = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0));
443
+ // salt(16) + iv(12) + plaintext(4) + GCM-tag(16) = 48
444
+ assert.ok(raw.length >= 44, `Output too short: ${raw.length} bytes`);
445
+ assert.strictEqual(raw.length, 48);
446
+ });
447
+ });