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.
- package/index.html +64 -45
- package/package.json +3 -2
- 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.
|
|
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
|
-
|
|
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 — 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
|
|
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.
|
|
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
|
|
1027
|
+
const WALLET_SIGN_PREFIX = "QR Secure Send: ";
|
|
1027
1028
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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: [
|
|
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.
|
|
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:"))
|
|
1048
|
-
|
|
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
|
|
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
|
-
|
|
1108
|
-
|
|
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
|
|
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 && !
|
|
1144
|
-
errEl.textContent = "Connect
|
|
1156
|
+
if (useWallet && !sendWalletAccount) {
|
|
1157
|
+
errEl.textContent = "Connect MetaMask first.";
|
|
1145
1158
|
return;
|
|
1146
1159
|
}
|
|
1147
1160
|
|
|
1148
1161
|
try {
|
|
1149
|
-
|
|
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
|
|
1176
|
-
let
|
|
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
|
-
|
|
1192
|
-
|
|
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
|
|
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
|
|
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',
|
|
1221
|
+
window.ethereum.on('accountsChanged', (accounts) => {
|
|
1205
1222
|
if (accounts.length) {
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
1247
|
-
errEl.textContent = "This secret requires wallet
|
|
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
|
-
|
|
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 && !
|
|
1300
|
-
errEl.textContent = "This secret requires wallet
|
|
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
|
-
|
|
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.
|
|
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
|
+
});
|