fuzzyworld

dm / commit

feat: encrypt API key in localStorage, hide share button for saved keys

4903d7129260e4b8e034c7a7beac6388503b321f · Pavel Safronov · 2026-05-27 15:59:03 -0700 · parents bc65c65adbba

feat: encrypt API key in localStorage, hide share button for saved keys

- localStorage now stores the key as AES-GCM ciphertext (L: prefix,
  10k PBKDF2 iterations, origin-derived passphrase) instead of plaintext.
  Plain HTTP falls back to the existing XOR cipher. Legacy plaintext
  values are read transparently and re-encrypted on next save.
- "Generate share link" button is hidden when the key was auto-loaded
  from storage; it only appears once the user manually types a key.
- Errors in the game screen now include a "Copy error details" link
  so non-technical users can share diagnostic info without opening DevTools.

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

diff --git a/public/index.html b/public/index.html
index 006bd46..a5f03d0 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1134,6 +1134,44 @@ async function aesDecrypt(blob, passphrase) {
   return new TextDecoder().decode(pt);
 }
 
+// -- localStorage obfuscation ('L:' prefix, 10k iterations, origin-derived key) --
+// Not secret — anyone reading the source can decrypt — but keeps the raw key
+// off-screen in DevTools → Application → localStorage.
+const _LSPASS = 'dm-local:' + location.origin;
+
+async function encryptForStorage(key) {
+  if (!window.isSecureContext) return xorEncrypt(key, _LSPASS);
+  const enc  = new TextEncoder();
+  const salt = crypto.getRandomValues(new Uint8Array(16));
+  const iv   = crypto.getRandomValues(new Uint8Array(12));
+  const mat  = await crypto.subtle.importKey('raw', enc.encode(_LSPASS), 'PBKDF2', false, ['deriveKey']);
+  const aesK = await crypto.subtle.deriveKey(
+    { name: 'PBKDF2', salt, iterations: 10000, hash: 'SHA-256' },
+    mat, { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
+  const ct   = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesK, enc.encode(key));
+  const blob = new Uint8Array(16 + 12 + ct.byteLength);
+  blob.set(salt, 0); blob.set(iv, 16); blob.set(new Uint8Array(ct), 28);
+  return 'L:' + btoa(String.fromCharCode(...blob)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
+}
+
+async function decryptFromStorage(stored) {
+  if (!stored) return null;
+  if (!stored.startsWith('L:') && !stored.startsWith('X:')) return stored; // legacy plaintext
+  if (stored.startsWith('X:')) return xorDecrypt(stored, _LSPASS);
+  if (!window.isSecureContext) return null;
+  const raw  = stored.slice(2).replace(/-/g, '+').replace(/_/g, '/');
+  const data = Uint8Array.from(atob(raw), c => c.charCodeAt(0));
+  const enc  = new TextEncoder();
+  const mat  = await crypto.subtle.importKey('raw', enc.encode(_LSPASS), 'PBKDF2', false, ['deriveKey']);
+  const aesK = await crypto.subtle.deriveKey(
+    { name: 'PBKDF2', salt: data.slice(0, 16), iterations: 10000, hash: 'SHA-256' },
+    mat, { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
+  try {
+    const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: data.slice(16, 28) }, aesK, data.slice(28));
+    return new TextDecoder().decode(pt);
+  } catch { return null; }
+}
+
 // -- Public API --------------------------------------------------------
 async function encryptApiKey(apiKey, passphrase) {
   return window.isSecureContext ? aesEncrypt(apiKey, passphrase) : xorEncrypt(apiKey, passphrase);
@@ -1231,7 +1269,7 @@ let isBusy = false;
 
 // ── CREATION SCREEN ───────────────────────────────────
 
-function buildCreationScreen() {
+async function buildCreationScreen() {
   const raceGrid = document.getElementById('race-grid');
   RACES.forEach(r => {
     const card = document.createElement('div');
@@ -1269,23 +1307,36 @@ function buildCreationScreen() {
     document.getElementById('own-key-section').style.display = 'none';
     document.getElementById('access-code-section').style.display = '';
   } else {
-    // Own-key mode: restore from localStorage
-    const savedKey = localStorage.getItem('dm_api_key');
+    // Own-key mode: restore from localStorage (stored encrypted)
+    let keyFromStorage = false;
+    const savedKey = await decryptFromStorage(localStorage.getItem('dm_api_key'));
     if (savedKey) {
       document.getElementById('api-key-input').value = savedKey;
       document.getElementById('key-saved-badge').style.display = '';
       document.getElementById('forget-key-btn').style.display = '';
+      keyFromStorage = true;
+    }
+
+    // Share button is only useful when the key was typed manually, not auto-loaded
+    function syncShareBtn() {
+      const hasManualKey = !keyFromStorage && document.getElementById('api-key-input').value.trim().length > 0;
+      document.getElementById('open-share-modal').style.display = hasManualKey ? '' : 'none';
     }
+    syncShareBtn();
 
     document.getElementById('forget-key-btn').addEventListener('click', () => {
       localStorage.removeItem('dm_api_key');
       document.getElementById('api-key-input').value = '';
       document.getElementById('key-saved-badge').style.display = 'none';
       document.getElementById('forget-key-btn').style.display = 'none';
+      keyFromStorage = false;
+      syncShareBtn();
     });
 
     document.getElementById('api-key-input').addEventListener('input', () => {
       document.getElementById('key-saved-badge').style.display = 'none';
+      keyFromStorage = false;
+      syncShareBtn();
     });
 
     // Share modal
@@ -1361,7 +1412,7 @@ async function beginAdventure() {
   } else {
     apiKey = document.getElementById('api-key-input').value.trim();
     if (!apiKey) { alert('Please enter your Anthropic API key.'); return; }
-    localStorage.setItem('dm_api_key', apiKey);
+    localStorage.setItem('dm_api_key', await encryptForStorage(apiKey));
   }
 
   const name = document.getElementById('char-name').value.trim() || `The ${selectedClass.name}`;
@@ -1802,7 +1853,19 @@ async function sendMessage(text, showAsPlayer = true) {
   } catch (err) {
     console.error('🔴 sendMessage error:', err);
     const lines = err.message.split('\n');
-    dmEl.innerHTML = `<span style="color:#e74c3c">⚠️ ${lines.map(escHtml).join('<br>')}</span>`;
+    const debugInfo = [
+      `Error: ${err.message}`,
+      `Time: ${new Date().toISOString()}`,
+      `Secure context: ${window.isSecureContext}`,
+      `Browser: ${navigator.userAgent}`,
+    ].join('\n');
+    dmEl.innerHTML =
+      `<span style="color:#e74c3c">⚠️ ${lines.map(escHtml).join('<br>')}</span>` +
+      `<br><a href="#" class="debug-copy-link" style="font-size:0.78rem;color:var(--text-dim);text-decoration:underline">📋 Copy error details</a>`;
+    dmEl.querySelector('.debug-copy-link').addEventListener('click', e => {
+      e.preventDefault();
+      navigator.clipboard.writeText(debugInfo).then(() => { e.target.textContent = '✓ Copied!'; });
+    });
     conversationHistory.pop();
   }
 
@@ -1862,7 +1925,7 @@ document.getElementById('new-game-btn').addEventListener('click', () => {
 
 // ── INIT ──────────────────────────────────────────────
 
-buildCreationScreen();
+buildCreationScreen().catch(console.error);
 </script>
 </body>
 </html>