Repository Public
1import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';2import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';3import { dirname } from 'node:path';4import { entropyToMnemonic, mnemonicToEntropy } from '@scure/bip39';5import { wordlist } from '@scure/bip39/wordlists/english.js';67interface KeystoreFile { v: 1; kdf: 'scrypt'; N: number; r: number; p: number; salt: string; iv: string; ct: string; tag: string }8const KDF = { N: 1 << 17, r: 8, p: 1 };910function kek(wrap: Buffer, salt: Buffer, k = KDF): Buffer {11 return scryptSync(wrap, salt, 32, { N: k.N, r: k.r, p: k.p, maxmem: 512 * 1024 * 1024 });12}1314function writePrivate(path: string, data: string | Buffer): void {15 mkdirSync(dirname(path), { recursive: true, mode: 0o700 });16 writeFileSync(path, data, { mode: 0o600, flag: 'wx' }); // never overwrite existing key material17 chmodSync(path, 0o600);18}1920/** Generate fresh 256-bit entropy on this machine and store it encrypted. Never returns or prints the secret. */21export function initKeystore(keystorePath: string, wrapKeyPath: string): void {22 if (existsSync(keystorePath) || existsSync(wrapKeyPath)) throw new Error('keystore or wrap key already exists — refusing to overwrite');23 const wrap = randomBytes(32), salt = randomBytes(16), iv = randomBytes(12);24 const entropy = randomBytes(32);25 const c = createCipheriv('aes-256-gcm', kek(wrap, salt), iv);26 const ct = Buffer.concat([c.update(entropy), c.final()]);27 const file: KeystoreFile = { v: 1, kdf: 'scrypt', ...KDF, salt: salt.toString('hex'), iv: iv.toString('hex'), ct: ct.toString('hex'), tag: c.getAuthTag().toString('hex') };28 writePrivate(wrapKeyPath, wrap.toString('hex') + '\n');29 writePrivate(keystorePath, JSON.stringify(file) + '\n');30 entropy.fill(0); wrap.fill(0);31}3233/** Decrypt and return the mnemonic. Callers must keep it in memory only. */34export function loadMnemonic(keystorePath: string, wrapKeyPath: string): string {35 const f = JSON.parse(readFileSync(keystorePath, 'utf8')) as KeystoreFile;36 if (f.v !== 1 || f.kdf !== 'scrypt') throw new Error('unsupported keystore');37 const wrap = Buffer.from(readFileSync(wrapKeyPath, 'utf8').trim(), 'hex');38 if (wrap.length !== 32) throw new Error('bad wrap key');39 const d = createDecipheriv('aes-256-gcm', kek(wrap, Buffer.from(f.salt, 'hex'), f), Buffer.from(f.iv, 'hex'));40 d.setAuthTag(Buffer.from(f.tag, 'hex'));41 const entropy = Buffer.concat([d.update(Buffer.from(f.ct, 'hex')), d.final()]);42 const m = entropyToMnemonic(entropy, wordlist);43 entropy.fill(0);44 return m;45}4647export const mnemonicIsValid = (m: string) => { try { mnemonicToEntropy(m, wordlist); return true; } catch { return false; } };48