OverviewFilesCommitsIssuesDocsReleasesPeers

hyperident / lib / crypto.js

181 lines · javascript ref:
/**
 * Crypto Manager - Cryptographic operations for Hyperident
 * Uses bare-crypto for all cryptographic functions
 */

import crypto from 'node:crypto'
import sodium from 'sodium-native'

/**
 * CryptoManager class for encryption, decryption, and key derivation
 */
export class CryptoManager {
  /**
   * Generate a cryptographically secure random salt
   * @returns {Buffer} 32-byte random salt
   */
  static generateSalt() {
    return crypto.randomBytes(32)
  }

  /**
   * Derive encryption key from passphrase and salt
   * @param {string} passphrase - User passphrase
   * @param {Buffer} salt - 32-byte salt
   * @returns {Buffer} 32-byte derived key
   */
  // ── vault secret encryption ────────────────────────────────────────────────────────────────────
  //
  // This replaces a scheme that was sha256(passphrase||salt) + repeating-key XOR, unauthenticated
  // (hyper-social #47). Against a BIP39 mnemonic that was not protection: the plaintext is lowercase
  // words from a PUBLIC 2048-word list, ~150-200 bytes against a 32-byte key, so the keystream repeated
  // five or six times and was recoverable WITHOUT the passphrase. A single SHA-256 made guessing the
  // passphrase cheap regardless.
  //
  // The old deriveKey/encryptData/decryptData are REMOVED rather than deprecated. Leaving callable
  // broken crypto beside the fixed version is how it gets reused.
  //
  // Parameters are returned rather than hardcoded at the call site so a vault records the cost it was
  // written with, and the cost can be raised later without breaking existing vaults.
  //
  // INTERACTIVE (64 MB, 2 ops) not MODERATE (256 MB): the phone is a first-class target — favela runs
  // as a FULL NODE on Android (#36) with a measured storage budget already in the hundreds of MB — and
  // a 256 MB argon2 allocation during unlock is a real risk there. The memlimit is recorded per-vault,
  // so raising it later is a per-vault decision, not a migration.
  static kdfParams () {
    return {
      kdf: 'argon2id',
      alg: sodium.crypto_pwhash_ALG_ARGON2ID13,
      opslimit: sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
      memlimit: sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
      saltBytes: sodium.crypto_pwhash_SALTBYTES
    }
  }

  // Memory-hard KDF. `salt` may be longer than the KDF consumes (generateSalt returns 32 for backward
  // compatibility with existing callers); the first SALTBYTES are used.
  static deriveVaultKey (passphrase, salt, params = CryptoManager.kdfParams()) {
    if (typeof passphrase !== 'string' || passphrase.length === 0) throw new Error('Passphrase must be a non-empty string')
    if (!Buffer.isBuffer(salt) || salt.length < sodium.crypto_pwhash_SALTBYTES) {
      throw new Error('Salt must be a Buffer of at least ' + sodium.crypto_pwhash_SALTBYTES + ' bytes')
    }
    const key = Buffer.alloc(sodium.crypto_secretbox_KEYBYTES)
    sodium.crypto_pwhash(
      key,
      Buffer.from(passphrase, 'utf8'),
      salt.subarray(0, sodium.crypto_pwhash_SALTBYTES),
      params.opslimit, params.memlimit, params.alg
    )
    return key
  }

  // XSalsa20-Poly1305. Returns nonce || ciphertext+MAC, so the box is self-contained and a fresh nonce
  // is used per call — encrypting the same secret twice must not produce identical bytes.
  static encryptSecret (data, passphrase, salt, params = CryptoManager.kdfParams()) {
    if (!Buffer.isBuffer(data)) throw new Error('Data must be a Buffer')
    const key = CryptoManager.deriveVaultKey(passphrase, salt, params)
    const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES)
    sodium.randombytes_buf(nonce)
    const ct = Buffer.alloc(data.length + sodium.crypto_secretbox_MACBYTES)
    sodium.crypto_secretbox_easy(ct, data, nonce, key)
    sodium.sodium_memzero(key)
    return Buffer.concat([nonce, ct])
  }

  // THROWS on a wrong passphrase or tampered bytes. The old scheme could not: an unauthenticated cipher
  // returns a different plaintext for a different key and cannot tell you which happened.
  static decryptSecret (box, passphrase, salt, params = CryptoManager.kdfParams()) {
    if (!Buffer.isBuffer(box) || box.length <= sodium.crypto_secretbox_NONCEBYTES + sodium.crypto_secretbox_MACBYTES) {
      throw new Error('Ciphertext is too short to be a valid vault box')
    }
    const nonce = box.subarray(0, sodium.crypto_secretbox_NONCEBYTES)
    const ct = box.subarray(sodium.crypto_secretbox_NONCEBYTES)
    const key = CryptoManager.deriveVaultKey(passphrase, salt, params)
    const out = Buffer.alloc(ct.length - sodium.crypto_secretbox_MACBYTES)
    const ok = sodium.crypto_secretbox_open_easy(out, ct, nonce, key)
    sodium.sodium_memzero(key)
    if (!ok) throw new Error('vault: wrong passphrase, or the vault is corrupt')
    return out
  }

  static hash(data) {
    if (!Buffer.isBuffer(data)) {
      throw new Error('Data must be a Buffer')
    }
    return crypto.createHash('sha256').update(data).digest()
  }

  /**
   * Generate random bytes
   * @param {number} length - Number of bytes to generate
   * @returns {Buffer} Random bytes
   */
  static randomBytes(length) {
    if (typeof length !== 'number' || length < 0) {
      throw new Error('Length must be a non-negative number')
    }
    return crypto.randomBytes(length)
  }

  /**
   * Generate Ed25519 keypair for device
   * @returns {Object} Object with publicKey and privateKey as Buffers
   */
  static generateDeviceKeyPair() {
    const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
    
    return {
      publicKey: publicKey.export({ type: 'spki', format: 'der' }),
      privateKey: privateKey.export({ type: 'pkcs8', format: 'der' })
    }
  }

  /**
   * Convert DER format keys to raw Ed25519 format (32 bytes)
   * @param {Buffer} derKey - DER formatted key
   * @param {string} type - 'public' or 'private'
   * @returns {Buffer} Raw Ed25519 key (32 bytes)
   */
  static derToRawEd25519(derKey, type) {
    if (!Buffer.isBuffer(derKey)) {
      throw new Error('DER key must be a Buffer')
    }
    if (type !== 'public' && type !== 'private') {
      throw new Error('Type must be "public" or "private"')
    }

    if (type === 'public') {
      // For Ed25519 public keys in SPKI format, the raw key is the last 32 bytes
      return derKey.slice(-32)
    } else {
      // For Ed25519 private keys in PKCS8 format, the raw key is at a specific offset
      // PKCS8 Ed25519 private key structure has the 32-byte key at offset -32
      return derKey.slice(-32)
    }
  }

  /**
   * Generate raw Ed25519 keypair using sodium-universal (compatible with keet-identity-key)
   * @returns {Object} Object with publicKey (32 bytes) and secretKey (64 bytes) as Buffers
   */
  static async generateRawDeviceKeyPair() {
    // Import sodium-universal dynamically to avoid issues if not installed
    let sodium;
    try {
      const sodiumModule = await import('sodium-universal');
      sodium = sodiumModule.default;
    } catch (error) {
      throw new Error('sodium-universal is required for device key generation. Run: npm install sodium-universal');
    }
    
    const publicKey = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES);
    const secretKey = Buffer.alloc(sodium.crypto_sign_SECRETKEYBYTES);
    sodium.crypto_sign_keypair(publicKey, secretKey);
    
    return {
      publicKey,
      secretKey  // Note: keet-identity-key uses 'secretKey' not 'privateKey'
    };
  }
}