Files
macu-wallet/src/core/keystore.js
T
2026-08-27 18:27:44 +00:00

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,
};