qr-secure-send 1.5.2 → 1.6.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 (2) hide show
  1. package/index.html +217 -24
  2. package/package.json +1 -1
package/index.html CHANGED
@@ -118,13 +118,45 @@
118
118
  font-size: 0.8rem; color: #d29922; margin-top: 0.5rem;
119
119
  padding: 0.5rem; background: #d299220f; border-radius: 4px;
120
120
  }
121
+
122
+ .passphrase-strength {
123
+ font-size: 0.78rem; line-height: 1.4; margin-top: -0.6rem;
124
+ margin-bottom: 0.8rem; padding: 0.4rem 0.6rem;
125
+ border-radius: 4px; display: none;
126
+ }
127
+ .passphrase-strength.weak { color: #f85149; background: #f851490f; display: block; }
128
+ .passphrase-strength.moderate { color: #d29922; background: #d299220f; display: block; }
129
+ .passphrase-strength.strong { color: #3fb950; background: #3fb9500f; display: block; }
130
+
131
+ .wallet-section {
132
+ margin-bottom: 1rem; padding: 0.8rem;
133
+ background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
134
+ }
135
+ .wallet-toggle {
136
+ display: flex; align-items: center; gap: 0.5rem; cursor: pointer;
137
+ font-size: 0.85rem; color: #8b949e; text-transform: none; letter-spacing: 0;
138
+ }
139
+ .wallet-toggle input[type="checkbox"] { width: auto; margin: 0; }
140
+ .wallet-fields { display: none; margin-top: 0.8rem; }
141
+ .wallet-fields.active { display: block; }
142
+ .wallet-address {
143
+ font-family: "SF Mono", "Fira Code", monospace; font-size: 0.82rem;
144
+ color: #58a6ff; word-break: break-all; padding: 0.3rem 0;
145
+ }
146
+ button.wallet-btn {
147
+ padding: 0.5rem 0.8rem; background: #f6851b; color: #fff; border: none;
148
+ border-radius: 6px; font-size: 0.85rem; font-weight: 500; cursor: pointer;
149
+ transition: background 0.2s;
150
+ }
151
+ button.wallet-btn:hover { background: #e2761b; }
152
+ button.wallet-btn:disabled { opacity: 0.5; cursor: not-allowed; }
121
153
  </style>
122
154
  </head>
123
155
  <body>
124
156
  <header>
125
157
  <h1>QR Secure Send</h1>
126
158
  <p>Encrypt and transfer secrets via QR code</p>
127
- <p style="font-size:0.7rem;color:#484f58;margin-top:0.2rem">v1.5.2</p>
159
+ <p style="font-size:0.7rem;color:#484f58;margin-top:0.2rem">v1.6.0</p>
128
160
  </header>
129
161
 
130
162
  <div class="tabs">
@@ -146,6 +178,22 @@
146
178
  <input type="password" id="send-passphrase" placeholder="Shared passphrase..." autocomplete="off">
147
179
  <button class="toggle-vis" data-target="send-passphrase">show</button>
148
180
  </div>
181
+ <div id="send-passphrase-strength" class="passphrase-strength"></div>
182
+
183
+ <div class="wallet-section">
184
+ <label class="wallet-toggle">
185
+ <input type="checkbox" id="send-wallet-toggle">
186
+ Require receiver's crypto wallet (MetaMask)
187
+ </label>
188
+ <div id="send-wallet-fields" class="wallet-fields">
189
+ <label for="send-wallet-address">Receiver Wallet Address</label>
190
+ <input type="text" id="send-wallet-address" placeholder="0x..." autocomplete="off"
191
+ style="font-family:'SF Mono','Fira Code',monospace;font-size:0.85rem;">
192
+ <p class="info" style="margin-top:-0.5rem;text-align:left">
193
+ The receiver must connect this exact wallet to decrypt. The address is mixed into the encryption key.
194
+ </p>
195
+ </div>
196
+ </div>
149
197
 
150
198
  <button class="primary" id="generate-btn">Generate QR Code</button>
151
199
  <div id="send-error" class="error"></div>
@@ -165,6 +213,15 @@
165
213
  <input type="password" id="recv-passphrase" placeholder="Same passphrase used to encrypt..." autocomplete="off">
166
214
  <button class="toggle-vis" data-target="recv-passphrase">show</button>
167
215
  </div>
216
+ <div id="recv-passphrase-strength" class="passphrase-strength"></div>
217
+
218
+ <div id="recv-wallet-section" class="wallet-section" style="display:none">
219
+ <p style="font-size:0.82rem;color:#d29922;margin-bottom:0.5rem;">
220
+ This secret requires wallet authentication. Connect the designated wallet to decrypt.
221
+ </p>
222
+ <button class="wallet-btn" id="recv-wallet-connect-btn">Connect MetaMask</button>
223
+ <div id="recv-wallet-status" class="wallet-address" style="display:none"></div>
224
+ </div>
168
225
 
169
226
  <div class="btn-row">
170
227
  <button class="primary" id="scan-btn">Start Camera</button>
@@ -187,7 +244,8 @@
187
244
 
188
245
  <div style="margin:2rem auto;max-width:480px;padding:0.8rem 1rem;background:#0d4429;border:1px solid #1a7f42;border-radius:8px;color:#3fb950;font-size:0.82rem;text-align:center;line-height:1.5">
189
246
  This app runs <strong>entirely in your browser</strong>. Nothing is sent to any server &mdash; all encryption and decryption happen locally using the Web Crypto API.
190
- <br>For fully offline use: <code style="background:#161b22;padding:0.15rem 0.4rem;border-radius:4px;color:#58a6ff">npm i -g qr-secure-send && qr-secure-send</code>
247
+ <br>For fully offline use:
248
+ <br><code style="background:#161b22;padding:0.3rem 0.6rem;border-radius:4px;color:#58a6ff;display:inline-block;margin-top:0.4rem">npm i -g qr-secure-send && qr-secure-send</code>
191
249
  </div>
192
250
 
193
251
  <script>
@@ -913,19 +971,59 @@
913
971
  }
914
972
 
915
973
 
974
+ // =========================================================================
975
+ // PASSPHRASE STRENGTH
976
+ // =========================================================================
977
+
978
+ const BRUTE_FORCE_NOTE = 'There is no rate-limiting \u2014 an attacker with the ' +
979
+ 'encrypted data can try passphrases offline at thousands of guesses/sec per GPU.';
980
+
981
+ function evaluatePassphraseStrength(passphrase) {
982
+ if (!passphrase) return { level: 'none', bits: 0, message: '' };
983
+
984
+ let charsetSize = 0;
985
+ if (/[a-z]/.test(passphrase)) charsetSize += 26;
986
+ if (/[A-Z]/.test(passphrase)) charsetSize += 26;
987
+ if (/[0-9]/.test(passphrase)) charsetSize += 10;
988
+ if (/[^a-zA-Z0-9]/.test(passphrase)) charsetSize += 33;
989
+ if (charsetSize === 0) charsetSize = 26;
990
+
991
+ let bits = Math.floor(passphrase.length * Math.log2(charsetSize));
992
+ if (/^(.)\1+$/.test(passphrase)) bits = 4;
993
+ if (/^[a-z]+$/i.test(passphrase)) bits = Math.floor(bits * 0.7);
994
+ if (passphrase.length < 6) bits = Math.min(bits, 20);
995
+
996
+ if (bits < 35) {
997
+ return { level: 'weak', bits, message:
998
+ 'Weak (~' + bits + '-bit). Crackable in hours/days by a GPU. ' + BRUTE_FORCE_NOTE +
999
+ ' Use 12+ chars with mixed case, digits & symbols, or a 5+ word passphrase.' };
1000
+ }
1001
+ if (bits < 50) {
1002
+ return { level: 'moderate', bits, message:
1003
+ 'Moderate (~' + bits + '-bit). May resist casual attacks but not a determined adversary. ' +
1004
+ BRUTE_FORCE_NOTE + ' Consider a longer passphrase for sensitive secrets.' };
1005
+ }
1006
+ return { level: 'strong', bits, message: 'Strong (~' + bits + '-bit).' };
1007
+ }
1008
+
916
1009
  // =========================================================================
917
1010
  // PAYLOAD HELPERS
918
1011
  // =========================================================================
919
1012
 
920
1013
  const GH_PAGES_URL = "https://degenddy.github.io/qr-secure-send/";
921
1014
 
922
- // Extract the base64 encrypted data from either raw "QRSEC:..." or a URL with "#QRSEC:..."
1015
+ function buildKeyInput(passphrase, walletAddress) {
1016
+ return walletAddress ? passphrase + ':' + walletAddress.toLowerCase() : passphrase;
1017
+ }
1018
+
1019
+ // Extract encrypted data from raw or URL form. Returns { data, wallet } or null.
923
1020
  function extractPayload(text) {
924
- if (text.startsWith("QRSEC:")) return text.slice(6);
925
- try {
926
- const hash = new URL(text).hash;
927
- if (hash.startsWith("#QRSEC:")) return hash.slice(7);
928
- } catch (_) {}
1021
+ const sources = [text];
1022
+ try { const h = new URL(text).hash; if (h) sources.push(h.slice(1)); } catch (_) {}
1023
+ for (const s of sources) {
1024
+ if (s.startsWith("QRSECW:")) return { data: s.slice(7), wallet: true };
1025
+ if (s.startsWith("QRSEC:")) return { data: s.slice(6), wallet: false };
1026
+ }
929
1027
  return null;
930
1028
  }
931
1029
 
@@ -957,10 +1055,27 @@
957
1055
  });
958
1056
  });
959
1057
 
1058
+ // Passphrase strength listeners
1059
+ ['send-passphrase', 'recv-passphrase'].forEach(id => {
1060
+ document.getElementById(id).addEventListener('input', () => {
1061
+ const result = evaluatePassphraseStrength(document.getElementById(id).value);
1062
+ const el = document.getElementById(id + '-strength');
1063
+ el.className = 'passphrase-strength' + (result.level !== 'none' ? ' ' + result.level : '');
1064
+ el.textContent = result.message;
1065
+ });
1066
+ });
1067
+
1068
+ // Send wallet toggle
1069
+ document.getElementById("send-wallet-toggle").addEventListener("change", (e) => {
1070
+ document.getElementById("send-wallet-fields").classList.toggle("active", e.target.checked);
1071
+ });
1072
+
960
1073
  // QR Generation
961
1074
  document.getElementById("generate-btn").addEventListener("click", async () => {
962
1075
  const secret = document.getElementById("secret").value.trim();
963
1076
  const passphrase = document.getElementById("send-passphrase").value;
1077
+ const useWallet = document.getElementById("send-wallet-toggle").checked;
1078
+ const walletAddress = useWallet ? document.getElementById("send-wallet-address").value.trim() : null;
964
1079
  const errEl = document.getElementById("send-error");
965
1080
  const output = document.getElementById("qr-output");
966
1081
  const info = document.getElementById("qr-info");
@@ -972,9 +1087,24 @@
972
1087
  if (!secret) { errEl.textContent = "Please enter a secret."; return; }
973
1088
  if (!passphrase) { errEl.textContent = "Please enter a passphrase."; return; }
974
1089
 
1090
+ const strength = evaluatePassphraseStrength(passphrase);
1091
+ if (strength.level === 'weak') {
1092
+ if (!confirm('Your passphrase is weak (~' + strength.bits + '-bit entropy). ' +
1093
+ 'An attacker who captures this QR code could crack it offline. Continue anyway?')) return;
1094
+ }
1095
+
1096
+ if (useWallet) {
1097
+ if (!walletAddress || !/^0x[0-9a-fA-F]{40}$/.test(walletAddress)) {
1098
+ errEl.textContent = "Enter a valid Ethereum wallet address (0x + 40 hex characters).";
1099
+ return;
1100
+ }
1101
+ }
1102
+
975
1103
  try {
976
- const encrypted = await encryptSecret(secret, passphrase);
977
- const payload = GH_PAGES_URL + "#QRSEC:" + encrypted;
1104
+ const keyInput = buildKeyInput(passphrase, walletAddress);
1105
+ const encrypted = await encryptSecret(secret, keyInput);
1106
+ const prefix = useWallet ? "QRSECW:" : "QRSEC:";
1107
+ const payload = GH_PAGES_URL + "#" + prefix + encrypted;
978
1108
 
979
1109
  if (payload.length > 2953) {
980
1110
  errEl.textContent = "Secret is too long for a QR code. Keep it under ~1950 characters.";
@@ -996,6 +1126,46 @@
996
1126
  "loaded from the internet when you start the camera.";
997
1127
  }
998
1128
 
1129
+ // Wallet connect (receive side)
1130
+ let connectedWalletAddress = null;
1131
+
1132
+ document.getElementById("recv-wallet-connect-btn").addEventListener("click", async () => {
1133
+ const statusEl = document.getElementById("recv-wallet-status");
1134
+ const errEl = document.getElementById("recv-error");
1135
+ if (typeof window.ethereum === 'undefined') {
1136
+ errEl.textContent = "MetaMask (or compatible wallet) not detected. Please install MetaMask.";
1137
+ return;
1138
+ }
1139
+ try {
1140
+ const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
1141
+ if (!accounts.length) { errEl.textContent = "No accounts returned. Please unlock MetaMask."; return; }
1142
+ connectedWalletAddress = accounts[0].toLowerCase();
1143
+ statusEl.textContent = "Connected: " + connectedWalletAddress;
1144
+ statusEl.style.display = "block";
1145
+ document.getElementById("recv-wallet-connect-btn").textContent = "Wallet Connected";
1146
+ document.getElementById("recv-wallet-connect-btn").disabled = true;
1147
+ } catch (e) {
1148
+ errEl.textContent = "Wallet connection failed: " + (e.message || "User rejected request.");
1149
+ }
1150
+ });
1151
+
1152
+ // Update wallet address if user switches account in MetaMask
1153
+ if (typeof window.ethereum !== 'undefined') {
1154
+ window.ethereum.on('accountsChanged', (accounts) => {
1155
+ if (accounts.length) {
1156
+ connectedWalletAddress = accounts[0].toLowerCase();
1157
+ const statusEl = document.getElementById("recv-wallet-status");
1158
+ statusEl.textContent = "Connected: " + connectedWalletAddress;
1159
+ statusEl.style.display = "block";
1160
+ } else {
1161
+ connectedWalletAddress = null;
1162
+ document.getElementById("recv-wallet-status").style.display = "none";
1163
+ const btn = document.getElementById("recv-wallet-connect-btn");
1164
+ btn.textContent = "Connect MetaMask"; btn.disabled = false;
1165
+ }
1166
+ });
1167
+ }
1168
+
999
1169
  // QR Scanning
1000
1170
  document.getElementById("scan-btn").addEventListener("click", async () => {
1001
1171
  const passphrase = document.getElementById("recv-passphrase").value;
@@ -1015,23 +1185,33 @@
1015
1185
  const started = await QRScan.start(
1016
1186
  region,
1017
1187
  async (decodedText) => {
1018
- const encrypted = extractPayload(decodedText);
1019
- if (!encrypted) {
1188
+ const payload = extractPayload(decodedText);
1189
+ if (!payload) {
1020
1190
  errEl.textContent = "Not a QR Secure Send code.";
1021
- return false; // keep scanning
1191
+ return false;
1192
+ }
1193
+ if (payload.wallet) {
1194
+ document.getElementById("recv-wallet-section").style.display = "block";
1195
+ if (!connectedWalletAddress) {
1196
+ errEl.textContent = "This secret requires wallet authentication. Connect your wallet first, then scan again.";
1197
+ return false;
1198
+ }
1022
1199
  }
1023
1200
  try {
1024
- const secret = await decryptSecret(encrypted, passphrase);
1201
+ const keyInput = buildKeyInput(passphrase, payload.wallet ? connectedWalletAddress : null);
1202
+ const secret = await decryptSecret(payload.data, keyInput);
1025
1203
  document.getElementById("recv-value").textContent = secret;
1026
1204
  resultBox.style.display = "block";
1027
1205
  errEl.textContent = "";
1028
1206
  QRScan.stop(region);
1029
1207
  document.getElementById("scan-btn").style.display = "block";
1030
1208
  document.getElementById("stop-btn").style.display = "none";
1031
- return true; // success, stop scanning
1209
+ return true;
1032
1210
  } catch (e) {
1033
- errEl.textContent = "Decryption failed. Wrong passphrase?";
1034
- return false; // keep scanning
1211
+ errEl.textContent = payload.wallet
1212
+ ? "Decryption failed. Wrong passphrase or wrong wallet?"
1213
+ : "Decryption failed. Wrong passphrase?";
1214
+ return false;
1035
1215
  }
1036
1216
  },
1037
1217
  (msg) => {
@@ -1063,29 +1243,42 @@
1063
1243
 
1064
1244
  if (!passphrase) { errEl.textContent = "Enter the decryption passphrase first."; return; }
1065
1245
 
1066
- const hashData = location.hash.startsWith("#QRSEC:") ? location.hash.slice(7) : null;
1067
- if (!hashData) { errEl.textContent = "No encrypted data found in URL."; return; }
1246
+ const payload = extractPayload(location.hash.slice(1));
1247
+ if (!payload) { errEl.textContent = "No encrypted data found in URL."; return; }
1248
+
1249
+ if (payload.wallet && !connectedWalletAddress) {
1250
+ errEl.textContent = "This secret requires wallet authentication. Connect your wallet first.";
1251
+ document.getElementById("recv-wallet-section").style.display = "block";
1252
+ return;
1253
+ }
1068
1254
 
1069
1255
  try {
1070
- const secret = await decryptSecret(hashData, passphrase);
1256
+ const keyInput = buildKeyInput(passphrase, payload.wallet ? connectedWalletAddress : null);
1257
+ const secret = await decryptSecret(payload.data, keyInput);
1071
1258
  document.getElementById("recv-value").textContent = secret;
1072
1259
  resultBox.style.display = "block";
1073
1260
  } catch (e) {
1074
- errEl.textContent = "Decryption failed. Wrong passphrase?";
1261
+ errEl.textContent = payload.wallet
1262
+ ? "Decryption failed. Wrong passphrase or wrong wallet?"
1263
+ : "Decryption failed. Wrong passphrase?";
1075
1264
  }
1076
1265
  });
1077
1266
 
1078
1267
  // Check URL hash for incoming encrypted data (link-based receive flow)
1079
- if (location.hash.startsWith("#QRSEC:")) {
1080
- // Switch to receive tab
1268
+ if (location.hash.startsWith("#QRSEC:") || location.hash.startsWith("#QRSECW:")) {
1081
1269
  document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
1082
1270
  document.querySelectorAll(".panel").forEach(p => p.classList.remove("active"));
1083
1271
  document.querySelector('[data-tab="receive"]').classList.add("active");
1084
1272
  document.getElementById("receive").classList.add("active");
1085
- // Show decrypt button instead of camera controls
1086
1273
  document.getElementById("scan-btn").style.display = "none";
1087
1274
  document.getElementById("decrypt-btn").style.display = "inline-block";
1088
1275
  document.getElementById("link-notice").style.display = "block";
1276
+ if (location.hash.startsWith("#QRSECW:")) {
1277
+ document.getElementById("recv-wallet-section").style.display = "block";
1278
+ document.getElementById("link-notice").textContent =
1279
+ "Encrypted data received via link. This secret requires wallet authentication. " +
1280
+ "Connect your wallet, enter the passphrase, and click Decrypt.";
1281
+ }
1089
1282
  }
1090
1283
 
1091
1284
  // Copy to clipboard
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qr-secure-send",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "Encrypt and transfer secrets via QR code",
5
5
  "keywords": ["qr", "qrcode", "encryption", "password", "secure", "transfer"],
6
6
  "author": "",