feat(argus): loadWallet cross-repo fall-through to Deviant encrypted keystore

Extend Argus/src/lib/wallet.js so loadWallet(name) transparently reads from
D:/Dev/Deviant/Keys/chipnet-keystore.json when the alias isn't in the local
SilentMode wallets.json. Same master password (env DEVIANT_KEYSTORE_PASSWORD
or CHIPNET_KEYSTORE_PASSWORD, or interactive prompt) unlocks the Deviant
keystore once per process; decrypted state cached in memory.

- Adds DEVIANT_KEYSTORE_PATH in config.js (defaults to sibling repo, env
  override for non-sibling layouts).
- loadWallet() tries local wallets.json first (fast path), then falls through
  to the Deviant keystore. Existing "main" behaviour unchanged.
- Slug normalisation: sirius.x ↔ sirius-x, main ↔ i, beacon ↔ bns-beacon,
  tld-beacon ↔ bns-tld-beacon — so scripts using either flavour resolve to
  the same key.
- New unlockDeviantKeystore() for eager-unlock at script boot.
- New listWallets() returns a merged inventory of slug/cashaddr/label/
  category/source. Never returns seeds or WIFs.

Verified against a live Deviant keystore with 15 entries: loadWallet('main')
resolves locally; loadWallet('sirius.x') falls through and produces the same
address as reading D:/Dev/SilentMode/Argus/wallets.json directly.
This commit is contained in:
Local Dev 2026-09-09 02:16:42 +02:00
parent 2d80e68ef8
commit 2847e98d83
2 changed files with 143 additions and 5 deletions

View file

@ -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;

View file

@ -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;
}