OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / identity / secure-storage.js

134 lines · javascript ref:
/**
 * 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