deviant/scripts/chipnet-keystore-decrypt.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

100 lines
4.3 KiB
JavaScript

#!/usr/bin/env node
/**
* chipnet-keystore-decrypt.mjs — utility to decrypt Keys/chipnet-keystore.json.
*
* Modes:
* node scripts/chipnet-keystore-decrypt.mjs --check # verify password, no writes
* node scripts/chipnet-keystore-decrypt.mjs --in-place # decrypt Keys/chipnet-keystore.json back to plain
* node scripts/chipnet-keystore-decrypt.mjs --stdout # write plaintext to stdout (for pipes / bulk import)
* node scripts/chipnet-keystore-decrypt.mjs --out=<path> # write plaintext to a chosen path
*
* Reads CHIPNET_KEYSTORE_PASSWORD env or prompts interactively.
*/
import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs';
import { join } from 'node:path';
import { pbkdf2Sync, createDecipheriv } from 'node:crypto';
import { createInterface } from 'node:readline';
const ROOT = join(import.meta.dirname, '..');
const KEYSTORE = join(ROOT, 'Keys', 'chipnet-keystore.json');
async function promptPassword(label) {
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);
}));
}
export function decryptKeystoreObj(encrypted, password) {
if (encrypted.spec !== 'chipnet-keystore/2-encrypted') throw new Error(`not encrypted (spec=${encrypted.spec})`);
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 main() {
const args = new Set(process.argv.slice(2));
const outArg = process.argv.find((a) => a.startsWith('--out='));
const check = args.has('--check');
const inPlace = args.has('--in-place');
const stdout = args.has('--stdout');
const outPath = outArg?.slice('--out='.length);
const modes = [check, inPlace, stdout, Boolean(outPath)].filter(Boolean).length;
if (modes === 0) { console.error('pick one: --check | --in-place | --stdout | --out=<path>'); process.exit(1); }
if (modes > 1) { console.error('choose exactly one mode'); process.exit(1); }
if (!existsSync(KEYSTORE)) { console.error(`✗ ${KEYSTORE} not found`); process.exit(1); }
const encrypted = JSON.parse(readFileSync(KEYSTORE, 'utf8'));
const pw = await promptPassword('master password: ');
let plain;
try {
plain = decryptKeystoreObj(encrypted, pw);
} catch (e) {
console.error(`✗ decryption failed (wrong password or tampered file): ${e.message}`);
process.exit(1);
}
const walletCount = Object.keys(plain.wallets ?? {}).length;
if (check) {
console.log(`✓ password OK · ${walletCount} wallets in the keystore`);
return;
}
if (stdout) { process.stdout.write(JSON.stringify(plain, null, 2) + '\n'); return; }
if (outPath) {
writeFileSync(outPath, JSON.stringify(plain, null, 2) + '\n');
console.log(`✓ decrypted to ${outPath} · ${walletCount} wallets`);
console.log(` security note: this file contains plaintext WIFs. Delete after use.`);
return;
}
if (inPlace) {
const bak = KEYSTORE + '.enc.bak';
copyFileSync(KEYSTORE, bak);
writeFileSync(KEYSTORE, JSON.stringify(plain, null, 2) + '\n');
console.log(`✓ keystore decrypted in place · ${walletCount} wallets`);
console.log(` encrypted backup: ${bak}`);
}
}
// Only run main if invoked directly, not when imported
if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}` || import.meta.url === `file:///${process.argv[1]?.replace(/\\/g, '/')}`) {
await main();
}