deviant/scripts/lib/chipnet-keystore.mjs
Local Dev a623aae040 feat(keystore): master-password encryption for Keys/chipnet-keystore.json
Adds AES-256-GCM + PBKDF2-SHA256 (200,000 iterations) encryption for the
consolidated chipnet keystore. Loader auto-detects the encrypted format
(`chipnet-keystore/2-encrypted`) and prompts for the master password on
first use — or reads CHIPNET_KEYSTORE_PASSWORD env for scripted/CI runs.
Password cached in-process only.

- scripts/chipnet-keystore-encrypt.mjs — one-shot encrypt in place;
  backs up plaintext to Keys/chipnet-keystore.plain.bak (also gitignored)
- scripts/chipnet-keystore-decrypt.mjs — --check, --in-place, --stdout,
  --out=<path> modes for verification and one-time exports (bulk import
  into a wallet UI, migration, key rotation)
- scripts/lib/chipnet-keystore.mjs — adds loadAllAsync(),
  getEntryAsync(), ensureKeystoreUnlocked(); sync API still works on
  plain keystores and throws a clear "call *Async instead" on encrypted
  ones until unlocked once at boot

Test coverage: round-trip encrypt+verify+load-async validated end-to-end;
plain-keystore path still loads all 12 wallets unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-01 23:58:47 +02:00

218 lines
8.6 KiB
JavaScript

/**
* chipnet-keystore.mjs — single-source-of-truth loader for all chipnet dev wallets.
*
* Prefers the consolidated keystore at `Keys/chipnet-keystore.json` (one file,
* all wallets, gitignored). Falls back to the legacy per-file layout at repo
* root (`.chipnet-keys.json`, `.chipnet-keys-<slug>.json`) for scripts that
* bypass this loader — so nothing breaks during the transition.
*
* Every returned entry has at least: { slug, cashaddr, hasKey }.
* Key-holding entries additionally have: { wif, tokenaddr, source }.
* A watch-only entry has `watchOnly: true` and no wif.
*
* Also merges watch-only cashaddrs from `.chipnet-watch-addresses.json` — those
* come through as `hasKey: false, watchOnly: true` for balance/scan purposes.
*
* Encrypted keystore support: if the on-disk file has `spec: "chipnet-keystore/2-encrypted"`,
* the loader prompts for the master password (or reads CHIPNET_KEYSTORE_PASSWORD env)
* and decrypts once per process. See scripts/chipnet-keystore-encrypt.mjs to migrate
* a plain keystore into the encrypted format.
*/
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { pbkdf2Sync, createDecipheriv } from 'node:crypto';
import { createInterface } from 'node:readline';
const REPO = join(import.meta.dirname, '..', '..');
const KEYSTORE_PATH = join(REPO, 'Keys', 'chipnet-keystore.json');
const WATCH_PATH = join(REPO, '.chipnet-watch-addresses.json');
let _cachedDecrypted = null; // per-process cache; cleared only on exit
async function promptPasswordSync(label) {
// Return the env var if set; otherwise prompt synchronously via readline.
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 await new Promise((resolve) => rl.question('', (answer) => {
process.stdout.write = originalWrite;
rl.close();
process.stdout.write('\n');
resolve(answer);
}));
}
function decryptKeystore(encrypted, password) {
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 readKeystoreFile() {
if (_cachedDecrypted) return _cachedDecrypted;
if (!existsSync(KEYSTORE_PATH)) return null;
const parsed = JSON.parse(readFileSync(KEYSTORE_PATH, 'utf8'));
if (parsed.spec !== 'chipnet-keystore/2-encrypted') { _cachedDecrypted = parsed; return parsed; }
// Encrypted — prompt for password (or read env), then cache.
const pw = await promptPasswordSync('🔒 master password to unlock keystore: ');
try {
const plain = decryptKeystore(parsed, pw);
_cachedDecrypted = plain;
return plain;
} catch (e) {
throw new Error(`keystore unlock failed: wrong password or tampered file (${e.message})`);
}
}
function loadFromKeystore() {
if (!_cachedDecrypted && existsSync(KEYSTORE_PATH)) {
// Sync-fast path for the common case (plain keystore) — no async needed.
const parsed = JSON.parse(readFileSync(KEYSTORE_PATH, 'utf8'));
if (parsed.spec !== 'chipnet-keystore/2-encrypted') { _cachedDecrypted = parsed; }
else {
// Encrypted keystore requires the async path — signal that to the caller.
// Callers already going through async loadAllAsync() / getEntryAsync() handle this.
throw new Error('keystore is encrypted — call loadAllAsync() / getEntryAsync() instead, or run scripts/chipnet-keystore-decrypt.mjs --in-place first');
}
}
if (!_cachedDecrypted) return null;
const out = {};
for (const [slug, w] of Object.entries(_cachedDecrypted.wallets ?? {})) {
if (w.watchOnly) {
out[slug] = { slug, cashaddr: w.cashaddr, hasKey: false, watchOnly: true, source: 'keystore:' + slug };
} else {
out[slug] = {
slug,
cashaddr: w.cashaddr,
tokenaddr: w.tokenaddr,
wif: w.wif,
seed: w.seed,
path: w.path,
hasKey: Boolean(w.wif || w.seed),
source: 'keystore:' + slug,
};
}
}
return out;
}
function loadFromLegacyFiles() {
const out = {};
for (const f of readdirSync(REPO)) {
const m = f.match(/^\.chipnet-keys(?:-(.+))?\.json$/);
if (!m) continue;
const slug = m[1] ?? 'a';
try {
const j = JSON.parse(readFileSync(join(REPO, f), 'utf8'));
if (!j.wif && !j.seed) continue;
out[slug] = {
slug,
cashaddr: j.cashaddr,
tokenaddr: j.tokenaddr,
wif: j.wif,
seed: j.seed,
path: j.path,
hasKey: true,
source: 'legacy:' + f,
};
} catch { /* skip corrupt file */ }
}
return out;
}
function loadWatchOnly() {
if (!existsSync(WATCH_PATH)) return {};
try {
const w = JSON.parse(readFileSync(WATCH_PATH, 'utf8'));
const out = {};
for (const a of w.addresses ?? []) {
out[a.slug] = { slug: a.slug, cashaddr: a.cashaddr, hasKey: false, watchOnly: true, source: 'watch-file' };
}
return out;
} catch { return {}; }
}
/**
* Return every wallet the repo knows about, keyed by slug.
* Precedence when the same slug appears in multiple sources:
* consolidated keystore > legacy per-file > watch-only file.
*
* Note: if the keystore file is encrypted (`chipnet-keystore/2-encrypted`) and
* hasn't been unlocked yet in this process, this throws with a clear "call
* loadAllAsync() instead" message. Every script that might see an encrypted
* keystore should either use loadAllAsync() OR call ensureKeystoreUnlocked()
* once at boot before using the sync form.
*/
export function loadAll() {
const merged = {};
const watch = loadWatchOnly();
for (const [slug, w] of Object.entries(watch)) merged[slug] = w;
const legacy = loadFromLegacyFiles();
for (const [slug, w] of Object.entries(legacy)) merged[slug] = w;
const keystore = loadFromKeystore();
if (keystore) for (const [slug, w] of Object.entries(keystore)) merged[slug] = w;
return merged;
}
/**
* Same as loadAll() but async — safe with an encrypted keystore. If the keystore
* is encrypted and hasn't been unlocked yet, prompts for the master password
* (or reads CHIPNET_KEYSTORE_PASSWORD env), decrypts once, caches in-process.
*/
export async function loadAllAsync() {
await readKeystoreFile(); // unlocks if encrypted, caches result
return loadAll(); // now sync path succeeds against the cache
}
/** Trigger the unlock (prompt/env read) once at boot so subsequent sync calls work. */
export async function ensureKeystoreUnlocked() { await readKeystoreFile(); }
/**
* Return the entry for a single slug, or throw.
* getEntry('a') → { slug: 'a', wif, cashaddr, tokenaddr, hasKey: true, source }
*/
export function getEntry(slug) {
const all = loadAll();
const e = all[slug];
if (!e) throw new Error(`unknown wallet slug '${slug}' — known: ${Object.keys(all).sort().join(', ')}`);
return e;
}
/** Async variant — unlocks encrypted keystore on first call. */
export async function getEntryAsync(slug) {
await ensureKeystoreUnlocked();
return getEntry(slug);
}
/** Slugs that have signing material (wif or seed). */
export function signableSlugs() {
return Object.entries(loadAll()).filter(([, w]) => w.hasKey).map(([s]) => s).sort();
}
/** All slugs including watch-only. */
export function allSlugs() {
return Object.keys(loadAll()).sort();
}
/**
* Instantiate a mainnet-js TestNetWallet from an entry (WIF preferred; seed as fallback).
* Async because mainnet-js .fromWIF / .fromSeed are async.
*/
export async function walletFromEntry(entry) {
const { TestNetWallet } = await import('mainnet-js');
if (entry.wif) return TestNetWallet.fromWIF(entry.wif);
if (entry.seed) return TestNetWallet.fromSeed(entry.seed, entry.path ?? "m/44'/1'/0'/0/0");
throw new Error(`entry '${entry.slug}' has no wif or seed — cannot sign`);
}