diff --git a/src/config.js b/src/config.js index 64da87a..13de3e7 100644 --- a/src/config.js +++ b/src/config.js @@ -9,4 +9,15 @@ export const TLD = ".bch"; export const BEACON_DUST_SAT = 600; export const NAME_RE = /^[a-z0-9-]{1,32}$/; export const WALLET_FILE = new URL("../wallets.json", import.meta.url); + +// Cross-repo fall-through: Deviant's encrypted keystore, sibling of this repo +// under D:/Dev/. If loadWallet(name) can't find `name` in WALLET_FILE, it looks +// here next. Encrypted with the same master password the user set via +// scripts/chipnet-keystore-encrypt.mjs in the Deviant repo; unlock via env +// DEVIANT_KEYSTORE_PASSWORD (== CHIPNET_KEYSTORE_PASSWORD) or interactive +// prompt. Override with env DEVIANT_KEYSTORE_PATH if the repos aren't siblings. +export const DEVIANT_KEYSTORE_PATH = + process.env.DEVIANT_KEYSTORE_PATH || + new URL("../../../Deviant/Keys/chipnet-keystore.json", import.meta.url); + export const APP_PORT = 4626; diff --git a/src/lib/wallet.js b/src/lib/wallet.js index 4dfbef2..165e6dd 100644 --- a/src/lib/wallet.js +++ b/src/lib/wallet.js @@ -1,10 +1,137 @@ import "./dns-patch.js"; // must load before mainnet-js touches the network import { TestNetWallet } from "mainnet-js"; -import { readFileSync } from "fs"; -import { WALLET_FILE } from "../config.js"; +import { readFileSync, existsSync } from "fs"; +import { pbkdf2Sync, createDecipheriv } from "node:crypto"; +import { createInterface } from "node:readline"; +import { WALLET_FILE, DEVIANT_KEYSTORE_PATH } from "../config.js"; + +// In-process cache of the decrypted Deviant keystore. Populated on first +// fall-through lookup, reused afterwards so the master-password prompt happens +// at most once per script run. +let _deviantCache = null; + +async function promptMasterPassword(label) { + if (process.env.DEVIANT_KEYSTORE_PASSWORD) return process.env.DEVIANT_KEYSTORE_PASSWORD; + if (process.env.CHIPNET_KEYSTORE_PASSWORD) return process.env.CHIPNET_KEYSTORE_PASSWORD; + const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true }); + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write(label); + process.stdout.write = (chunk, ...args) => { + if (typeof chunk === "string" && chunk.includes("\n")) return originalWrite("\n", ...args); + return true; + }; + return new Promise((resolve) => rl.question("", (answer) => { + process.stdout.write = originalWrite; + rl.close(); + process.stdout.write("\n"); + resolve(answer); + })); +} + +function decryptDeviantKeystore(encrypted, password) { + if (encrypted.spec !== "chipnet-keystore/2-encrypted") return encrypted; // already plaintext + const salt = Buffer.from(encrypted.kdf.salt, "hex"); + const iv = Buffer.from(encrypted.encryption.iv, "hex"); + const cipherAll = Buffer.from(encrypted.ciphertext, "hex"); + const ct = cipherAll.subarray(0, cipherAll.length - 16); + const tag = cipherAll.subarray(cipherAll.length - 16); + const key = pbkdf2Sync(password, salt, encrypted.kdf.iterations, 32, "sha256"); + const decipher = createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + const pt = Buffer.concat([decipher.update(ct), decipher.final()]); + return JSON.parse(pt.toString("utf8")); +} + +async function loadDeviantKeystore() { + if (_deviantCache) return _deviantCache; + const p = DEVIANT_KEYSTORE_PATH; + if (!existsSync(p)) return null; + const parsed = JSON.parse(readFileSync(p, "utf8")); + if (parsed.spec === "chipnet-keystore/2-encrypted") { + const pw = await promptMasterPassword("🔒 Deviant keystore master password: "); + try { + _deviantCache = decryptDeviantKeystore(parsed, pw); + } catch (e) { + throw new Error(`Deviant keystore unlock failed: ${e.message}`); + } + } else { + _deviantCache = parsed; + } + return _deviantCache; +} + +// SilentMode uses "sirius.x" as an alias; the Deviant keystore stores it as +// "sirius-x" (dots collide with the covenant-address separator in cashaddr +// tooling). Try both spellings + a few common variants so callers don't have +// to memorise which flavour a given entry uses. +function slugCandidates(name) { + const set = new Set(); + set.add(name); + set.add(name.replace(/\./g, "-")); + set.add(name.replace(/-/g, ".")); + set.add(name.toLowerCase()); + // "main" in Argus/SilentMode = "i" in the Deviant keystore (same seed). + if (name === "main") set.add("i"); + // "beacon" (SilentMode) ↔ "bns-beacon" (Deviant) + if (name === "beacon") set.add("bns-beacon"); + if (name === "tld-beacon") set.add("bns-tld-beacon"); + return [...set]; +} export async function loadWallet(which = "main") { - const store = JSON.parse(readFileSync(WALLET_FILE, "utf8")); - const { seed, path } = store[which]; - return TestNetWallet.fromSeed(seed, path); + // Fast path: local SilentMode wallets.json. + if (existsSync(WALLET_FILE)) { + const store = JSON.parse(readFileSync(WALLET_FILE, "utf8")); + if (store[which]?.seed) { + const { seed, path } = store[which]; + return TestNetWallet.fromSeed(seed, path); + } + } + // Fall-through: Deviant's cross-repo encrypted keystore. + const dv = await loadDeviantKeystore(); + if (dv?.wallets) { + for (const slug of slugCandidates(which)) { + const entry = dv.wallets[slug]; + if (!entry) continue; + if (entry.seed) return TestNetWallet.fromSeed(entry.seed, entry.path ?? "m/44'/145'/0'/0/0"); + if (entry.wif) return TestNetWallet.fromWIF(entry.wif); + } + } + throw new Error( + `loadWallet('${which}') — not found in ${WALLET_FILE} nor in the Deviant keystore ` + + `(tried aliases: ${slugCandidates(which).join(", ")}). Check the master password ` + + `(env DEVIANT_KEYSTORE_PASSWORD) and DEVIANT_KEYSTORE_PATH.` + ); +} + +// Explicit unlock hook for scripts that want to prompt for the master +// password at boot instead of lazily on first fall-through. +export async function unlockDeviantKeystore() { + return loadDeviantKeystore(); +} + +// Enumerate every alias reachable via loadWallet (SilentMode + Deviant merged). +// Never returns seeds or WIFs — only slug + cashaddr + source. +export async function listWallets() { + const out = []; + if (existsSync(WALLET_FILE)) { + const store = JSON.parse(readFileSync(WALLET_FILE, "utf8")); + for (const [slug, w] of Object.entries(store)) { + out.push({ slug, source: "silentmode", path: w.path ?? null }); + } + } + const dv = await loadDeviantKeystore(); + if (dv?.wallets) { + for (const [slug, w] of Object.entries(dv.wallets)) { + out.push({ + slug, + source: "deviant", + cashaddr: w.cashaddr, + label: w.label ?? null, + category: w.category ?? null, + kind: w.seed ? "hd" : "wif", + }); + } + } + return out; }