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>
100 lines
4.3 KiB
JavaScript
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();
|
|
}
|