OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-wallet-lib / encryption.cjs

168 lines · javascript ref:
/**
 * FILE: lib/encryption.cjs
 * STATUS: ACTIVE
 * RUNTIME: Bare
 * MODULE: CommonJS
 *
 * PURPOSE:
 * Password-based encryption for mnemonics at rest
 * Uses PBKDF2 for key derivation and AES-256-GCM for encryption
 *
 * USED BY:
 * - wallet-server.cjs (mnemonic encryption/decryption)
 * - hyper-wallet-advanced-cli.cjs (mnemonic encryption/decryption)
 *
 * KEY EXPORTS:
 * - encrypt(plaintext, password) - Encrypt with password
 * - decrypt(encrypted, password) - Decrypt with password
 *
 * SECURITY:
 * - PBKDF2-SHA256 for key derivation (100k iterations, bare-crypto compatible)
 * - AES-256-GCM authenticated encryption
 * - Random salt and IV per encryption
 * - Auth tag prevents tampering
 *
 * NOTE: Using PBKDF2 instead of scrypt due to bare-crypto limitations
 */

const crypto = require('bare-crypto')
const b4a = require('b4a')

// PBKDF2 parameters (bare-crypto compatible)
const PBKDF2_ITERATIONS = 100000  // NIST recommended minimum
const PBKDF2_KEYLEN = 32          // 256-bit key
const PBKDF2_DIGEST = 'sha256'    // Hash algorithm

// AES-GCM parameters
const IV_LENGTH = 12    // 96-bit IV (recommended for GCM)
const SALT_LENGTH = 32  // 256-bit salt
const TAG_LENGTH = 16   // 128-bit auth tag

/**
 * Derive encryption key from password using PBKDF2
 * (bare-crypto compatible - scrypt not available)
 *
 * @param {string} password - User password
 * @param {Buffer} salt - Random salt
 * @returns {Buffer} - 256-bit derived key
 */
function deriveKey(password, salt) {
  // Derive key using PBKDF2
  // Note: This is CPU-intensive (intentional security feature)
  const key = crypto.pbkdf2Sync(
    password,
    salt,
    PBKDF2_ITERATIONS,
    PBKDF2_KEYLEN,
    PBKDF2_DIGEST
  )

  return key
}

/**
 * Encrypt plaintext with password
 *
 * @param {string} plaintext - Text to encrypt (e.g., mnemonic)
 * @param {string} password - Encryption password
 * @returns {string} - Base64-encoded encrypted data with salt and IV
 *
 * Format: base64(salt + iv + ciphertext + tag)
 */
function encrypt(plaintext, password) {
  // Generate random salt and IV
  const salt = crypto.randomBytes(SALT_LENGTH)
  const iv = crypto.randomBytes(IV_LENGTH)

  // Derive encryption key
  const key = deriveKey(password, salt)

  // Create cipher
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)

  // Encrypt plaintext
  const plaintextBuffer = b4a.from(plaintext, 'utf8')
  const encrypted = b4a.concat([
    cipher.update(plaintextBuffer),
    cipher.final()
  ])

  // Get authentication tag
  const tag = cipher.getAuthTag()

  // Concatenate: salt + iv + ciphertext + tag
  const result = b4a.concat([salt, iv, encrypted, tag])

  // Return as base64
  return b4a.toString(result, 'base64')
}

/**
 * Decrypt encrypted data with password
 *
 * @param {string} encrypted - Base64-encoded encrypted data
 * @param {string} password - Decryption password
 * @returns {string} - Decrypted plaintext
 * @throws {Error} - If password is wrong or data is corrupted
 */
function decrypt(encrypted, password) {
  // Decode from base64
  const data = b4a.from(encrypted, 'base64')

  // Extract components
  const salt = data.slice(0, SALT_LENGTH)
  const iv = data.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH)
  const ciphertext = data.slice(SALT_LENGTH + IV_LENGTH, data.length - TAG_LENGTH)
  const tag = data.slice(data.length - TAG_LENGTH)

  // Derive decryption key
  const key = deriveKey(password, salt)

  // Create decipher
  const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
  decipher.setAuthTag(tag)

  try {
    // Decrypt ciphertext
    const decrypted = b4a.concat([
      decipher.update(ciphertext),
      decipher.final()
    ])

    return b4a.toString(decrypted, 'utf8')
  } catch (err) {
    // Auth tag verification failed or wrong password
    throw new Error('Decryption failed - wrong password or corrupted data')
  }
}

/**
 * Check if encrypted data format is valid
 *
 * @param {string} encrypted - Base64-encoded encrypted data
 * @returns {boolean} - True if format is valid
 */
function isValidFormat(encrypted) {
  try {
    const data = b4a.from(encrypted, 'base64')
    const minLength = SALT_LENGTH + IV_LENGTH + TAG_LENGTH
    return data.length >= minLength
  } catch {
    return false
  }
}

module.exports = {
  encrypt,
  decrypt,
  isValidFormat,

  // Export constants for testing
  PBKDF2_ITERATIONS,
  PBKDF2_KEYLEN,
  PBKDF2_DIGEST,
  IV_LENGTH,
  SALT_LENGTH,
  TAG_LENGTH
}