OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / lib / secure-storage.js

127 lines · javascript ref:
import sodium from 'sodium-native'
import b4a from 'b4a'

/**
 * SecureStorage — Encrypted vault for sensitive data
 *
 * Uses Argon2id for key derivation and XSalsa20-Poly1305 for encryption.
 * Stores encrypted data in a Hyperbee or similar key-value store.
 */
export class SecureStorage {
  constructor (store) {
    this._store = store // Hyperbee or similar key-value store
  }

  /**
   * Derive encryption key from password using Argon2id
   * @param {string} password - User password
   * @param {Buffer} salt - 16-byte salt
   * @returns {Buffer} 32-byte key
   */
  static deriveKey (password, salt) {
    const key = b4a.alloc(32)
    const pw = b4a.from(password)
    sodium.crypto_pwhash(
      key,
      pw,
      salt,
      sodium.crypto_pwhash_OPSLIMIT_MODERATE,
      sodium.crypto_pwhash_MEMLIMIT_MODERATE,
      sodium.crypto_pwhash_ALG_ARGON2ID13
    )
    return key
  }

  /**
   * Encrypt data with key
   * @param {Buffer} data - Data to encrypt
   * @param {Buffer} key - 32-byte encryption key
   * @returns {{ nonce: Buffer, ciphertext: Buffer }}
   */
  static encrypt (data, key) {
    const nonce = b4a.alloc(24)
    sodium.randombytes_buf(nonce)
    const ciphertext = b4a.alloc(data.length + sodium.crypto_secretbox_MACBYTES)
    sodium.crypto_secretbox_easy(ciphertext, data, nonce, key)
    return { nonce, ciphertext }
  }

  /**
   * Decrypt data with key
   * @param {Buffer} ciphertext - Encrypted data
   * @param {Buffer} nonce - 24-byte nonce
   * @param {Buffer} key - 32-byte decryption key
   * @returns {Buffer} Decrypted data
   */
  static decrypt (ciphertext, nonce, key) {
    const plaintext = b4a.alloc(ciphertext.length - sodium.crypto_secretbox_MACBYTES)
    const ok = sodium.crypto_secretbox_open_easy(plaintext, ciphertext, nonce, key)
    if (!ok) throw new Error('Decryption failed')
    return plaintext
  }

  /**
   * Store encrypted value
   * @param {string} name - Vault entry name
   * @param {string} value - Value to encrypt
   * @param {string} password - Encryption password
   */
  async put (name, value, password) {
    const salt = b4a.alloc(16)
    sodium.randombytes_buf(salt)
    const key = SecureStorage.deriveKey(password, salt)
    const { nonce, ciphertext } = SecureStorage.encrypt(b4a.from(value), key)

    await this._store.put(`vault:${name}`, {
      salt: b4a.toString(salt, 'hex'),
      nonce: b4a.toString(nonce, 'hex'),
      ciphertext: b4a.toString(ciphertext, 'hex')
    })
  }

  /**
   * Retrieve and decrypt value
   * @param {string} name - Vault entry name
   * @param {string} password - Decryption password
   * @returns {string|null} Decrypted value or null
   */
  async get (name, password) {
    const entry = await this._store.get(`vault:${name}`)
    if (!entry) return null

    const { salt, nonce, ciphertext } = entry.value
    const key = SecureStorage.deriveKey(password, b4a.from(salt, 'hex'))

    try {
      const plaintext = SecureStorage.decrypt(
        b4a.from(ciphertext, 'hex'),
        b4a.from(nonce, 'hex'),
        key
      )
      return b4a.toString(plaintext)
    } catch {
      return null
    }
  }

  /**
   * Check if vault entry exists
   * @param {string} name - Vault entry name
   * @returns {boolean}
   */
  async has (name) {
    const entry = await this._store.get(`vault:${name}`)
    return entry !== null
  }

  /**
   * Delete vault entry
   * @param {string} name - Vault entry name
   */
  async del (name) {
    await this._store.del(`vault:${name}`)
  }
}

export default SecureStorage