SPB Git

Repository Public

keystore.ts 2.7 KB 48 lines typescript

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
Files main
Menu

Theme