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.
- package/index.html +217 -24
- 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.
|
|
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 — all encryption and decryption happen locally using the Web Crypto API.
|
|
190
|
-
<br>For fully offline use:
|
|
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
|
-
|
|
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
|
-
|
|
925
|
-
try {
|
|
926
|
-
|
|
927
|
-
if (
|
|
928
|
-
|
|
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
|
|
977
|
-
const
|
|
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
|
|
1019
|
-
if (!
|
|
1188
|
+
const payload = extractPayload(decodedText);
|
|
1189
|
+
if (!payload) {
|
|
1020
1190
|
errEl.textContent = "Not a QR Secure Send code.";
|
|
1021
|
-
return false;
|
|
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
|
|
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;
|
|
1209
|
+
return true;
|
|
1032
1210
|
} catch (e) {
|
|
1033
|
-
errEl.textContent =
|
|
1034
|
-
|
|
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
|
|
1067
|
-
if (!
|
|
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
|
|
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 =
|
|
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
|