hephaestus/auth-proxy/public/wallet.js
2026-09-12 00:07:28 +02:00

353 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">
<details class="wallet-opt" data-choice="create">
<summary>
<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>
<span class="chev" aria-hidden="true">▾</span>
</summary>
<div class="wallet-flow"></div>
</details>
<details class="wallet-opt" data-choice="import">
<summary>
<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>
<span class="chev" aria-hidden="true">▾</span>
</summary>
<div class="wallet-flow"></div>
</details>
<details class="wallet-opt" data-choice="connect">
<summary>
<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>
<span class="chev" aria-hidden="true">▾</span>
</summary>
<div class="wallet-flow"></div>
</details>
</div>
<p class="fine">Prefer old-school? <a href="/user/login?password=1">Sign in with username &amp; password →</a></p>
</div>
`));
app.querySelectorAll("details.wallet-opt").forEach((d) => {
d.addEventListener("toggle", () => {
if (!d.open) return;
// Accordion: close siblings
app.querySelectorAll("details.wallet-opt").forEach((other) => {
if (other !== d) other.open = false;
});
// Lazily populate this option's flow content the first time it opens
const flow = d.querySelector(".wallet-flow");
if (flow && !flow.dataset.ready) {
populateFlow(d.dataset.choice, flow);
flow.dataset.ready = "1";
}
});
});
// Auto-open a specific option when the URL fragment is one of #create / #import / #connect
// (e.g. links from the hephaestus.x landing dropdown). Fragments survive OAuth redirects.
const hash = (location.hash || "").replace(/^#/, "").toLowerCase();
if (hash === "create" || hash === "import" || hash === "connect") {
const target = app.querySelector(`details.wallet-opt[data-choice="${hash}"]`);
if (target) target.open = true;
}
}
function populateFlow(kind, flow) {
if (kind === "create") {
const mnemonic = generateBip39Mnemonic();
flow.appendChild(el(`
<div class="flow">
<p class="sub"><b>Your new recovery phrase.</b> 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" class="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>
<button class="primary go">Create wallet &amp; sign in</button>
<p class="warn hidden err"></p>
</div>
`));
flow.querySelector(".go").addEventListener("click", async () => {
const pw = flow.querySelector(".pw").value;
if (pw.length < 8) return showErr(flow, "passphrase must be at least 8 chars");
await onboardAndLogin(mnemonic, pw, flow);
});
} else if (kind === "import") {
flow.appendChild(el(`
<div class="flow">
<p class="sub">Paste your BIP-39 recovery phrase (12 or 24 words). Same phrase = same account.</p>
<textarea class="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" class="pw" autocomplete="new-password" placeholder="min 8 chars">
<button class="primary go">Import &amp; sign in</button>
<p class="warn hidden err"></p>
</div>
`));
flow.querySelector(".go").addEventListener("click", async () => {
const mn = flow.querySelector(".mn").value.trim().replace(/\s+/g, " ");
const pw = flow.querySelector(".pw").value;
if (pw.length < 8) return showErr(flow, "passphrase must be at least 8 chars");
try { await keypairFromMnemonic(mn); }
catch { return showErr(flow, "invalid recovery phrase"); }
await onboardAndLogin(mn, pw, flow);
});
} else if (kind === "connect") {
flow.appendChild(el(`
<div class="flow">
<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.</p>
</div>
`));
}
}
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();