/** * Client-side BCH wallet UI. * * Uses @bitauth/libauth ESM build from a CDN. Wallets are generated with * BIP-39 (12 words) → BIP-32 (m/44'/145'/0'/0/0) → secp256k1 keypair. * Signatures follow "Bitcoin Signed Message" format so any BCH tool can verify. * * Storage: encrypted mnemonic in localStorage under 'hephaestus.wallet.v1'. * Encryption: AES-GCM with PBKDF2(SHA-256, 200k) from a user passphrase. * Passphrase never leaves the browser. */ import { deriveHdPath, deriveHdPrivateNodeFromSeed, deriveSeedFromBip39Mnemonic, encodeCashAddress, generateBip39Mnemonic, hash160, hash256, secp256k1, utf8ToBin, binToBase64, binToHex, CashAddressType, } from "https://esm.sh/@bitauth/libauth@3.1.0-next.4"; const app = document.getElementById("app"); const state = app.dataset.state ?? ""; const redirectUri = app.dataset.redirectUri; const STORAGE_KEY = "hephaestus.wallet.v1"; /* ---------- BCH wallet primitives ---------- */ const DERIVATION_PATH = "m/44'/145'/0'/0/0"; function encodeVarInt(n) { if (n < 0xfd) return new Uint8Array([n]); if (n <= 0xffff) return new Uint8Array([0xfd, n & 0xff, (n >> 8) & 0xff]); return new Uint8Array([ 0xfe, n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff, ]); } function magicHash(message) { const magic = utf8ToBin("Bitcoin Signed Message:\n"); const msg = utf8ToBin(message); const parts = [encodeVarInt(magic.length), magic, encodeVarInt(msg.length), msg]; const total = parts.reduce((n, p) => n + p.length, 0); const buf = new Uint8Array(total); let off = 0; for (const p of parts) { buf.set(p, off); off += p.length; } return hash256(buf); } async function keypairFromMnemonic(mnemonic) { const seed = deriveSeedFromBip39Mnemonic(mnemonic); if (typeof seed === "string") throw new Error(seed); const root = deriveHdPrivateNodeFromSeed(seed); const child = deriveHdPath(root, DERIVATION_PATH); if (typeof child === "string") throw new Error(child); const privateKey = child.privateKey; const publicKey = secp256k1.derivePublicKeyCompressed(privateKey); if (typeof publicKey === "string") throw new Error(publicKey); const pkh = hash160(publicKey); const enc = encodeCashAddress({ prefix: "bitcoincash", type: CashAddressType.p2pkh, payload: pkh, }); const cashaddr = typeof enc === "string" ? enc : enc.address; return { privateKey, publicKey, cashaddr }; } function signMessage(privateKey, message) { const digest = magicHash(message); const compact = secp256k1.signMessageHashRecoverableCompact(privateKey, digest); if (typeof compact === "string") throw new Error(compact); // Header byte: 27 + recid + 4 (compressed pubkey flag). libauth returns // {signature, recoveryId}; adapt to expected shape. const recid = compact.recoveryId; const sig65 = new Uint8Array(65); sig65[0] = 27 + recid + 4; sig65.set(compact.signature, 1); return binToBase64(sig65); } /* ---------- Encrypted storage ---------- */ async function deriveKey(passphrase, salt) { const enc = new TextEncoder(); const material = await crypto.subtle.importKey( "raw", enc.encode(passphrase), "PBKDF2", false, ["deriveKey"], ); return crypto.subtle.deriveKey( { name: "PBKDF2", salt, iterations: 200_000, hash: "SHA-256" }, material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ); } async function encryptMnemonic(mnemonic, passphrase) { const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await deriveKey(passphrase, salt); const ct = new Uint8Array(await crypto.subtle.encrypt( { name: "AES-GCM", iv }, key, new TextEncoder().encode(mnemonic), )); return { v: 1, salt: binToHex(salt), iv: binToHex(iv), ct: binToHex(ct), }; } async function decryptMnemonic(blob, passphrase) { const salt = hexToBin(blob.salt); const iv = hexToBin(blob.iv); const ct = hexToBin(blob.ct); const key = await deriveKey(passphrase, salt); const pt = new Uint8Array(await crypto.subtle.decrypt( { name: "AES-GCM", iv }, key, ct, )); return new TextDecoder().decode(pt); } function hexToBin(h) { const out = new Uint8Array(h.length / 2); for (let i = 0; i < out.length; i++) out[i] = parseInt(h.substr(i * 2, 2), 16); return out; } function hasStoredWallet() { return !!localStorage.getItem(STORAGE_KEY); } function loadStoredWallet() { return JSON.parse(localStorage.getItem(STORAGE_KEY)); } function saveStoredWallet(blob) { localStorage.setItem(STORAGE_KEY, JSON.stringify(blob)); } /* ---------- UI ---------- */ function render() { if (hasStoredWallet()) return renderUnlock(); return renderTabs(); } function el(html) { const t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstElementChild; } function renderTabs() { app.innerHTML = ""; app.appendChild(el(`

Sign in to Hephaestus

No email. No password reset. Your wallet is your identity.

`)); app.querySelectorAll(".tab").forEach((b) => { b.addEventListener("click", () => { app.querySelectorAll(".tab").forEach((x) => x.setAttribute("aria-selected", x === b ? "true" : "false")); renderPanel(b.dataset.tab); }); }); renderPanel("generate"); } function renderPanel(kind) { const panel = document.getElementById("panel"); panel.innerHTML = ""; if (kind === "generate") { const mnemonic = generateBip39Mnemonic(); panel.appendChild(el(`
${mnemonic}

The passphrase never leaves your device. If you lose both the phrase and the passphrase your account is gone — we cannot help.

`)); panel.querySelector("#go").addEventListener("click", async () => { const pw = panel.querySelector("#pw").value; if (pw.length < 8) return showErr(panel, "passphrase must be at least 8 chars"); await onboardAndLogin(mnemonic, pw, panel); }); } else { panel.appendChild(el(`
`)); panel.querySelector("#go").addEventListener("click", async () => { const mn = panel.querySelector("#mn").value.trim().replace(/\s+/g, " "); const pw = panel.querySelector("#pw").value; if (pw.length < 8) return showErr(panel, "passphrase must be at least 8 chars"); try { await keypairFromMnemonic(mn); } catch { return showErr(panel, "invalid recovery phrase"); } await onboardAndLogin(mn, pw, panel); }); } } function renderUnlock() { app.innerHTML = ""; app.appendChild(el(`

Unlock your wallet

Enter the passphrase you set when you created this wallet in this browser.

Wrong browser? Forget this wallet and start over.

`)); app.querySelector("#reset").addEventListener("click", (e) => { e.preventDefault(); localStorage.removeItem(STORAGE_KEY); renderTabs(); }); app.querySelector("#go").addEventListener("click", async () => { const pw = app.querySelector("#pw").value; const blob = loadStoredWallet(); let mnemonic; try { mnemonic = await decryptMnemonic(blob, pw); } catch { return showErr(app, "wrong passphrase"); } await signInWithMnemonic(mnemonic, app); }); } async function onboardAndLogin(mnemonic, passphrase, root) { const btn = root.querySelector("button.primary"); btn.disabled = true; try { const blob = await encryptMnemonic(mnemonic, passphrase); saveStoredWallet(blob); await signInWithMnemonic(mnemonic, root); } catch (e) { btn.disabled = false; showErr(root, e.message ?? String(e)); } } async function signInWithMnemonic(mnemonic, root) { const btn = root.querySelector("button.primary"); if (btn) btn.disabled = true; try { const { privateKey, cashaddr } = await keypairFromMnemonic(mnemonic); const chalRes = await fetch("challenge", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ cashaddr, state, redirect_uri: redirectUri }), }); if (!chalRes.ok) throw new Error("challenge request failed"); const { nonce, message } = await chalRes.json(); const signature = signMessage(privateKey, message); const verRes = await fetch("verify", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ nonce, signature }), }); if (!verRes.ok) throw new Error("signature rejected"); const { redirect } = await verRes.json(); window.location.href = redirect; } catch (e) { if (btn) btn.disabled = false; showErr(root, e.message ?? String(e)); } } function showErr(root, msg) { const box = root.querySelector("#err"); if (!box) return; box.textContent = msg; box.classList.remove("hidden"); } render();