argon2-crypto.ts raw

   1  /**
   2   * Secure vault encryption/decryption using Argon2id + AES-GCM
   3   *
   4   * - Argon2id key derivation with ~3 second computation time
   5   * - AES-256-GCM authenticated encryption
   6   * - Random 32-byte salt per vault
   7   * - Random 12-byte IV per encryption
   8   *
   9   * Note: Uses main thread for Argon2id (via WebAssembly) because Web Workers
  10   * in browser extensions cannot load external scripts due to CSP restrictions.
  11   * The deriving modal provides user feedback during the ~3 second derivation.
  12   */
  13  
  14  import { argon2id } from 'hash-wasm';
  15  import { Buffer } from 'buffer';
  16  
  17  // Argon2id parameters tuned for ~3 second derivation on typical hardware
  18  const ARGON2_CONFIG = {
  19    parallelism: 4, // 4 threads
  20    iterations: 8, // Time cost
  21    memorySize: 262144, // 256 MB memory
  22    hashLength: 32, // 256-bit key for AES-256
  23    outputType: 'binary' as const,
  24  };
  25  
  26  /**
  27   * Derive an encryption key from password using Argon2id
  28   * @param password - User's password
  29   * @param salt - Random 32-byte salt
  30   * @returns 32-byte derived key
  31   */
  32  export async function deriveKeyArgon2(
  33    password: string,
  34    salt: Uint8Array
  35  ): Promise<Uint8Array> {
  36    // Use hash-wasm's argon2id (WebAssembly-based, runs on main thread)
  37    // This blocks the UI for ~3 seconds, which is why we show a modal
  38    const result = await argon2id({
  39      password: password,
  40      salt: salt,
  41      ...ARGON2_CONFIG,
  42    });
  43    return result;
  44  }
  45  
  46  /**
  47   * Generate a random salt for Argon2id
  48   * @returns Base64 encoded 32-byte salt
  49   */
  50  export function generateSalt(): string {
  51    const salt = crypto.getRandomValues(new Uint8Array(32));
  52    return Buffer.from(salt).toString('base64');
  53  }
  54  
  55  /**
  56   * Generate a random IV for AES-GCM
  57   * @returns Base64 encoded 12-byte IV
  58   */
  59  export function generateIV(): string {
  60    const iv = crypto.getRandomValues(new Uint8Array(12));
  61    return Buffer.from(iv).toString('base64');
  62  }
  63  
  64  /**
  65   * Encrypt data using Argon2id-derived key + AES-256-GCM
  66   * @param plaintext - Data to encrypt
  67   * @param password - User's password
  68   * @param saltBase64 - Base64 encoded 32-byte salt
  69   * @param ivBase64 - Base64 encoded 12-byte IV
  70   * @returns Base64 encoded ciphertext
  71   */
  72  export async function encryptWithArgon2(
  73    plaintext: string,
  74    password: string,
  75    saltBase64: string,
  76    ivBase64: string
  77  ): Promise<string> {
  78    const salt = Buffer.from(saltBase64, 'base64');
  79    const iv = Buffer.from(ivBase64, 'base64');
  80  
  81    // Derive key using Argon2id (~3 seconds, in worker)
  82    const keyBytes = await deriveKeyArgon2(password, salt);
  83  
  84    // Import key for AES-GCM
  85    const key = await crypto.subtle.importKey(
  86      'raw',
  87      keyBytes,
  88      { name: 'AES-GCM' },
  89      false,
  90      ['encrypt']
  91    );
  92  
  93    // Encrypt the data
  94    const encoder = new TextEncoder();
  95    const encrypted = await crypto.subtle.encrypt(
  96      { name: 'AES-GCM', iv: iv },
  97      key,
  98      encoder.encode(plaintext)
  99    );
 100  
 101    return Buffer.from(encrypted).toString('base64');
 102  }
 103  
 104  /**
 105   * Decrypt data using Argon2id-derived key + AES-256-GCM
 106   * @param ciphertextBase64 - Base64 encoded ciphertext
 107   * @param password - User's password
 108   * @param saltBase64 - Base64 encoded 32-byte salt
 109   * @param ivBase64 - Base64 encoded 12-byte IV
 110   * @returns Decrypted plaintext
 111   * @throws Error if password is wrong or data is corrupted
 112   */
 113  export async function decryptWithArgon2(
 114    ciphertextBase64: string,
 115    password: string,
 116    saltBase64: string,
 117    ivBase64: string
 118  ): Promise<string> {
 119    const salt = Buffer.from(saltBase64, 'base64');
 120    const iv = Buffer.from(ivBase64, 'base64');
 121    const ciphertext = Buffer.from(ciphertextBase64, 'base64');
 122  
 123    // Derive key using Argon2id (~3 seconds, in worker)
 124    const keyBytes = await deriveKeyArgon2(password, salt);
 125  
 126    // Import key for AES-GCM
 127    const key = await crypto.subtle.importKey(
 128      'raw',
 129      keyBytes,
 130      { name: 'AES-GCM' },
 131      false,
 132      ['decrypt']
 133    );
 134  
 135    // Decrypt
 136    let decrypted;
 137    try {
 138      decrypted = await crypto.subtle.decrypt(
 139        { name: 'AES-GCM', iv: iv },
 140        key,
 141        ciphertext
 142      );
 143    } catch {
 144      throw new Error('Decryption failed - invalid password or corrupted data');
 145    }
 146  
 147    const decoder = new TextDecoder();
 148    return decoder.decode(decrypted);
 149  }
 150  
 151