hephaestus/auth-proxy/public/wallet.js
Local Dev e67943ed00 feat(hephaestus): BCH wallet as default login (3-option picker + Caddy redirect)
- login.css/wallet.js: replace 2-tab (generate/import) UX with 3 big options
  — Create a new wallet [easiest], Import / add a wallet, Connect a wallet —
  WizardConnect [most private] (Cashonize/Paytaca placeholder, "coming soon").
- Caddyfile (both hostnames): /user/login now 302→ /user/oauth2/hephaestus-wallet.
  Escape hatch: /user/login?password=1 falls through to Forgejo's password form,
  linked from the picker footer as "Prefer old-school? Sign in with username & password".
2026-08-31 17:50:49 +02:00

351 lines
13 KiB
JavaScript

/**
* 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(`
<div>
<h1>Sign in with your <span class="acid">Bitcoin Cash</span> wallet</h1>
<p class="lede">No email. No password reset. Your wallet is your identity.</p>
<div class="wallet-picker" id="picker">
<button class="wallet-opt" data-choice="create">
<span class="ico">✨</span>
<span class="body">
<b>Create a new wallet<span class="pill">easiest</span></b>
<span class="sub">Made here in your browser. You get a recovery phrase to write down — it is the only key.</span>
</span>
</button>
<button class="wallet-opt" data-choice="import">
<span class="ico">🔑</span>
<span class="body">
<b>Import / add a wallet</b>
<span class="sub">Restore a wallet you already have from its 12- or 24-word recovery phrase.</span>
</span>
</button>
<button class="wallet-opt" data-choice="connect">
<span class="ico">🪄</span>
<span class="body">
<b>Connect a wallet — WizardConnect<span class="pill">most private</span></b>
<span class="sub">Cashonize 0.9+ or Paytaca. Your keys never leave your wallet.</span>
</span>
</button>
</div>
<p class="fine">Prefer old-school? <a href="/user/login?password=1">Sign in with username &amp; password →</a></p>
<div id="panel"></div>
</div>
`));
app.querySelectorAll(".wallet-opt").forEach((b) => {
b.addEventListener("click", () => {
// Hide the picker while a specific panel is open
app.querySelector("#picker").classList.add("hidden");
app.querySelector(".fine")?.classList.add("hidden");
renderPanel(b.dataset.choice);
});
});
}
function backToPicker() {
app.querySelector("#picker")?.classList.remove("hidden");
app.querySelector(".fine")?.classList.remove("hidden");
const panel = document.getElementById("panel");
if (panel) panel.innerHTML = "";
}
function renderPanel(kind) {
const panel = document.getElementById("panel");
panel.innerHTML = "";
if (kind === "create") {
const mnemonic = generateBip39Mnemonic();
panel.appendChild(el(`
<div class="flow">
<h2>Your new recovery phrase</h2>
<p class="sub">Write it down. This is the only way to recover your account — we can never help you reset it.</p>
<div class="mnemonic-box">${mnemonic}</div>
<label>Set a passphrase to encrypt this wallet in your browser</label>
<input type="password" id="pw" autocomplete="new-password" placeholder="min 8 chars">
<p class="small">The passphrase never leaves your device. Lose both phrase and passphrase = account is gone.</p>
<div class="row">
<button class="primary" id="go">Create wallet &amp; sign in</button>
<button class="ghost" id="back">← Back</button>
</div>
<p class="warn hidden" id="err"></p>
</div>
`));
panel.querySelector("#back").addEventListener("click", backToPicker);
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 if (kind === "import") {
panel.appendChild(el(`
<div class="flow">
<h2>Import a wallet</h2>
<p class="sub">Paste your BIP-39 recovery phrase (12 or 24 words). Same phrase = same account.</p>
<textarea id="mn" spellcheck="false" autocomplete="off" placeholder="word word word ..."></textarea>
<label>Set a passphrase to encrypt this wallet in your browser</label>
<input type="password" id="pw" autocomplete="new-password" placeholder="min 8 chars">
<div class="row">
<button class="primary" id="go">Import &amp; sign in</button>
<button class="ghost" id="back">← Back</button>
</div>
<p class="warn hidden" id="err"></p>
</div>
`));
panel.querySelector("#back").addEventListener("click", backToPicker);
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);
});
} else if (kind === "connect") {
panel.appendChild(el(`
<div class="flow">
<h2>Connect a wallet</h2>
<p class="sub">Sign the challenge with an external BCH wallet — your keys never leave it. Encrypted end-to-end over Nostr; the relay only sees ciphertext.</p>
<div class="wc-supported">
<div class="wc-wallet"><b>Cashonize</b><span>browser extension · v0.9+</span><a href="https://cashonize.com/" target="_blank" rel="noopener">install →</a></div>
<div class="wc-wallet"><b>Paytaca</b><span>mobile · iOS + Android</span><a href="https://paytaca.com/" target="_blank" rel="noopener">install →</a></div>
</div>
<p class="note-soon"><b>Coming soon.</b> 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).</p>
<div class="row">
<button class="ghost" id="back">← Back to picker</button>
</div>
</div>
`));
panel.querySelector("#back").addEventListener("click", backToPicker);
}
}
function renderUnlock() {
app.innerHTML = "";
app.appendChild(el(`
<div>
<h1>Unlock your wallet</h1>
<p class="lede">Enter the passphrase you set when you created this wallet in this browser.</p>
<label>Passphrase</label>
<input type="password" id="pw" autocomplete="current-password">
<button class="primary" id="go">Unlock & sign in</button>
<p class="warn hidden" id="err"></p>
<p class="small" style="margin-top:16px">
Wrong browser? <a href="#" id="reset">Forget this wallet and start over</a>.
</p>
</div>
`));
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();