/** * 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(`
No email. No password reset. Your wallet is your identity.
Prefer old-school? Sign in with username & password โ
Write it down. This is the only way to recover your account โ we can never help you reset it.
The passphrase never leaves your device. Lose both phrase and passphrase = account is gone.
Paste your BIP-39 recovery phrase (12 or 24 words). Same phrase = same account.
Sign the challenge with an external BCH wallet โ your keys never leave it. Encrypted end-to-end over Nostr; the relay only sees ciphertext.
Coming soon. WizardConnect for message-signing is being wired in โ for now, use Create or Import above (or the button below to jump back to the picker).
Enter the passphrase you set when you created this wallet in this browser.
Wrong browser? Forget this wallet and start over.