qr-secure-send 1.7.2 → 1.8.0

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 +64 -45
  2. package/package.json +3 -2
  3. package/test.js +464 -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.8.0</p>
163
163
  </header>
164
164
 
165
165
  <div class="tabs">
@@ -190,8 +190,9 @@
190
190
  </label>
191
191
  <div id="send-wallet-fields" class="wallet-fields">
192
192
  <p class="info" style="text-align:left;margin-bottom:0.5rem;line-height:1.5">
193
- MetaMask will sign a fixed message using your private key.
193
+ Each QR code gets a unique random seed. MetaMask signs it with your private key.
194
194
  The resulting signature becomes the encryption key &mdash; only the same wallet can reproduce it.
195
+ If a signature leaks, only that specific QR is exposed, not future ones.
195
196
  Your private key never leaves MetaMask.
196
197
  You can also set a passphrase above for extra security (both will be needed to decrypt).
197
198
  </p>
@@ -227,7 +228,7 @@
227
228
  <p style="font-size:0.82rem;color:#d29922;margin-bottom:0.5rem;">
228
229
  This secret was encrypted with a wallet. Connect the same wallet to decrypt.
229
230
  </p>
230
- <button class="wallet-btn" id="recv-wallet-connect-btn">Connect & Sign with MetaMask</button>
231
+ <button class="wallet-btn" id="recv-wallet-connect-btn">Connect MetaMask</button>
231
232
  <a class="wallet-btn" id="recv-wallet-deeplink" style="display:none;text-decoration:none;text-align:center" target="_blank">Open in MetaMask App</a>
232
233
  <div id="recv-wallet-status" class="wallet-address" style="display:none"></div>
233
234
  </div>
@@ -1019,18 +1020,24 @@
1019
1020
  // PAYLOAD HELPERS
1020
1021
  // =========================================================================
1021
1022
 
1022
- const APP_VERSION = "1.7.2";
1023
+ const APP_VERSION = "1.8.0";
1023
1024
  const GH_PAGES_URL = "https://degenddy.github.io/qr-secure-send/?v=" + APP_VERSION;
1024
1025
  const METAMASK_DEEP_URL = "https://link.metamask.io/dapp/degenddy.github.io/qr-secure-send/?v=" + APP_VERSION;
1025
1026
 
1026
- const WALLET_SIGN_MSG = "QR Secure Send: generate encryption key";
1027
+ const WALLET_SIGN_PREFIX = "QR Secure Send: ";
1027
1028
 
1028
- // Ask MetaMask to sign a deterministic message. Returns the signature (hex string).
1029
- // Same wallet + same message = same signature every time (RFC 6979).
1030
- async function getWalletSignature(account) {
1029
+ function generateNonce() {
1030
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
1031
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
1032
+ }
1033
+
1034
+ // Ask MetaMask to sign a message with a unique nonce. Returns the signature (hex string).
1035
+ // Same wallet + same nonce = same signature every time (RFC 6979).
1036
+ async function getWalletSignature(account, nonce) {
1037
+ const msg = WALLET_SIGN_PREFIX + nonce;
1031
1038
  const sig = await window.ethereum.request({
1032
1039
  method: 'personal_sign',
1033
- params: [WALLET_SIGN_MSG, account]
1040
+ params: [msg, account]
1034
1041
  });
1035
1042
  return sig;
1036
1043
  }
@@ -1039,13 +1046,20 @@
1039
1046
  return walletSignature ? passphrase + ':' + walletSignature : passphrase;
1040
1047
  }
1041
1048
 
1042
- // Extract encrypted data from raw or URL form. Returns { data, wallet } or null.
1049
+ // Extract encrypted data from raw or URL form.
1050
+ // Returns { data, wallet, nonce } or null.
1051
+ // Wallet format: QRSECW:<nonce>:<base64>
1043
1052
  function extractPayload(text) {
1044
1053
  const sources = [text];
1045
1054
  try { const h = new URL(text).hash; if (h) sources.push(h.slice(1)); } catch (_) {}
1046
1055
  for (const s of sources) {
1047
- if (s.startsWith("QRSECW:")) return { data: s.slice(7), wallet: true };
1048
- if (s.startsWith("QRSEC:")) return { data: s.slice(6), wallet: false };
1056
+ if (s.startsWith("QRSECW:")) {
1057
+ const rest = s.slice(7);
1058
+ const colonIdx = rest.indexOf(':');
1059
+ if (colonIdx === -1) return null;
1060
+ return { data: rest.slice(colonIdx + 1), wallet: true, nonce: rest.slice(0, colonIdx) };
1061
+ }
1062
+ if (s.startsWith("QRSEC:")) return { data: s.slice(6), wallet: false, nonce: null };
1049
1063
  }
1050
1064
  return null;
1051
1065
  }
@@ -1091,8 +1105,8 @@
1091
1105
  document.getElementById("send-wallet-fields").classList.toggle("active", e.target.checked);
1092
1106
  });
1093
1107
 
1094
- // Send wallet connect
1095
- let sendWalletSignature = null;
1108
+ // Send wallet connect (just connects — signing happens at generate time with a fresh nonce)
1109
+ let sendWalletAccount = null;
1096
1110
 
1097
1111
  document.getElementById("send-wallet-connect-btn").addEventListener("click", async () => {
1098
1112
  const errEl = document.getElementById("send-error");
@@ -1104,14 +1118,13 @@
1104
1118
  try {
1105
1119
  const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
1106
1120
  if (!accounts.length) { errEl.textContent = "No accounts returned."; return; }
1107
- const account = accounts[0];
1108
- sendWalletSignature = await getWalletSignature(account);
1109
- statusEl.textContent = "Signed with: " + account.toLowerCase();
1121
+ sendWalletAccount = accounts[0];
1122
+ statusEl.textContent = "Connected: " + sendWalletAccount.toLowerCase();
1110
1123
  statusEl.style.display = "block";
1111
1124
  document.getElementById("send-wallet-connect-btn").textContent = "Wallet Connected";
1112
1125
  document.getElementById("send-wallet-connect-btn").disabled = true;
1113
1126
  } catch (e) {
1114
- errEl.textContent = "Wallet connection/signing failed: " + (e.message || "User rejected.");
1127
+ errEl.textContent = "Wallet connection failed: " + (e.message || "User rejected.");
1115
1128
  }
1116
1129
  });
1117
1130
 
@@ -1140,15 +1153,21 @@
1140
1153
  }
1141
1154
  }
1142
1155
 
1143
- if (useWallet && !sendWalletSignature) {
1144
- errEl.textContent = "Connect and sign with MetaMask first.";
1156
+ if (useWallet && !sendWalletAccount) {
1157
+ errEl.textContent = "Connect MetaMask first.";
1145
1158
  return;
1146
1159
  }
1147
1160
 
1148
1161
  try {
1149
- const keyInput = buildKeyInput(passphrase, useWallet ? sendWalletSignature : null);
1162
+ let walletSig = null;
1163
+ let nonce = null;
1164
+ if (useWallet) {
1165
+ nonce = generateNonce();
1166
+ walletSig = await getWalletSignature(sendWalletAccount, nonce);
1167
+ }
1168
+ const keyInput = buildKeyInput(passphrase, walletSig);
1150
1169
  const encrypted = await encryptSecret(secret, keyInput);
1151
- const prefix = useWallet ? "QRSECW:" : "QRSEC:";
1170
+ const prefix = useWallet ? "QRSECW:" + nonce + ":" : "QRSEC:";
1152
1171
  const payload = GH_PAGES_URL + "#" + prefix + encrypted;
1153
1172
 
1154
1173
  if (payload.length > 2953) {
@@ -1172,8 +1191,8 @@
1172
1191
  "loaded from the internet when you start the camera.";
1173
1192
  }
1174
1193
 
1175
- // Wallet connect & sign (receive side)
1176
- let recvWalletSignature = null;
1194
+ // Wallet connect (receive side) — just connects; signing happens at decrypt time with the nonce
1195
+ let recvWalletAccount = null;
1177
1196
 
1178
1197
  document.getElementById("recv-wallet-connect-btn").addEventListener("click", async () => {
1179
1198
  const statusEl = document.getElementById("recv-wallet-status");
@@ -1188,32 +1207,28 @@
1188
1207
  try {
1189
1208
  const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
1190
1209
  if (!accounts.length) { errEl.textContent = "No accounts returned."; return; }
1191
- const account = accounts[0];
1192
- recvWalletSignature = await getWalletSignature(account);
1193
- statusEl.textContent = "Signed with: " + account.toLowerCase();
1210
+ recvWalletAccount = accounts[0];
1211
+ statusEl.textContent = "Connected: " + recvWalletAccount.toLowerCase();
1194
1212
  statusEl.style.display = "block";
1195
- document.getElementById("recv-wallet-connect-btn").textContent = "Wallet Signed";
1213
+ document.getElementById("recv-wallet-connect-btn").textContent = "Wallet Connected";
1196
1214
  document.getElementById("recv-wallet-connect-btn").disabled = true;
1197
1215
  } catch (e) {
1198
- errEl.textContent = "Wallet connection/signing failed: " + (e.message || "User rejected.");
1216
+ errEl.textContent = "Wallet connection failed: " + (e.message || "User rejected.");
1199
1217
  }
1200
1218
  });
1201
1219
 
1202
- // Re-sign if user switches account in MetaMask
1203
1220
  if (typeof window.ethereum !== 'undefined') {
1204
- window.ethereum.on('accountsChanged', async (accounts) => {
1221
+ window.ethereum.on('accountsChanged', (accounts) => {
1205
1222
  if (accounts.length) {
1206
- try {
1207
- recvWalletSignature = await getWalletSignature(accounts[0]);
1208
- const statusEl = document.getElementById("recv-wallet-status");
1209
- statusEl.textContent = "Signed with: " + accounts[0].toLowerCase();
1210
- statusEl.style.display = "block";
1211
- } catch (_) { recvWalletSignature = null; }
1223
+ recvWalletAccount = accounts[0];
1224
+ const statusEl = document.getElementById("recv-wallet-status");
1225
+ statusEl.textContent = "Connected: " + recvWalletAccount.toLowerCase();
1226
+ statusEl.style.display = "block";
1212
1227
  } else {
1213
- recvWalletSignature = null;
1228
+ recvWalletAccount = null;
1214
1229
  document.getElementById("recv-wallet-status").style.display = "none";
1215
1230
  const btn = document.getElementById("recv-wallet-connect-btn");
1216
- btn.textContent = "Connect & Sign with MetaMask"; btn.disabled = false;
1231
+ btn.textContent = "Connect MetaMask"; btn.disabled = false;
1217
1232
  }
1218
1233
  });
1219
1234
  }
@@ -1243,13 +1258,15 @@
1243
1258
  }
1244
1259
  if (payload.wallet) {
1245
1260
  document.getElementById("recv-wallet-section").style.display = "block";
1246
- if (!recvWalletSignature) {
1247
- errEl.textContent = "This secret requires wallet signing. Connect & sign with MetaMask first, then scan again.";
1261
+ if (!recvWalletAccount) {
1262
+ errEl.textContent = "This secret requires wallet authentication. Connect MetaMask first, then scan again.";
1248
1263
  return false;
1249
1264
  }
1250
1265
  }
1251
1266
  try {
1252
- const keyInput = buildKeyInput(passphrase, payload.wallet ? recvWalletSignature : null);
1267
+ let walletSig = null;
1268
+ if (payload.wallet) walletSig = await getWalletSignature(recvWalletAccount, payload.nonce);
1269
+ const keyInput = buildKeyInput(passphrase, walletSig);
1253
1270
  const secret = await decryptSecret(payload.data, keyInput);
1254
1271
  document.getElementById("recv-value").textContent = secret;
1255
1272
  resultBox.style.display = "block";
@@ -1296,14 +1313,16 @@
1296
1313
  const payload = extractPayload(location.hash.slice(1));
1297
1314
  if (!payload) { errEl.textContent = "No encrypted data found in URL."; return; }
1298
1315
 
1299
- if (payload.wallet && !recvWalletSignature) {
1300
- errEl.textContent = "This secret requires wallet signing. Connect & sign with MetaMask first.";
1316
+ if (payload.wallet && !recvWalletAccount) {
1317
+ errEl.textContent = "This secret requires wallet authentication. Connect MetaMask first.";
1301
1318
  document.getElementById("recv-wallet-section").style.display = "block";
1302
1319
  return;
1303
1320
  }
1304
1321
 
1305
1322
  try {
1306
- const keyInput = buildKeyInput(passphrase, payload.wallet ? recvWalletSignature : null);
1323
+ let walletSig = null;
1324
+ if (payload.wallet) walletSig = await getWalletSignature(recvWalletAccount, payload.nonce);
1325
+ const keyInput = buildKeyInput(passphrase, walletSig);
1307
1326
  const secret = await decryptSecret(payload.data, keyInput);
1308
1327
  document.getElementById("recv-value").textContent = secret;
1309
1328
  resultBox.style.display = "block";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qr-secure-send",
3
- "version": "1.7.2",
3
+ "version": "1.8.0",
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,464 @@
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_PREFIX) =/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_PREFIX, generateNonce,
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
+ assert.strictEqual(r.nonce, null);
68
+ });
69
+
70
+ it('parses raw QRSECW: with nonce', () => {
71
+ const r = extractPayload('QRSECW:mynonce123:abc123');
72
+ assert.strictEqual(r.data, 'abc123'); assert.strictEqual(r.wallet, true);
73
+ assert.strictEqual(r.nonce, 'mynonce123');
74
+ });
75
+
76
+ it('returns null for QRSECW: without nonce separator', () => {
77
+ assert.strictEqual(extractPayload('QRSECW:nodatahere'), null);
78
+ });
79
+
80
+ it('parses URL with QRSEC: in hash', () => {
81
+ const r = extractPayload('https://example.com/page#QRSEC:xyz');
82
+ assert.strictEqual(r.data, 'xyz'); assert.strictEqual(r.wallet, false);
83
+ });
84
+
85
+ it('parses URL with QRSECW: in hash', () => {
86
+ const r = extractPayload('https://example.com/page#QRSECW:nonce42:xyz');
87
+ assert.strictEqual(r.data, 'xyz'); assert.strictEqual(r.wallet, true);
88
+ assert.strictEqual(r.nonce, 'nonce42');
89
+ });
90
+
91
+ it('returns null for unrelated text', () => {
92
+ assert.strictEqual(extractPayload('hello world'), null);
93
+ });
94
+
95
+ it('returns null for URL without valid hash', () => {
96
+ assert.strictEqual(extractPayload('https://example.com/page#other'), null);
97
+ });
98
+
99
+ it('returns null for empty string', () => {
100
+ assert.strictEqual(extractPayload(''), null);
101
+ });
102
+
103
+ it('handles URL with query params and hash', () => {
104
+ const r = extractPayload('https://example.com?v=1#QRSEC:data');
105
+ assert.strictEqual(r.data, 'data'); assert.strictEqual(r.wallet, false);
106
+ });
107
+
108
+ it('generateNonce produces unique 32-char hex strings', () => {
109
+ const n1 = generateNonce();
110
+ const n2 = generateNonce();
111
+ assert.strictEqual(n1.length, 32);
112
+ assert.ok(/^[0-9a-f]{32}$/.test(n1));
113
+ assert.notStrictEqual(n1, n2);
114
+ });
115
+ });
116
+
117
+ // =========================================================================
118
+ // buildKeyInput
119
+ // =========================================================================
120
+
121
+ describe('buildKeyInput', () => {
122
+ it('returns passphrase alone when no wallet sig', () => {
123
+ assert.strictEqual(buildKeyInput('mypass', null), 'mypass');
124
+ assert.strictEqual(buildKeyInput('mypass', undefined), 'mypass');
125
+ });
126
+
127
+ it('returns passphrase alone when wallet sig is empty string', () => {
128
+ assert.strictEqual(buildKeyInput('mypass', ''), 'mypass');
129
+ });
130
+
131
+ it('concatenates passphrase and wallet sig with colon', () => {
132
+ assert.strictEqual(buildKeyInput('mypass', '0xabc'), 'mypass:0xabc');
133
+ });
134
+
135
+ it('works with empty passphrase and wallet sig', () => {
136
+ assert.strictEqual(buildKeyInput('', '0xabc'), ':0xabc');
137
+ });
138
+
139
+ it('works with empty passphrase and no wallet sig', () => {
140
+ assert.strictEqual(buildKeyInput('', null), '');
141
+ });
142
+ });
143
+
144
+ // =========================================================================
145
+ // evaluatePassphraseStrength
146
+ // =========================================================================
147
+
148
+ describe('evaluatePassphraseStrength', () => {
149
+ it('returns none for empty/falsy', () => {
150
+ assert.strictEqual(evaluatePassphraseStrength('').level, 'none');
151
+ assert.strictEqual(evaluatePassphraseStrength(null).level, 'none');
152
+ assert.strictEqual(evaluatePassphraseStrength(undefined).level, 'none');
153
+ });
154
+
155
+ it('returns weak for short lowercase', () => {
156
+ const r = evaluatePassphraseStrength('abc');
157
+ assert.strictEqual(r.level, 'weak');
158
+ assert.ok(r.bits < 35);
159
+ });
160
+
161
+ it('returns weak for repeated character', () => {
162
+ const r = evaluatePassphraseStrength('aaaaaaaaaaaa');
163
+ assert.strictEqual(r.level, 'weak');
164
+ assert.ok(r.bits <= 10, `Expected very low bits for repeated char, got ${r.bits}`);
165
+ });
166
+
167
+ it('returns weak for short numeric (< 6 chars)', () => {
168
+ const r = evaluatePassphraseStrength('12345');
169
+ assert.strictEqual(r.level, 'weak');
170
+ assert.ok(r.bits <= 20);
171
+ });
172
+
173
+ it('returns moderate for medium mixed-case + digit', () => {
174
+ const r = evaluatePassphraseStrength('Hello1ab');
175
+ assert.strictEqual(r.level, 'moderate');
176
+ });
177
+
178
+ it('returns strong for long mixed with symbols', () => {
179
+ const r = evaluatePassphraseStrength('C0mpl3x!P@ssw0rd#2024');
180
+ assert.strictEqual(r.level, 'strong');
181
+ assert.ok(r.bits >= 50);
182
+ });
183
+
184
+ it('weak message mentions rate-limiting', () => {
185
+ const r = evaluatePassphraseStrength('abc');
186
+ assert.ok(r.message.includes('rate-limiting'));
187
+ });
188
+
189
+ it('all-lowercase gets 0.7 factor applied', () => {
190
+ const r = evaluatePassphraseStrength('abcdefghij');
191
+ assert.ok(r.bits < 35);
192
+ });
193
+ });
194
+
195
+ // =========================================================================
196
+ // Encryption round-trip
197
+ // =========================================================================
198
+
199
+ describe('Encryption round-trip', () => {
200
+ it('encrypts and decrypts with passphrase', async () => {
201
+ const encrypted = await encryptSecret('hello world', 'mypassphrase');
202
+ const decrypted = await decryptSecret(encrypted, 'mypassphrase');
203
+ assert.strictEqual(decrypted, 'hello world');
204
+ });
205
+
206
+ it('encrypts and decrypts with empty passphrase', async () => {
207
+ const encrypted = await encryptSecret('secret data', '');
208
+ const decrypted = await decryptSecret(encrypted, '');
209
+ assert.strictEqual(decrypted, 'secret data');
210
+ });
211
+
212
+ it('encrypts and decrypts with simulated wallet signature only', async () => {
213
+ const walletSig = '0x' + 'ab'.repeat(65);
214
+ const keyInput = buildKeyInput('', walletSig);
215
+ const encrypted = await encryptSecret('wallet-protected', keyInput);
216
+ const decrypted = await decryptSecret(encrypted, keyInput);
217
+ assert.strictEqual(decrypted, 'wallet-protected');
218
+ });
219
+
220
+ it('encrypts and decrypts with passphrase + wallet signature', async () => {
221
+ const walletSig = '0x' + 'cd'.repeat(65);
222
+ const keyInput = buildKeyInput('mypass', walletSig);
223
+ const encrypted = await encryptSecret('dual-protected', keyInput);
224
+ const decrypted = await decryptSecret(encrypted, keyInput);
225
+ assert.strictEqual(decrypted, 'dual-protected');
226
+ });
227
+
228
+ it('handles unicode and emoji secrets', async () => {
229
+ const secret = 'Hello \u4e16\u754c \ud83d\udd10\ud83d\udee1\ufe0f';
230
+ const encrypted = await encryptSecret(secret, 'pass');
231
+ const decrypted = await decryptSecret(encrypted, 'pass');
232
+ assert.strictEqual(decrypted, secret);
233
+ });
234
+
235
+ it('handles empty secret', async () => {
236
+ const encrypted = await encryptSecret('', 'pass');
237
+ const decrypted = await decryptSecret(encrypted, 'pass');
238
+ assert.strictEqual(decrypted, '');
239
+ });
240
+
241
+ it('handles special characters in passphrase', async () => {
242
+ const pass = "p@$$w0rd!#%^&*()_+-=[]{}|;':\",./<>?";
243
+ const encrypted = await encryptSecret('test', pass);
244
+ const decrypted = await decryptSecret(encrypted, pass);
245
+ assert.strictEqual(decrypted, 'test');
246
+ });
247
+
248
+ it('handles very long secret (1500+ chars)', async () => {
249
+ const secret = 'A'.repeat(1500);
250
+ const encrypted = await encryptSecret(secret, 'pass');
251
+ const decrypted = await decryptSecret(encrypted, 'pass');
252
+ assert.strictEqual(decrypted, secret);
253
+ });
254
+
255
+ it('handles binary-like content', async () => {
256
+ const secret = Array.from({ length: 256 }, (_, i) => String.fromCharCode(i)).join('');
257
+ const encrypted = await encryptSecret(secret, 'pass');
258
+ const decrypted = await decryptSecret(encrypted, 'pass');
259
+ assert.strictEqual(decrypted, secret);
260
+ });
261
+ });
262
+
263
+ // =========================================================================
264
+ // Security / attack resistance
265
+ // =========================================================================
266
+
267
+ describe('Security / attack resistance', () => {
268
+ it('wrong passphrase fails decryption', async () => {
269
+ const encrypted = await encryptSecret('secret', 'correct-pass');
270
+ await assert.rejects(() => decryptSecret(encrypted, 'wrong-pass'));
271
+ });
272
+
273
+ it('wrong wallet signature fails decryption', async () => {
274
+ const keyEnc = buildKeyInput('pass', '0xaaa');
275
+ const keyDec = buildKeyInput('pass', '0xbbb');
276
+ const encrypted = await encryptSecret('secret', keyEnc);
277
+ await assert.rejects(() => decryptSecret(encrypted, keyDec));
278
+ });
279
+
280
+ it('tampered ciphertext fails GCM authentication', async () => {
281
+ const encrypted = await encryptSecret('secret', 'pass');
282
+ const raw = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0));
283
+ raw[30] ^= 0xff;
284
+ const tampered = btoa(String.fromCharCode(...raw));
285
+ await assert.rejects(() => decryptSecret(tampered, 'pass'));
286
+ });
287
+
288
+ it('tampered salt fails', async () => {
289
+ const encrypted = await encryptSecret('secret', 'pass');
290
+ const raw = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0));
291
+ raw[0] ^= 0xff;
292
+ const tampered = btoa(String.fromCharCode(...raw));
293
+ await assert.rejects(() => decryptSecret(tampered, 'pass'));
294
+ });
295
+
296
+ it('tampered IV fails', async () => {
297
+ const encrypted = await encryptSecret('secret', 'pass');
298
+ const raw = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0));
299
+ raw[16] ^= 0xff;
300
+ const tampered = btoa(String.fromCharCode(...raw));
301
+ await assert.rejects(() => decryptSecret(tampered, 'pass'));
302
+ });
303
+
304
+ it('single bit-flip in middle of ciphertext fails', async () => {
305
+ const encrypted = await encryptSecret('secret message here', 'pass');
306
+ const raw = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0));
307
+ const mid = Math.floor((28 + raw.length) / 2);
308
+ raw[mid] ^= 0x01;
309
+ const tampered = btoa(String.fromCharCode(...raw));
310
+ await assert.rejects(() => decryptSecret(tampered, 'pass'));
311
+ });
312
+
313
+ it('truncated ciphertext (salt+iv only) fails', async () => {
314
+ const encrypted = await encryptSecret('secret', 'pass');
315
+ const raw = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0));
316
+ const truncated = btoa(String.fromCharCode(...raw.slice(0, 28)));
317
+ await assert.rejects(() => decryptSecret(truncated, 'pass'));
318
+ });
319
+
320
+ it('empty ciphertext string throws', async () => {
321
+ await assert.rejects(() => decryptSecret('', 'pass'));
322
+ });
323
+
324
+ it('same secret + passphrase produces different ciphertext (random salt/IV)', async () => {
325
+ const e1 = await encryptSecret('same', 'same');
326
+ const e2 = await encryptSecret('same', 'same');
327
+ assert.notStrictEqual(e1, e2);
328
+ });
329
+
330
+ it('different secrets with same passphrase produce different ciphertext', async () => {
331
+ const e1 = await encryptSecret('secret1', 'pass');
332
+ const e2 = await encryptSecret('secret2', 'pass');
333
+ assert.notStrictEqual(e1, e2);
334
+ });
335
+
336
+ it('PBKDF2 key derivation takes meaningful time (>10ms)', async () => {
337
+ const salt = new Uint8Array(16);
338
+ const start = performance.now();
339
+ await deriveKey('test', salt);
340
+ const elapsed = performance.now() - start;
341
+ assert.ok(elapsed > 10, `Key derivation too fast (${elapsed.toFixed(1)}ms) — iterations may be too low`);
342
+ });
343
+
344
+ it('similar passphrases produce different keys / fail cross-decrypt', async () => {
345
+ const e1 = await encryptSecret('test', 'password1');
346
+ await assert.rejects(() => decryptSecret(e1, 'password2'));
347
+ });
348
+
349
+ it('no plaintext leakage in encrypted output', async () => {
350
+ const secret = 'UNIQUE_PLAINTEXT_MARKER_12345';
351
+ const encrypted = await encryptSecret(secret, 'pass');
352
+ assert.ok(!encrypted.includes('UNIQUE_PLAINTEXT_MARKER'));
353
+ const raw = atob(encrypted);
354
+ assert.ok(!raw.includes('UNIQUE_PLAINTEXT_MARKER'));
355
+ });
356
+
357
+ it('timing consistency across encryptions', async () => {
358
+ const times = [];
359
+ for (let i = 0; i < 5; i++) {
360
+ const start = performance.now();
361
+ await encryptSecret('timing test', 'pass');
362
+ times.push(performance.now() - start);
363
+ }
364
+ const min = Math.min(...times);
365
+ const max = Math.max(...times);
366
+ assert.ok(max < min * 5, `Timing variance too high: min=${min.toFixed(1)}ms, max=${max.toFixed(1)}ms`);
367
+ });
368
+ });
369
+
370
+ // =========================================================================
371
+ // QR encoding
372
+ // =========================================================================
373
+
374
+ describe('QR encoding', () => {
375
+ it('encodes short text as version 1 (21x21)', () => {
376
+ const { matrix, size } = QRGen.encode('Hi');
377
+ assert.strictEqual(size, 21);
378
+ assert.strictEqual(matrix.length, 21);
379
+ assert.strictEqual(matrix[0].length, 21);
380
+ });
381
+
382
+ it('uses higher version for longer text', () => {
383
+ const { size: s1 } = QRGen.encode('A');
384
+ const { size: s2 } = QRGen.encode('A'.repeat(100));
385
+ assert.ok(s2 > s1, `Expected larger QR for longer text: got ${s1} vs ${s2}`);
386
+ });
387
+
388
+ it('matrix contains only 1 (black) and 2 (white)', () => {
389
+ const { matrix, size } = QRGen.encode('test data');
390
+ for (let r = 0; r < size; r++)
391
+ for (let c = 0; c < size; c++)
392
+ assert.ok(matrix[r][c] === 1 || matrix[r][c] === 2,
393
+ `Invalid value ${matrix[r][c]} at [${r}][${c}]`);
394
+ });
395
+
396
+ it('throws for data exceeding QR capacity', () => {
397
+ assert.throws(() => QRGen.encode('A'.repeat(3000)), /too large/i);
398
+ });
399
+
400
+ it('handles version 2+ (alignment patterns)', () => {
401
+ const { size } = QRGen.encode('A'.repeat(25));
402
+ assert.ok(size >= 25);
403
+ });
404
+
405
+ it('produces deterministic output for same input', () => {
406
+ const r1 = QRGen.encode('deterministic');
407
+ const r2 = QRGen.encode('deterministic');
408
+ assert.strictEqual(r1.size, r2.size);
409
+ for (let r = 0; r < r1.size; r++)
410
+ for (let c = 0; c < r1.size; c++)
411
+ assert.strictEqual(r1.matrix[r][c], r2.matrix[r][c],
412
+ `Mismatch at [${r}][${c}]`);
413
+ });
414
+ });
415
+
416
+ // =========================================================================
417
+ // Payload format integration (full flow)
418
+ // =========================================================================
419
+
420
+ describe('Payload format integration', () => {
421
+ it('full flow: encrypt -> QRSEC: -> extractPayload -> decrypt', async () => {
422
+ const encrypted = await encryptSecret('my secret', 'mypass');
423
+ const payload = 'QRSEC:' + encrypted;
424
+ const extracted = extractPayload(payload);
425
+ assert.strictEqual(extracted.wallet, false);
426
+ const decrypted = await decryptSecret(extracted.data, 'mypass');
427
+ assert.strictEqual(decrypted, 'my secret');
428
+ });
429
+
430
+ it('full flow with QRSECW: prefix and nonce', async () => {
431
+ const nonce = generateNonce();
432
+ const keyInput = buildKeyInput('pass', '0xfakewalletsig');
433
+ const encrypted = await encryptSecret('wallet secret', keyInput);
434
+ const payload = 'QRSECW:' + nonce + ':' + encrypted;
435
+ const extracted = extractPayload(payload);
436
+ assert.strictEqual(extracted.wallet, true);
437
+ assert.strictEqual(extracted.nonce, nonce);
438
+ const decrypted = await decryptSecret(extracted.data, keyInput);
439
+ assert.strictEqual(decrypted, 'wallet secret');
440
+ });
441
+
442
+ it('URL wrapping with GitHub Pages URL', async () => {
443
+ const encrypted = await encryptSecret('via link', 'pass');
444
+ const url = 'https://degenddy.github.io/qr-secure-send/?v=1.7.2#QRSEC:' + encrypted;
445
+ const extracted = extractPayload(url);
446
+ assert.strictEqual(extracted.wallet, false);
447
+ const decrypted = await decryptSecret(extracted.data, 'pass');
448
+ assert.strictEqual(decrypted, 'via link');
449
+ });
450
+
451
+ it('encrypted output is valid base64', async () => {
452
+ const encrypted = await encryptSecret('test', 'pass');
453
+ assert.doesNotThrow(() => atob(encrypted));
454
+ assert.ok(/^[A-Za-z0-9+/]+=*$/.test(encrypted));
455
+ });
456
+
457
+ it('encrypted output has correct structure (salt + IV + ciphertext)', async () => {
458
+ const encrypted = await encryptSecret('test', 'pass');
459
+ const raw = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0));
460
+ // salt(16) + iv(12) + plaintext(4) + GCM-tag(16) = 48
461
+ assert.ok(raw.length >= 44, `Output too short: ${raw.length} bytes`);
462
+ assert.strictEqual(raw.length, 48);
463
+ });
464
+ });