fuzzyworld

dm / commit

feat: add encrypted share link for distributing API access

b1813380cac098bce979d69ba2041d43d60ae127 · Pavel Safronov · 2026-05-27 00:15:25 -0700 · parents ff6a15340c52

feat: add encrypted share link for distributing API access

Developers can generate a share link from the creation screen:
- API key is encrypted with AES-GCM / PBKDF2 (200k iterations)
- Encrypted blob goes in the URL hash (#k=...) — never sent to any server
- Passphrase is shared separately (verbally, Signal, etc.)

Recipients opening a #k= URL see an access code prompt instead of
the API key field. Wrong passphrase → decryption fails with an error.
Correct passphrase → key lives only in gameState for the session,
never written to localStorage.

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

diff --git a/public/index.html b/public/index.html
index e5af3f2..66fb9bf 100644
--- a/public/index.html
+++ b/public/index.html
@@ -723,6 +723,116 @@ body {
 }
 .attribution a:hover { color: var(--gold); }
 
+/* ── SHARE MODAL ────────────────────────────────── */
+
+#share-modal {
+  display: none;
+  position: fixed;
+  inset: 0;
+  z-index: 200;
+  background: rgba(0,0,0,0.75);
+  align-items: center;
+  justify-content: center;
+}
+#share-modal.open { display: flex; }
+
+.share-inner {
+  background: linear-gradient(135deg, #14140a, #1e1c0a);
+  border: 1px solid var(--gold-dim);
+  border-radius: 10px;
+  padding: 28px 32px;
+  width: 480px;
+  max-width: 95vw;
+  box-shadow: 0 0 60px rgba(201,168,76,0.2), 0 20px 60px rgba(0,0,0,0.8);
+}
+
+.share-title {
+  font-family: 'Cinzel', serif;
+  font-size: 1.1rem;
+  font-weight: 600;
+  color: var(--gold-light);
+  margin-bottom: 20px;
+  letter-spacing: 0.06em;
+}
+
+.share-row {
+  display: flex;
+  gap: 10px;
+  align-items: flex-end;
+  margin-bottom: 14px;
+}
+
+.share-url-box {
+  font-family: monospace;
+  font-size: 0.75rem;
+  background: var(--bg-input);
+  border: 1px solid var(--border);
+  border-radius: 4px;
+  padding: 10px 12px;
+  color: var(--text);
+  word-break: break-all;
+  margin-bottom: 6px;
+  user-select: all;
+  cursor: text;
+}
+
+.share-note {
+  font-size: 0.78rem;
+  color: var(--text-dim);
+  font-style: italic;
+  margin-bottom: 14px;
+}
+
+.share-btn {
+  font-family: 'Cinzel', serif;
+  font-size: 0.78rem;
+  font-weight: 600;
+  letter-spacing: 0.08em;
+  cursor: pointer;
+  border-radius: 4px;
+  padding: 9px 18px;
+  transition: all 0.15s;
+}
+
+.share-btn-primary {
+  color: #0d0d1a;
+  background: var(--gold);
+  border: none;
+}
+.share-btn-primary:hover { background: var(--gold-light); }
+.share-btn-secondary {
+  color: var(--text-dim);
+  background: transparent;
+  border: 1px solid var(--border);
+}
+.share-btn-secondary:hover { border-color: var(--gold-dim); color: var(--text); }
+
+.share-link-trigger {
+  font-family: 'Cinzel', serif;
+  font-size: 0.65rem;
+  letter-spacing: 0.1em;
+  color: var(--text-dim);
+  background: transparent;
+  border: 1px solid var(--border);
+  border-radius: 4px;
+  padding: 5px 12px;
+  cursor: pointer;
+  transition: all 0.15s;
+  text-transform: uppercase;
+  margin-top: 10px;
+}
+.share-link-trigger:hover { border-color: var(--gold-dim); color: var(--gold); }
+
+/* access code row */
+.access-code-label {
+  font-family: 'Cinzel', serif;
+  font-size: 0.7rem;
+  letter-spacing: 0.12em;
+  color: var(--gold);
+  text-transform: uppercase;
+  margin-bottom: 6px;
+}
+
 #levelup-toast button {
   font-family: 'Cinzel', serif;
   font-size: 0.85rem;
@@ -744,20 +854,36 @@ body {
   <div class="creation-sub">Powered by Claude — your story begins here</div>
 
   <div class="creation-section">
-    <div class="section-label">Anthropic API Key</div>
-    <div class="field-group">
-      <label>API Key</label>
-      <input type="password" id="api-key-input" placeholder="sk-ant-api03-…" autocomplete="off" spellcheck="false">
+    <!-- Shown when no encrypted key in URL (developer / own key) -->
+    <div id="own-key-section">
+      <div class="section-label">Anthropic API Key</div>
+      <div class="field-group">
+        <label>API Key</label>
+        <input type="password" id="api-key-input" placeholder="sk-ant-api03-…" autocomplete="off" spellcheck="false">
+      </div>
+      <div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px;min-height:18px">
+        <span id="key-saved-badge" style="font-size:0.75rem;color:var(--hp-full);display:none">✓ Saved on this device</span>
+        <button id="forget-key-btn" style="font-size:0.72rem;color:var(--text-dim);background:none;border:none;cursor:pointer;text-decoration:underline;text-underline-offset:2px;padding:0;display:none" type="button">Forget saved key</button>
+      </div>
+      <p style="font-size:0.78rem;color:var(--text-dim);margin-top:6px;font-style:italic">
+        Sent directly to Anthropic — never forwarded elsewhere. Saved to this device so you only enter it once.
+        Get one free at <a href="https://console.anthropic.com/" target="_blank" rel="noopener" style="color:var(--gold-dim)">console.anthropic.com</a>
+        — pay-as-you-go, a typical session costs a few cents.
+      </p>
+      <button class="share-link-trigger" id="open-share-modal">🔗 Generate share link</button>
     </div>
-    <div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px;min-height:18px">
-      <span id="key-saved-badge" style="font-size:0.75rem;color:var(--hp-full);display:none">✓ Saved on this device</span>
-      <button id="forget-key-btn" style="font-size:0.72rem;color:var(--text-dim);background:none;border:none;cursor:pointer;text-decoration:underline;text-underline-offset:2px;padding:0;display:none" type="button">Forget saved key</button>
+
+    <!-- Shown when #k=... is present in the URL (shared link recipient) -->
+    <div id="access-code-section" style="display:none">
+      <div class="section-label">Access Code</div>
+      <div class="field-group">
+        <label class="access-code-label">Enter the code you received</label>
+        <input type="password" id="access-code-input" placeholder="your passphrase" autocomplete="off" spellcheck="false">
+      </div>
+      <p style="font-size:0.78rem;color:var(--text-dim);margin-top:8px;font-style:italic">
+        You received a special link to this app. Enter the access code to unlock it.
+      </p>
     </div>
-    <p style="font-size:0.78rem;color:var(--text-dim);margin-top:6px;font-style:italic">
-      Sent directly to Anthropic — never forwarded elsewhere. Saved to this device so you only enter it once.
-      Get one free at <a href="https://console.anthropic.com/" target="_blank" rel="noopener" style="color:var(--gold-dim)">console.anthropic.com</a>
-      — pay-as-you-go, a typical session costs a few cents.
-    </p>
   </div>
 
   <div class="creation-section">
@@ -876,6 +1002,37 @@ body {
   </div>
 </div>
 
+<!-- Share link modal -->
+<div id="share-modal">
+  <div class="share-inner">
+    <div class="share-title">🔗 Generate Share Link</div>
+
+    <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">
+    </div>
+
+    <div class="field-group" style="margin-bottom:16px">
+      <label>Access Code <span style="color:var(--text-dim);font-weight:400;text-transform:none;letter-spacing:0">(passphrase your users will enter)</span></label>
+      <input type="text" id="share-pass-input" placeholder="e.g. goblin-treasure-42" 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">
+    </div>
+
+    <div class="share-row">
+      <button class="share-btn share-btn-primary" id="share-generate-btn">Generate</button>
+      <button class="share-btn share-btn-secondary" id="share-close-btn">Cancel</button>
+    </div>
+
+    <div id="share-result" style="display:none;margin-top:16px">
+      <div style="font-family:'Cinzel',serif;font-size:0.65rem;letter-spacing:0.12em;color:var(--gold);text-transform:uppercase;margin-bottom:6px">Share this URL</div>
+      <div class="share-url-box" id="share-url-display"></div>
+      <div class="share-row" style="margin-bottom:8px">
+        <button class="share-btn share-btn-primary" id="share-copy-btn">Copy URL</button>
+      </div>
+      <p class="share-note">⚠️ Share the access code <em>separately</em> from this link — by text, verbally, etc. The link alone is not enough to access the app.</p>
+    </div>
+  </div>
+</div>
+
 <!-- Level up toast -->
 <div id="levelup-toast">
   <div class="toast-icon">⭐</div>
@@ -885,6 +1042,47 @@ body {
 </div>
 
 <script>
+// ── CRYPTO ────────────────────────────────────────────
+
+async function deriveKey(passphrase, salt, usage) {
+  const enc = new TextEncoder();
+  const raw = await crypto.subtle.importKey('raw', enc.encode(passphrase), 'PBKDF2', false, ['deriveKey']);
+  return crypto.subtle.deriveKey(
+    { name: 'PBKDF2', salt, iterations: 200000, hash: 'SHA-256' },
+    raw,
+    { name: 'AES-GCM', length: 256 },
+    false,
+    [usage]
+  );
+}
+
+async function encryptApiKey(apiKey, 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 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, '');
+}
+
+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);
+  return new TextDecoder().decode(pt);
+}
+
+function getSharedBlob() {
+  const params = new URLSearchParams(location.hash.slice(1));
+  return params.get('k') || null;
+}
+
 // ── DATA ──────────────────────────────────────────────
 
 const RACES = [
@@ -992,36 +1190,107 @@ function buildCreationScreen() {
     classGrid.appendChild(card);
   });
 
-  document.getElementById('begin-btn').addEventListener('click', beginAdventure);
+  document.getElementById('begin-btn').addEventListener('click', () => beginAdventure());
+
+  const sharedBlob = getSharedBlob();
+
+  if (sharedBlob) {
+    // Shared-link mode: show access code input, hide own-key section
+    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');
+    if (savedKey) {
+      document.getElementById('api-key-input').value = savedKey;
+      document.getElementById('key-saved-badge').style.display = '';
+      document.getElementById('forget-key-btn').style.display = '';
+    }
 
-  // Restore saved API key
-  const savedKey = 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 = '';
-  }
+    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';
+    });
 
-  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';
-  });
+    document.getElementById('api-key-input').addEventListener('input', () => {
+      document.getElementById('key-saved-badge').style.display = 'none';
+    });
 
-  document.getElementById('api-key-input').addEventListener('input', () => {
-    document.getElementById('key-saved-badge').style.display = 'none';
-  });
+    // Share modal
+    document.getElementById('open-share-modal').addEventListener('click', () => {
+      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-modal').classList.add('open');
+    });
+
+    document.getElementById('share-close-btn').addEventListener('click', () => {
+      document.getElementById('share-modal').classList.remove('open');
+    });
+
+    document.getElementById('share-modal').addEventListener('click', e => {
+      if (e.target === document.getElementById('share-modal'))
+        document.getElementById('share-modal').classList.remove('open');
+    });
+
+    document.getElementById('share-generate-btn').addEventListener('click', async () => {
+      const apiKey     = document.getElementById('share-key-input').value.trim();
+      const passphrase = document.getElementById('share-pass-input').value.trim();
+      if (!apiKey)     { alert('Enter the API key to encrypt.'); return; }
+      if (!passphrase) { alert('Enter an access code / passphrase.'); return; }
+      const btn = document.getElementById('share-generate-btn');
+      btn.disabled = true; btn.textContent = 'Encrypting…';
+      try {
+        const blob = await encryptApiKey(apiKey, passphrase);
+        const url  = `${location.origin}${location.pathname}#k=${blob}`;
+        document.getElementById('share-url-display').textContent = url;
+        document.getElementById('share-result').style.display = '';
+      } catch (err) {
+        alert(`Encryption failed: ${err.message}`);
+      } finally {
+        btn.disabled = false; btn.textContent = 'Generate';
+      }
+    });
+
+    document.getElementById('share-copy-btn').addEventListener('click', () => {
+      const url = document.getElementById('share-url-display').textContent;
+      navigator.clipboard.writeText(url).then(() => {
+        const btn = document.getElementById('share-copy-btn');
+        btn.textContent = '✓ Copied!';
+        setTimeout(() => btn.textContent = 'Copy URL', 2000);
+      });
+    });
+  }
 }
 
 function checkBeginReady() {
   document.getElementById('begin-btn').disabled = !(selectedRace && selectedClass);
 }
 
-function beginAdventure() {
-  const apiKey = document.getElementById('api-key-input').value.trim();
-  if (!apiKey) { alert('Please enter your Anthropic API key.'); return; }
-  localStorage.setItem('dm_api_key', apiKey);
+async function beginAdventure() {
+  let apiKey;
+  const sharedBlob = getSharedBlob();
+
+  if (sharedBlob) {
+    const passphrase = document.getElementById('access-code-input').value.trim();
+    if (!passphrase) { alert('Please enter your access code.'); return; }
+    const btn = document.getElementById('begin-btn');
+    btn.disabled = true; btn.textContent = 'Unlocking…';
+    try {
+      apiKey = await decryptApiKey(sharedBlob, passphrase);
+    } catch {
+      alert('Invalid access code. Please check and try again.');
+      btn.disabled = false; btn.textContent = 'Begin Adventure';
+      return;
+    }
+    btn.textContent = 'Begin Adventure';
+  } 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);
+  }
 
   const name = document.getElementById('char-name').value.trim() || `The ${selectedClass.name}`;
   const theme = document.getElementById('adventure-theme').value.trim();