353 lines
13 KiB
JavaScript
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 & 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 & 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 & 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();
|