dm / commit
feat: encrypt API key in localStorage, hide share button for saved keys
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>