Skip to content

Commit f0a45db

Browse files
Melvin Carvalhoclaude
andcommitted
Add keys.html — generate a testnet4 wallet (private/hardened BIP32)
The one page that touches private keys (testnet4, throwaway). crypto.getRandomValues seed → BIP84 m/84'/1'/0' account → shows seed (secret), account tpub (for spv.html), and receiving addresses. wasm-keygen.js: private/hardened BIP32 via the bundled WASM secp (pointFromScalar/privateAdd), isolated from the verify path; verified against BIP32 spec vector 1 (test-keygen.mjs). The emitted tpub derives the same addresses in spv.html — generate → watch → fund loop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4aa9742 commit f0a45db

3 files changed

Lines changed: 174 additions & 0 deletions

File tree

‎keys.html‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>bitcoin-kernel/browser-node — keys (generate a testnet4 wallet)</title>
7+
<style>
8+
:root { --bg:#fff; --fg:#16181d; --mut:#5b6470; --bd:#e6e8eb; --pan:#fafbfc; --ac:#e8830c; --ac2:#0969da; --good:#1a7f37; --bad:#cf222e; --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; color-scheme:light; }
9+
body { background:var(--bg); color:var(--fg); font:14px/1.55 var(--mono); max-width:780px; margin:24px auto; padding:0 16px 60px; }
10+
h1 { font-size:18px; } a { color:var(--ac2); }
11+
button { background:var(--pan); color:var(--fg); border:1px solid var(--bd); border-radius:6px; padding:9px 15px; cursor:pointer; font:inherit; }
12+
button:hover { background:#f0f2f4; border-color:var(--ac); } button:disabled { opacity:.45; cursor:default; }
13+
.warn-box { border:1px solid var(--ac); background:#fff7ed; border-radius:8px; padding:10px 13px; margin:14px 0; }
14+
.field { margin:14px 0; }
15+
.field .k { color:var(--mut); font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
16+
.field .v { word-break:break-all; border:1px solid var(--bd); border-radius:6px; padding:8px 9px; background:var(--pan); margin-top:4px; user-select:all; }
17+
.secret .v { border-color:var(--bad); background:#fff5f5; }
18+
table { border-collapse:collapse; width:100%; margin-top:6px; }
19+
td, th { border:1px solid var(--bd); padding:6px 9px; text-align:left; } td.addr { word-break:break-all; }
20+
th { background:var(--pan); color:var(--mut); font-weight:700; font-size:12px; }
21+
.ok{color:var(--good)} .err{color:var(--bad)} .dim{color:var(--mut)}
22+
code { background:var(--pan); border:1px solid var(--bd); border-radius:4px; padding:1px 5px; font-size:.92em; }
23+
</style>
24+
</head>
25+
<body>
26+
<h1>bitcoin-kernel / browser-node — keys <span class="dim">(generate a testnet4 wallet)</span></h1>
27+
<p class="dim">Generate a fresh <b>testnet4</b> wallet in your browser. This is the one page in this project that touches
28+
<b>private keys</b> — everything else is watch-only/verification. The seed is created locally (<code>crypto.getRandomValues</code>)
29+
and never leaves this tab. Take the <b>account tpub</b> to the <a href="spv.html">watch-only wallet</a>, and fund a receiving
30+
address from a testnet4 faucet. <a href="how-it-works.html">how it works</a></p>
31+
32+
<div class="warn-box dim">
33+
⚠ <b>Testnet4 only — throwaway.</b> Do not use for real funds. The seed below is your wallet; anyone who sees it controls it.
34+
This is a demo: keys live only in this tab and vanish on reload (write the seed down if you want to keep the wallet).</div>
35+
36+
<p><button id="gen">▶ Generate a new wallet</button> <span id="status" class="dim"></span></p>
37+
38+
<div id="out"></div>
39+
40+
<p class="dim" style="margin-top:18px; border-top:1px solid var(--bd); padding-top:10px">
41+
Path: BIP84 <code>m/84'/1'/0'</code> (testnet account), receiving chain <code>0/i</code>, P2WPKH. Private/hardened BIP32 keygen
42+
uses the bundled WASM secp (<code>pointFromScalar</code> / <code>privateAdd</code>), verified against BIP32 spec vector 1
43+
(<code>test-keygen.mjs</code>). Next: a BIP39 mnemonic for the backup, and signing/spending.</p>
44+
45+
<script type="module">
46+
import { deriveAccountNode } from './wasm-keygen.js';
47+
import { Bip32 } from './engine/codec/wallet.js';
48+
import { ScriptEngine } from './engine/codec/script.js';
49+
50+
const $ = (id) => document.getElementById(id);
51+
let se = null;
52+
53+
async function loadEngine() {
54+
const jl = (n) => fetch(`./engine/schema/${n}.jsonld`).then(r => r.json());
55+
const [script, chain] = await Promise.all([jl('script'), jl('chain')]);
56+
se = ScriptEngine.fromSchemas(script, chain, 'btc:testnet4');
57+
}
58+
59+
function generate() {
60+
const seed = crypto.getRandomValues(new Uint8Array(32));
61+
const seedHex = Array.from(seed, (b) => b.toString(16).padStart(2, '0')).join('');
62+
const node = deriveAccountNode(seed, { coin: 1, version: 0x043587cf }); // testnet tpub
63+
const tpub = Bip32.encode(node);
64+
let rows = '';
65+
for (let i = 0; i < 5; i++) {
66+
const addr = se.classify(Bip32.scriptPubKey(Bip32.derivePath(node, `0/${i}`), 'p2wpkh')).address;
67+
rows += `<tr><td>0/${i}</td><td class="addr">${addr}</td></tr>`;
68+
}
69+
$('out').innerHTML =
70+
`<div class="field secret"><div class="k">seed — secret, back this up</div><div class="v">${seedHex}</div></div>` +
71+
`<div class="field"><div class="k">account tpub — paste into the watch-only wallet (spv.html)</div><div class="v">${tpub}</div></div>` +
72+
`<div class="field"><div class="k">receiving addresses — fund one from a testnet4 faucet</div>` +
73+
`<table><tr><th style="width:70px">path</th><th>address (P2WPKH)</th></tr>${rows}</table></div>`;
74+
$('status').innerHTML = '<span class="ok">✓ wallet generated (in this tab only)</span>';
75+
}
76+
77+
$('gen').onclick = generate;
78+
await loadEngine();
79+
$('status').textContent = 'engine ready — press Generate';
80+
</script>
81+
</body>
82+
</html>

‎test-keygen.mjs‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Prove private/hardened BIP32 keygen using the bundled WASM secp (pointFromScalar
2+
// + privateAdd, which the engine ships but doesn't wire up). Derive the BIP84
3+
// account from the KNOWN test-vector seed and check the first addresses equal the
4+
// published BIP84 vector — the crypto keys.html will use to make a tpub.
5+
import fs from 'node:fs';
6+
import { readFile } from 'node:fs/promises';
7+
import { Bip32 } from './engine/codec/wallet.js';
8+
import { ScriptEngine } from './engine/codec/script.js';
9+
import { hmacSha512, hash160, bytesToHex, hexToBytes } from './engine/codec/hash.js';
10+
11+
const D = new URL('./', import.meta.url);
12+
const jl = async (n) => JSON.parse(await readFile(new URL(`engine/schema/${n}.jsonld`, D), 'utf8'));
13+
14+
// --- wire the WASM secp's keygen ops (mirrors wasm-secp.js's memory marshalling) ---
15+
const wbuf = fs.readFileSync(new URL('secp256k1.wasm', D));
16+
const generateInt32 = () => { const a = new Uint8Array(4); crypto.getRandomValues(a); return (a[0] << 24) | (a[1] << 16) | (a[2] << 8) | a[3]; };
17+
const throwError = (c) => { throw new Error('secp wasm error ' + c); };
18+
const { instance } = await WebAssembly.instantiate(wbuf, { './rand.js': { generateInt32 }, './validate_error.js': { throwError } });
19+
const w = instance.exports; w.initializeContext();
20+
const PRIV = w.PRIVATE_INPUT.value, PUB = w.PUBLIC_KEY_INPUT.value, TWEAK = w.TWEAK_INPUT.value;
21+
const mem = () => new Uint8Array(w.memory.buffer);
22+
function pointFromScalar(d) { const m = mem(); m.set(d, PRIV); const ok = w.pointFromScalar(33) === 1; const out = ok ? m.slice(PUB, PUB + 33) : null; m.fill(0, PRIV, PRIV + 32); return out; }
23+
function privateAdd(d, t) { const m = mem(); m.set(d, PRIV); m.set(t, TWEAK); const ok = w.privateAdd() === 1; const out = ok ? m.slice(PRIV, PRIV + 32) : null; m.fill(0, PRIV, PRIV + 32); m.fill(0, TWEAK, TWEAK + 32); return out; }
24+
25+
// --- private BIP32 ---
26+
const ser32 = (i) => Uint8Array.from([(i >>> 24) & 255, (i >>> 16) & 255, (i >>> 8) & 255, i & 255]);
27+
function master(seed) { const I = hmacSha512(new TextEncoder().encode('Bitcoin seed'), seed); const priv = I.slice(0, 32); return { priv, chainCode: I.slice(32), pub: pointFromScalar(priv), depth: 0, childNumber: 0, parentFingerprint: '00000000' }; }
28+
function ckdPriv(p, index) {
29+
const hardened = index >= 0x80000000;
30+
const data = hardened ? Uint8Array.from([0, ...p.priv, ...ser32(index)]) : Uint8Array.from([...p.pub, ...ser32(index)]);
31+
const I = hmacSha512(p.chainCode, data);
32+
const childPriv = privateAdd(p.priv, I.slice(0, 32));
33+
return { priv: childPriv, chainCode: I.slice(32), pub: pointFromScalar(childPriv), depth: p.depth + 1, childNumber: index, parentFingerprint: bytesToHex(hash160(p.pub).subarray(0, 4)) };
34+
}
35+
const H = (i) => i + 0x80000000;
36+
37+
// --- the proof: BIP32 spec test vector 1 (seed 000102…0f) → master + m/0' xpubs ---
38+
const xpubOf = (n) => Bip32.encode({ version: 0x0488b21e, depth: n.depth, parentFingerprint: n.parentFingerprint, childNumber: n.childNumber, chainCode: bytesToHex(n.chainCode), publicKey: bytesToHex(n.pub) });
39+
const m = master(hexToBytes('000102030405060708090a0b0c0d0e0f'));
40+
const m0h = ckdPriv(m, H(0));
41+
const checks = [
42+
['m', xpubOf(m), 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8'],
43+
["m/0'", xpubOf(m0h), 'xpub68Gmy5EdvgibQVfPdqkBBCHxA5htiqg55crXYuXoQRKfDBFA1WEjWgP6LHhwBZeNK1VTsfTFUHCdrfp1bgwQ9xv5ski8PX9rL2dZXvgGDnw'],
44+
];
45+
let ok = true;
46+
for (const [label, got, want] of checks) { const pass = got === want; ok &&= pass; console.log(` ${label.padEnd(5)} ${pass ? '✓' : '✗\n got ' + got + '\n want ' + want}`); }
47+
48+
// and show the keys.html flow: a testnet BIP84 account (m/84'/1'/0') → tpub → first tb1 address
49+
const se = ScriptEngine.fromSchemas(await jl('script'), await jl('chain'), 'btc:testnet4');
50+
let acc = m; for (const i of [H(84), H(1), H(0)]) acc = ckdPriv(acc, i);
51+
const tpub = Bip32.encode({ version: 0x043587cf, depth: acc.depth, parentFingerprint: acc.parentFingerprint, childNumber: acc.childNumber, chainCode: bytesToHex(acc.chainCode), publicKey: bytesToHex(acc.pub) });
52+
console.log(`\n demo: m/84'/1'/0' tpub ${tpub.slice(0, 14)}… → 0/0 ${se.classify(Bip32.scriptPubKey(Bip32.derivePath(Bip32.decode(tpub), '0/0'), 'p2wpkh')).address}`);
53+
54+
console.log(ok ? '\n✅ private BIP32 keygen (WASM pointFromScalar/privateAdd) matches BIP32 vector 1 — keys.html can generate tpubs + addresses' : '\n❌ mismatch');
55+
process.exit(ok ? 0 : 1);

‎wasm-keygen.js‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Keygen for keys.html: PRIVATE/hardened BIP32 derivation, using the bundled WASM
2+
// secp's pointFromScalar + privateAdd (which the engine ships but only wires for
3+
// verify). Isolated here so the verification paths (wasm-secp.js, the worker) are
4+
// untouched. THIS MODULE TOUCHES PRIVATE KEYS — testnet4 demo wallets only.
5+
// Verified against BIP32 spec vector 1 in test-keygen.mjs.
6+
import { hmacSha512, hash160, bytesToHex } from './engine/codec/hash.js';
7+
8+
const wasmUrl = new URL('./secp256k1.wasm', import.meta.url);
9+
const generateInt32 = () => { const a = new Uint8Array(4); crypto.getRandomValues(a); return (a[0] << 24) | (a[1] << 16) | (a[2] << 8) | a[3]; };
10+
const throwError = (code) => { throw new Error('secp256k1 wasm error ' + code); };
11+
const { instance } = await WebAssembly.instantiate(await (await fetch(wasmUrl)).arrayBuffer(), { './rand.js': { generateInt32 }, './validate_error.js': { throwError } });
12+
const w = instance.exports; w.initializeContext();
13+
const PRIV = w.PRIVATE_INPUT.value, PUB = w.PUBLIC_KEY_INPUT.value, TWEAK = w.TWEAK_INPUT.value;
14+
const mem = () => new Uint8Array(w.memory.buffer);
15+
16+
export function pointFromScalar(d) { const m = mem(); m.set(d, PRIV); const ok = w.pointFromScalar(33) === 1; const out = ok ? m.slice(PUB, PUB + 33) : null; m.fill(0, PRIV, PRIV + 32); return out; }
17+
export function privateAdd(d, t) { const m = mem(); m.set(d, PRIV); m.set(t, TWEAK); const ok = w.privateAdd() === 1; const out = ok ? m.slice(PRIV, PRIV + 32) : null; m.fill(0, PRIV, PRIV + 32); m.fill(0, TWEAK, TWEAK + 32); return out; }
18+
19+
const ser32 = (i) => Uint8Array.from([(i >>> 24) & 255, (i >>> 16) & 255, (i >>> 8) & 255, i & 255]);
20+
const H = (i) => i + 0x80000000;
21+
22+
function master(seed) { const I = hmacSha512(new TextEncoder().encode('Bitcoin seed'), seed); const priv = I.slice(0, 32); return { priv, chainCode: I.slice(32), pub: pointFromScalar(priv), depth: 0, childNumber: 0, parentFingerprint: '00000000' }; }
23+
function ckdPriv(p, index) {
24+
const hardened = index >= 0x80000000;
25+
const data = hardened ? Uint8Array.from([0, ...p.priv, ...ser32(index)]) : Uint8Array.from([...p.pub, ...ser32(index)]);
26+
const I = hmacSha512(p.chainCode, data);
27+
const childPriv = privateAdd(p.priv, I.slice(0, 32));
28+
return { priv: childPriv, chainCode: I.slice(32), pub: pointFromScalar(childPriv), depth: p.depth + 1, childNumber: index, parentFingerprint: bytesToHex(hash160(p.pub).subarray(0, 4)) };
29+
}
30+
31+
// Derive a BIP84 account node m/84'/coin'/0' from a seed, as a watch-only node
32+
// (no private key) ready for Bip32.encode() → tpub/xpub. coin 1 = testnet.
33+
export function deriveAccountNode(seed, { coin = 1, version = 0x043587cf } = {}) {
34+
let n = master(seed);
35+
for (const i of [H(84), H(coin), H(0)]) n = ckdPriv(n, i);
36+
return { version, depth: n.depth, parentFingerprint: n.parentFingerprint, childNumber: n.childNumber, chainCode: bytesToHex(n.chainCode), publicKey: bytesToHex(n.pub) };
37+
}

0 commit comments

Comments
 (0)