dm / commit
fix: support share link generation on plain HTTP
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; }