fuzzyworld

dm / commit

fix: support share link generation on plain HTTP

1890c88994af2413c6dfe8e94e158b285ec2c377 Β· Pavel Safronov Β· 2026-05-27 12:09:26 -0700 Β· parents b1813380cac0

fix: support share link generation on plain HTTP

crypto.subtle requires a secure context (HTTPS or localhost). Added a
pure-JS XOR stream cipher fallback (FNV-1a, 8000-round key stretch) that
works on any origin.

Blobs are prefixed: "A:" = AES-GCM (strong, secure context only),
"X:" = XOR stream (works everywhere). decryptApiKey reads the prefix and
dispatches accordingly, so AES links still open correctly on HTTPS.

The share modal shows a warning banner when generating on HTTP. If an
AES-encrypted link is somehow opened on HTTP, the error message explains
HTTPS is required rather than showing a generic failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 public/index.html | 104 +++++++++++++++++++++++++++++++++++++++++++++---------
 1 file changed, 88 insertions(+), 16 deletions(-)

diff --git a/public/index.html b/public/index.html
index 66fb9bf..fb75dc0 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1007,6 +1007,10 @@ body {
   <div class="share-inner">
     <div class="share-title">πŸ”— Generate Share Link</div>
 
+    <p id="share-http-warning" style="display:none;font-size:0.78rem;color:var(--hp-low);background:rgba(231,76,60,0.08);border:1px solid rgba(231,76,60,0.25);border-radius:4px;padding:8px 12px;margin-bottom:14px">
+      ⚠️ Running on HTTP β€” using a simplified cipher. For stronger encryption, generate links from your deployed HTTPS site.
+    </p>
+
     <div class="field-group" style="margin-bottom:14px">
       <label>Your API Key</label>
       <input type="password" id="share-key-input" placeholder="sk-ant-api03-…" autocomplete="off" spellcheck="false" style="width:100%;background:var(--bg-input);border:1px solid var(--border);border-radius:4px;color:var(--text-bright);font-family:'Crimson Text',serif;font-size:1rem;padding:10px 14px;outline:none">
@@ -1043,8 +1047,61 @@ body {
 
 <script>
 // ── CRYPTO ────────────────────────────────────────────
+// Two implementations: AES-GCM (requires secure context) and a pure-JS
+// XOR stream cipher fallback for plain HTTP.  Blobs are prefixed with
+// "A:" (AES-GCM) or "X:" (XOR) so decryption always picks the right path.
+
+// -- Pure-JS fallback (HTTP / non-secure contexts) ---------------------
+// FNV-1a hash used for key derivation + XOR stream cipher.
+// Adequate for casual access control; weaker than AES-GCM.
+function fnv32(s) {
+  let h = 2166136261;
+  for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619);
+  return h >>> 0;
+}
+
+function xorEncrypt(text, passphrase) {
+  // Stretch the key with 8000 rounds to slow brute-force
+  let seed = fnv32(passphrase + ':dm:0');
+  for (let i = 1; i <= 8000; i++) seed = fnv32(seed + ':' + passphrase + ':' + i);
+
+  const data = new TextEncoder().encode(text);
+  const out  = new Uint8Array(4 + data.length);
+  const tag  = fnv32(passphrase + ':dm:verify');
+  out[0] = tag >> 24 & 0xFF; out[1] = tag >> 16 & 0xFF;
+  out[2] = tag >>  8 & 0xFF; out[3] = tag       & 0xFF;
+
+  let state = seed;
+  for (let i = 0; i < data.length; i++) {
+    state = fnv32(state.toString(16) + ':' + i);
+    out[4 + i] = data[i] ^ (state & 0xFF);
+  }
+  return 'X:' + btoa(String.fromCharCode(...out))
+    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
+}
+
+function xorDecrypt(blob, passphrase) {
+  const b64  = blob.slice(2).replace(/-/g, '+').replace(/_/g, '/');
+  const data = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
+
+  const expected = fnv32(passphrase + ':dm:verify');
+  const actual   = (data[0] << 24 | data[1] << 16 | data[2] << 8 | data[3]) >>> 0;
+  if (actual !== expected) throw new Error('Invalid access code');
+
+  let seed = fnv32(passphrase + ':dm:0');
+  for (let i = 1; i <= 8000; i++) seed = fnv32(seed + ':' + passphrase + ':' + i);
+
+  const result = new Uint8Array(data.length - 4);
+  let state = seed;
+  for (let i = 0; i < result.length; i++) {
+    state = fnv32(state.toString(16) + ':' + i);
+    result[i] = data[4 + i] ^ (state & 0xFF);
+  }
+  return new TextDecoder().decode(result);
+}
 
-async function deriveKey(passphrase, salt, usage) {
+// -- AES-GCM via Web Crypto (HTTPS / localhost) ------------------------
+async function deriveAesKey(passphrase, salt, usage) {
   const enc = new TextEncoder();
   const raw = await crypto.subtle.importKey('raw', enc.encode(passphrase), 'PBKDF2', false, ['deriveKey']);
   return crypto.subtle.deriveKey(
@@ -1056,28 +1113,41 @@ async function deriveKey(passphrase, salt, usage) {
   );
 }
 
-async function encryptApiKey(apiKey, passphrase) {
-  const enc = new TextEncoder();
+async function aesEncrypt(text, passphrase) {
+  const enc  = new TextEncoder();
   const salt = crypto.getRandomValues(new Uint8Array(16));
   const iv   = crypto.getRandomValues(new Uint8Array(12));
-  const key  = await deriveKey(passphrase, salt, 'encrypt');
-  const ct   = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc.encode(apiKey));
+  const key  = await deriveAesKey(passphrase, salt, 'encrypt');
+  const ct   = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc.encode(text));
   const blob = new Uint8Array(16 + 12 + ct.byteLength);
   blob.set(salt, 0); blob.set(iv, 16); blob.set(new Uint8Array(ct), 28);
-  return btoa(String.fromCharCode(...blob)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
+  return 'A:' + btoa(String.fromCharCode(...blob))
+    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
 }
 
-async function decryptApiKey(b64url, passphrase) {
-  const b64  = b64url.replace(/-/g, '+').replace(/_/g, '/');
-  const blob = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
-  const salt = blob.slice(0, 16);
-  const iv   = blob.slice(16, 28);
-  const ct   = blob.slice(28);
-  const key  = await deriveKey(passphrase, salt, 'decrypt');
-  const pt   = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
+async function aesDecrypt(blob, passphrase) {
+  const raw  = blob.slice(2).replace(/-/g, '+').replace(/_/g, '/');
+  const data = Uint8Array.from(atob(raw), c => c.charCodeAt(0));
+  const key  = await deriveAesKey(passphrase, data.slice(0, 16), 'decrypt');
+  const pt   = await crypto.subtle.decrypt(
+    { name: 'AES-GCM', iv: data.slice(16, 28) }, key, data.slice(28));
   return new TextDecoder().decode(pt);
 }
 
+// -- Public API --------------------------------------------------------
+async function encryptApiKey(apiKey, passphrase) {
+  return window.isSecureContext ? aesEncrypt(apiKey, passphrase) : xorEncrypt(apiKey, passphrase);
+}
+
+async function decryptApiKey(blob, passphrase) {
+  if (blob.startsWith('X:')) return xorDecrypt(blob, passphrase);
+  // 'A:' prefix or legacy (no prefix) β€” both use AES-GCM
+  const b = blob.startsWith('A:') ? blob : 'A:' + blob;
+  if (!window.isSecureContext)
+    throw new Error('This link uses strong encryption and requires HTTPS to open. Use your GitLab Pages URL.');
+  return aesDecrypt(b, passphrase);
+}
+
 function getSharedBlob() {
   const params = new URLSearchParams(location.hash.slice(1));
   return params.get('k') || null;
@@ -1223,6 +1293,8 @@ function buildCreationScreen() {
       const cur = document.getElementById('api-key-input').value.trim();
       if (cur) document.getElementById('share-key-input').value = cur;
       document.getElementById('share-result').style.display = 'none';
+      document.getElementById('share-http-warning').style.display =
+        window.isSecureContext ? 'none' : '';
       document.getElementById('share-modal').classList.add('open');
     });
 
@@ -1280,8 +1352,8 @@ async function beginAdventure() {
     btn.disabled = true; btn.textContent = 'Unlocking…';
     try {
       apiKey = await decryptApiKey(sharedBlob, passphrase);
-    } catch {
-      alert('Invalid access code. Please check and try again.');
+    } catch (err) {
+      alert(err.message.includes('HTTPS') ? err.message : 'Invalid access code. Please check and try again.');
       btn.disabled = false; btn.textContent = 'Begin Adventure';
       return;
     }