102 lines
3.1 KiB
JavaScript
102 lines
3.1 KiB
JavaScript
/**
|
|
* 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,
|
|
};
|