argus/src/lib/wallet-web.js

231 lines
8.3 KiB
JavaScript
Raw Normal View History

// The built-in, in-browser, self-custody wallet for the registration flow.
//
// This exists so a newcomer with no BCH wallet at all can still buy a name.
// The seed phrase is generated in the buyer's browser, encrypted with their
// password (PBKDF2 -> AES-GCM via WebCrypto) and kept in localStorage. It is
// never transmitted anywhere. There is no reset: whoever holds the phrase holds
// the names. The UI must say so before the phrase is generated.
//
// Runs in the browser and in Node 18+ (both provide WebCrypto + TextEncoder).
import {
generateBip39Mnemonic,
deriveSeedFromBip39Mnemonic,
deriveHdPrivateNodeFromSeed,
deriveHdPath,
secp256k1,
hash160,
encodeCashAddress,
lockingBytecodeToCashAddress,
cashAddressToLockingBytecode,
binToHex,
hexToBin,
utf8ToBin,
binToBase64,
base64ToBin,
} from "@bitauth/libauth";
export const CHIPNET_PREFIX = "bchtest";
export const MAINNET_PREFIX = "bitcoincash";
// BCH coin type 145; account 0. Branch 0 = receive, 1 = change.
export const ACCOUNT_PATH = "m/44'/145'/0'";
// How many receiving addresses to watch. The flow funds and changes back to
// index 0, so this window only matters for coins sent in from elsewhere.
export const ADDRESS_WINDOW = 5;
const STORAGE_KEY = "bns.wallet.v1";
function p2pkhLockingBytecode(publicKey) {
const pkh = hash160(publicKey);
return Uint8Array.from([0x76, 0xa9, 0x14, ...pkh, 0x88, 0xac]);
}
function encodeAddress(publicKey, prefix, tokenSupport) {
const r = lockingBytecodeToCashAddress({
bytecode: p2pkhLockingBytecode(publicKey),
prefix,
tokenSupport,
});
if (typeof r === "string") throw new Error(`address encoding failed: ${r}`);
return r.address;
}
export class BuiltInWallet {
/**
* @param {string} mnemonic
* @param {Uint8Array} seed
* @param {string} prefix
* @param {string} accountPath BIP44 account path (default: BCH `m/44'/145'/0'`).
* Override with e.g. `m/44'/0'/0'` for operator wallets that were derived
* on the BTC coin type, so BuiltInWallet can drive keys minted from the
* CLI/mainnet-js path too.
*/
constructor(mnemonic, seed, prefix = CHIPNET_PREFIX, accountPath = ACCOUNT_PATH) {
this.mnemonic = mnemonic;
this.prefix = prefix;
this.accountPath = accountPath;
this._root = deriveHdPrivateNodeFromSeed(seed);
this._keys = new Map(); // lockingBytecodeHex -> {privateKey, publicKey, path}
this._derive();
}
static async create(prefix = CHIPNET_PREFIX, accountPath = ACCOUNT_PATH) {
const mnemonic = generateBip39Mnemonic();
return new BuiltInWallet(mnemonic, await deriveSeedFromBip39Mnemonic(mnemonic), prefix, accountPath);
}
static async fromMnemonic(mnemonic, prefix = CHIPNET_PREFIX, accountPath = ACCOUNT_PATH) {
const clean = String(mnemonic).trim().toLowerCase().replace(/\s+/g, " ");
const seed = await deriveSeedFromBip39Mnemonic(clean);
if (typeof seed === "string") throw new Error(`invalid recovery phrase: ${seed}`);
return new BuiltInWallet(clean, seed, prefix, accountPath);
}
_derive() {
this.receive = [];
this.change = [];
for (const [branch, list] of [[0, this.receive], [1, this.change]]) {
for (let i = 0; i < ADDRESS_WINDOW; i++) {
const path = `${this.accountPath}/${branch}/${i}`;
const node = deriveHdPath(this._root, path);
if (typeof node === "string") throw new Error(`HD derivation failed at ${path}: ${node}`);
const publicKey = secp256k1.derivePublicKeyCompressed(node.privateKey);
if (typeof publicKey === "string") throw new Error(`pubkey derivation failed: ${publicKey}`);
const lockingBytecode = p2pkhLockingBytecode(publicKey);
const entry = {
path,
branch,
index: i,
privateKey: node.privateKey,
publicKey,
lockingBytecode,
address: encodeAddress(publicKey, this.prefix, false),
tokenAddress: encodeAddress(publicKey, this.prefix, true),
};
list.push(entry);
this._keys.set(binToHex(lockingBytecode), entry);
}
}
}
/** The address the buyer funds and receives change on. */
get address() { return this.receive[0].address; }
/** Token-aware form — where the name certificate is minted to. */
get tokenAddress() { return this.receive[0].tokenAddress; }
/** Every address worth scanning for coins. */
get watchedAddresses() {
return [...this.receive, ...this.change].map((e) => e.address);
}
/** Signer callback for sign-tx.js. */
keyFor(lockingBytecodeHex) {
const e = this._keys.get(lockingBytecodeHex);
return e ? { privateKey: e.privateKey, publicKey: e.publicKey } : null;
}
/**
* Sign a 32-byte digest with the key for `address` (default: the wallet's
* primary receive address) and return a 65-byte BCH message signature:
* [recovery flag, r|s]
* flag = 27 + recovery + 4 (compressed pubkey — standard)
*
* Used by the signed-records-manifest flow: the caller sha256s the
* canonical manifest bytes, hands the digest here, gets back a signature
* the gateway's POST endpoint verifies against the on-chain NFT owner.
* See DESIGN-signed-records-manifest.md.
*/
signMessage(digest, address) {
if (!(digest instanceof Uint8Array) || digest.length !== 32) {
throw new Error("signMessage expects a 32-byte Uint8Array digest");
}
const addr = address || this.address;
const entry = [...this.receive, ...this.change].find((k) => k.address === addr);
if (!entry) throw new Error(`no key for address ${addr}`);
const result = secp256k1.signMessageHashRecoverableCompact(entry.privateKey, digest);
if (typeof result === "string") throw new Error(`signMessage failed: ${result}`);
const { signature } = result;
// libauth names the field recoveryId; older builds used recovery. A wrong
// read here silently produced flag byte 0 and half of all signatures
// failed verification.
const recovery = result.recoveryId ?? result.recovery;
if (typeof recovery !== "number") throw new Error("signMessage: no recovery id in signature result");
const flag = 27 + 4 + recovery; // 4 = "public key was compressed"
const bytes = new Uint8Array(65);
bytes[0] = flag;
bytes.set(signature, 1);
return bytes;
}
// ---------- encrypted storage ----------
async encrypt(password) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(password, salt);
const ct = new Uint8Array(
await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, utf8ToBin(this.mnemonic)),
);
return JSON.stringify({
v: 1,
kdf: "PBKDF2-SHA256",
iterations: PBKDF2_ITERATIONS,
prefix: this.prefix,
salt: binToBase64(salt),
iv: binToBase64(iv),
ct: binToBase64(ct),
});
}
static async decrypt(blob, password) {
const o = typeof blob === "string" ? JSON.parse(blob) : blob;
const key = await deriveKey(password, base64ToBin(o.salt), o.iterations ?? PBKDF2_ITERATIONS);
let plain;
try {
plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToBin(o.iv) },
key,
base64ToBin(o.ct),
);
} catch {
throw new Error("wrong password");
}
return BuiltInWallet.fromMnemonic(new TextDecoder().decode(plain), o.prefix ?? CHIPNET_PREFIX);
}
async save(password, storage = globalThis.localStorage) {
storage.setItem(STORAGE_KEY, await this.encrypt(password));
}
static async load(password, storage = globalThis.localStorage) {
const blob = storage.getItem(STORAGE_KEY);
if (!blob) return null;
return BuiltInWallet.decrypt(blob, password);
}
static exists(storage = globalThis.localStorage) {
return !!storage?.getItem(STORAGE_KEY);
}
static forget(storage = globalThis.localStorage) {
storage.removeItem(STORAGE_KEY);
}
}
const PBKDF2_ITERATIONS = 250_000;
async function deriveKey(password, salt, iterations = PBKDF2_ITERATIONS) {
const material = await crypto.subtle.importKey("raw", utf8ToBin(String(password)), "PBKDF2", false, [
"deriveKey",
]);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations, hash: "SHA-256" },
material,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
export { encodeCashAddress, cashAddressToLockingBytecode, binToHex, hexToBin };