/**
* Secure Storage — Encrypted mnemonic vault
*
* Argon2id key derivation + XSalsa20-Poly1305 encryption.
* Zero-dependency on pear-git internals — uses sodium-native only.
*/
import fs from 'bare-fs'
import sodium from 'sodium-native'
const VAULT_VERSION = 1
const SALT_BYTES = 16
const NONCE_BYTES = sodium.crypto_secretbox_NONCEBYTES // 24
const KEY_BYTES = sodium.crypto_secretbox_KEYBYTES // 32
const MAC_BYTES = sodium.crypto_secretbox_MACBYTES // 16
// Argon2id parameters (MODERATE — ~0.7s on modern hardware)
const OPSLIMIT = sodium.crypto_pwhash_OPSLIMIT_MODERATE
const MEMLIMIT = sodium.crypto_pwhash_MEMLIMIT_MODERATE
const ALG = sodium.crypto_pwhash_ALG_ARGON2ID13
export class SecureStorage {
constructor (filePath) {
this.filePath = filePath
}
/**
* Encrypt plaintext with password and write to disk
* @param {Buffer} plaintext - Data to encrypt
* @param {Buffer} password - Password buffer (from secure-prompt or Buffer.from)
*/
async encrypt (plaintext, password) {
const salt = Buffer.alloc(SALT_BYTES)
sodium.randombytes_buf(salt)
const key = Buffer.alloc(KEY_BYTES)
sodium.crypto_pwhash(key, password, salt, OPSLIMIT, MEMLIMIT, ALG)
const nonce = Buffer.alloc(NONCE_BYTES)
sodium.randombytes_buf(nonce)
const ciphertext = Buffer.alloc(plaintext.length + MAC_BYTES)
sodium.crypto_secretbox_easy(ciphertext, plaintext, nonce, key)
// Zero derived key immediately
sodium.sodium_memzero(key)
const vault = {
version: VAULT_VERSION,
salt: salt.toString('hex'),
nonce: nonce.toString('hex'),
ciphertext: ciphertext.toString('hex'),
opslimit: OPSLIMIT,
memlimit: MEMLIMIT
}
await fs.promises.writeFile(this.filePath, JSON.stringify(vault, null, 2), {
encoding: 'utf-8',
mode: 0o600
})
}
/**
* Decrypt vault with password
* @param {Buffer} password - Password buffer
* @returns {Buffer} Decrypted plaintext
*/
async decrypt (password) {
const data = await fs.promises.readFile(this.filePath, 'utf-8')
const vault = JSON.parse(data)
if (vault.version !== VAULT_VERSION) {
throw new Error(`Unsupported vault version: ${vault.version}`)
}
const salt = Buffer.from(vault.salt, 'hex')
const nonce = Buffer.from(vault.nonce, 'hex')
const ciphertext = Buffer.from(vault.ciphertext, 'hex')
const opslimit = vault.opslimit || OPSLIMIT
const memlimit = vault.memlimit || MEMLIMIT
const key = Buffer.alloc(KEY_BYTES)
sodium.crypto_pwhash(key, password, salt, opslimit, memlimit, ALG)
const plaintext = Buffer.alloc(ciphertext.length - MAC_BYTES)
let ok = false
try {
ok = sodium.crypto_secretbox_open_easy(plaintext, ciphertext, nonce, key)
} catch {
ok = false
}
sodium.sodium_memzero(key)
if (ok === false) {
sodium.sodium_memzero(plaintext)
throw new Error('Decryption failed — wrong password or corrupted vault')
}
return plaintext
}
/**
* Check if vault file exists
* @returns {boolean}
*/
exists () {
try {
fs.statSync(this.filePath)
return true
} catch {
return false
}
}
/**
* Change vault password — decrypt with old, re-encrypt with new
* @param {Buffer} oldPassword
* @param {Buffer} newPassword
*/
async changePassword (oldPassword, newPassword) {
const plaintext = await this.decrypt(oldPassword)
try {
await this.encrypt(plaintext, newPassword)
} finally {
sodium.sodium_memzero(plaintext)
}
}
}
export default SecureStorage