/** * Crypto module — Web Crypto API wrapper. * * Uses PBKDF2 for key derivation and AES-GCM for symmetric encryption. * All operations are async and use the browser's native Web Crypto API. * * The derived encryption key is kept in memory only — never written to disk. */ import { generateId } from '../models/schema.js' const PBKDF2_ITERATIONS = 600_000 const SALT_LENGTH = 16 // bytes const IV_LENGTH = 12 // bytes (recommended for AES-GCM) /** * Generate a random salt. * @returns {Uint8Array} */ export function generateSalt() { return crypto.getRandomValues(new Uint8Array(SALT_LENGTH)) } /** * Derive an AES-GCM encryption key from a master password and salt. * * @param {string} masterPassword * @param {Uint8Array} salt * @returns {Promise} */ export async function deriveKey(masterPassword, salt) { // Step 1: Import the password as raw key material const keyMaterial = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(masterPassword), 'PBKDF2', false, ['deriveKey'] ) // Step 2: Derive an AES-GCM key using PBKDF2 return crypto.subtle.deriveKey( { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256', }, keyMaterial, { name: 'AES-GCM', length: 256 }, false, // not extractable — stays in memory ['encrypt', 'decrypt'] ) } /** * Encrypt a plaintext string. * * @param {string} plaintext * @param {CryptoKey} key * @returns {Promise} JSON string containing { iv, ciphertext } * (salt is stored separately in the app store) */ export async function encrypt(plaintext, key) { const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)) const encoded = new TextEncoder().encode(plaintext) const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, encoded ) // Return as JSON with base64-encoded iv and ciphertext return JSON.stringify({ iv: uint8ArrayToBase64(iv), ciphertext: uint8ArrayToBase64(new Uint8Array(ciphertext)), }) } /** * Decrypt an encrypted blob back to plaintext. * * @param {string} encryptedJson - JSON string from encrypt() * @param {CryptoKey} key * @returns {Promise} */ export async function decrypt(encryptedJson, key) { const { iv, ciphertext } = JSON.parse(encryptedJson) const ciphertextBuffer = base64ToUint8Array(ciphertext) const ivBuffer = base64ToUint8Array(iv) const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: ivBuffer }, key, ciphertextBuffer ) return new TextDecoder().decode(decrypted) } /** * Verify that a master password is correct by attempting to decrypt a test payload. * * @param {string} masterPassword * @param {Uint8Array} salt * @param {string} testEncrypted - A known encrypted test string * @param {string} testPlaintext - The expected plaintext * @returns {Promise} */ export async function verifyPassword(masterPassword, salt, testEncrypted, testPlaintext) { try { const key = await deriveKey(masterPassword, salt) const decrypted = await decrypt(testEncrypted, key) return decrypted === testPlaintext } catch { return false } } /** * Create a test payload for password verification on first setup. * * @param {string} masterPassword * @returns {Promise<{ salt: Uint8Array, testEncrypted: string }>} */ export async function createTestPayload(masterPassword) { const salt = generateSalt() const key = await deriveKey(masterPassword, salt) const testPlaintext = 'vault_test_' + generateId() const testEncrypted = await encrypt(testPlaintext, key) return { salt, testEncrypted, testPlaintext } } // --- Utility: Uint8Array ↔ Base64 --- export function uint8ArrayToBase64(buffer) { const bytes = new Uint8Array(buffer) let binary = '' for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]) } return btoa(binary) } export function base64ToUint8Array(base64) { const binary = atob(base64) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i) } return bytes } /** * Generate a random password. * * @param {Object} options * @param {number} [options.length=16] * @param {boolean} [options.uppercase=true] * @param {boolean} [options.lowercase=true] * @param {boolean} [options.digits=true] * @param {boolean} [options.symbols=true] * @param {string} [options.exclude=''] * @returns {string} */ export function generatePassword({ length = 16, uppercase = true, lowercase = true, digits = true, symbols = true, exclude = '', } = {}) { let charset = '' if (uppercase) charset += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' if (lowercase) charset += 'abcdefghijklmnopqrstuvwxyz' if (digits) charset += '0123456789' if (symbols) charset += '!@#$%^&*()_+-=[]{}|;:,.<>?' // Remove excluded characters if (exclude) { const excludeSet = new Set(exclude.split('')) charset = charset.split('').filter(c => !excludeSet.has(c)).join('') } if (!charset) { throw new Error('Password charset is empty — enable at least one character type') } const charsetLength = charset.length const maxValid = 256 - (256 % charsetLength) const randomBytes = new Uint8Array(length * 2) let password = '' let byteIdx = 0 while (password.length < length) { if (byteIdx >= randomBytes.length) { crypto.getRandomValues(randomBytes) byteIdx = 0 } const byte = randomBytes[byteIdx++] if (byte < maxValid) { password += charset[byte % charsetLength] } } return password }