/** * keystore.js * * Handles the ONE thing that must never go wrong: turning a 12/24-word * recovery phrase into an encrypted file on disk, and back again. * * Design choices (why): * - We never persist the mnemonic in plaintext, anywhere, ever. * - Key derivation from password uses scrypt (memory-hard, resists GPU * brute-force better than PBKDF2 at equivalent settings). * - Encryption is AES-256-GCM, which gives us both confidentiality and * integrity (tamper detection) in one primitive. * - Every encryption uses a fresh random salt + IV, stored alongside the * ciphertext (this is safe — salts/IVs are not secret). */ const crypto = require('crypto'); const fs = require('fs'); const bip39 = require('bip39'); const SCRYPT_PARAMS = { N: 2 ** 17, r: 8, p: 1 }; // ~0.5-1s on modern hardware const KEY_LEN = 32; // AES-256 /** Generate a brand-new 24-word mnemonic (256 bits of entropy). */ function generateMnemonic() { return bip39.generateMnemonic(256); } function validateMnemonic(mnemonic) { return bip39.validateMnemonic(mnemonic.trim()); } /** * Encrypt a mnemonic with a user password. * Returns a JSON-serializable object safe to write to disk. */ function encryptMnemonic(mnemonic, password) { const salt = crypto.randomBytes(16); const iv = crypto.randomBytes(12); // GCM standard IV length const key = crypto.scryptSync(password, salt, KEY_LEN, SCRYPT_PARAMS); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); const ciphertext = Buffer.concat([ cipher.update(mnemonic, 'utf8'), cipher.final(), ]); const authTag = cipher.getAuthTag(); return { version: 1, kdf: 'scrypt', kdfParams: { ...SCRYPT_PARAMS, salt: salt.toString('hex') }, cipher: 'aes-256-gcm', iv: iv.toString('hex'), ciphertext: ciphertext.toString('hex'), authTag: authTag.toString('hex'), }; } /** * Decrypt a keystore object with the user's password. * Throws if the password is wrong (auth tag check fails) — this is * intentional; GCM detects tampering/incorrect keys and we don't want to * silently return garbage. */ function decryptMnemonic(keystoreObj, password) { const { kdfParams, iv, ciphertext, authTag } = keystoreObj; const salt = Buffer.from(kdfParams.salt, 'hex'); const key = crypto.scryptSync(password, salt, KEY_LEN, { N: kdfParams.N, r: kdfParams.r, p: kdfParams.p, }); const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'hex')); decipher.setAuthTag(Buffer.from(authTag, 'hex')); const plaintext = Buffer.concat([ decipher.update(Buffer.from(ciphertext, 'hex')), decipher.final(), // throws "Unsupported state or unable to authenticate data" on wrong password ]); return plaintext.toString('utf8'); } function saveKeystore(path, keystoreObj) { fs.writeFileSync(path, JSON.stringify(keystoreObj, null, 2), { mode: 0o600 }); } function loadKeystore(path) { return JSON.parse(fs.readFileSync(path, 'utf8')); } module.exports = { generateMnemonic, validateMnemonic, encryptMnemonic, decryptMnemonic, saveKeystore, loadKeystore, };