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>
218 lines
8.6 KiB
JavaScript
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`);
|
|
}
|