/** * @bytelyst/client-encrypt — AES-256-GCM via Web Crypto API * * Works in browsers (window.crypto.subtle) and React Native (expo-crypto polyfill). * Produces EncryptedField objects wire-compatible with: * - @bytelyst/field-encrypt (Node.js server) * - BLFieldEncrypt (Swift CryptoKit / Kotlin javax.crypto) */ import type { EncryptedField } from './types.js'; import { toHex, fromHex } from './hex.js'; const ALGORITHM = 'AES-GCM'; const KEY_SIZE_BITS = 256; const IV_BYTES = 12; const TAG_BITS = 128; /** Get the SubtleCrypto instance (browser or globalThis). */ function getSubtle(): SubtleCrypto { if (typeof globalThis !== 'undefined' && globalThis.crypto?.subtle) { return globalThis.crypto.subtle; } throw new Error( '@bytelyst/client-encrypt requires Web Crypto API (SubtleCrypto). ' + 'Use a polyfill in React Native (e.g., expo-crypto).' ); } /** Get the crypto object for random bytes. */ function getCrypto(): Crypto { if (typeof globalThis !== 'undefined' && globalThis.crypto) { return globalThis.crypto; } throw new Error('@bytelyst/client-encrypt requires globalThis.crypto for random bytes.'); } /** * Encrypt a plaintext string with AES-256-GCM using Web Crypto API. * * @param plaintext - UTF-8 string to encrypt * @param key - CryptoKey (AES-GCM, 256-bit) * @param dekId - DEK identifier stored in the output * @param aad - Optional additional authenticated data * @returns EncryptedField with hex-encoded ciphertext, IV, and tag */ export async function encryptField( plaintext: string, key: CryptoKey, dekId: string, aad?: string ): Promise { const subtle = getSubtle(); const crypto = getCrypto(); const iv = new Uint8Array(IV_BYTES); crypto.getRandomValues(iv); const encoder = new TextEncoder(); const plaintextBytes = encoder.encode(plaintext); const params: AesGcmParams = { name: ALGORITHM, iv: iv.buffer as ArrayBuffer, tagLength: TAG_BITS, }; if (aad) { params.additionalData = encoder.encode(aad).buffer as ArrayBuffer; } // Web Crypto returns ciphertext || tag concatenated const ciphertextWithTag = new Uint8Array( await subtle.encrypt(params, key, plaintextBytes.buffer as ArrayBuffer) ); const tagOffset = ciphertextWithTag.length - TAG_BITS / 8; const ct = ciphertextWithTag.slice(0, tagOffset); const tag = ciphertextWithTag.slice(tagOffset); return { __encrypted: true, v: 1, alg: 'aes-256-gcm', ct: toHex(ct), iv: toHex(iv), tag: toHex(tag), dekId, }; } /** * Decrypt an EncryptedField back to plaintext. * * @param field - EncryptedField object * @param key - CryptoKey (must match the key used to encrypt) * @param aad - Optional AAD (must match the AAD used during encryption) * @returns Decrypted UTF-8 string * @throws DOMException if authentication tag verification fails */ export async function decryptField( field: EncryptedField, key: CryptoKey, aad?: string ): Promise { const subtle = getSubtle(); const iv = fromHex(field.iv); const ct = fromHex(field.ct); const tag = fromHex(field.tag); // Web Crypto expects ciphertext || tag concatenated const ciphertextWithTag = new Uint8Array(ct.length + tag.length); ciphertextWithTag.set(ct, 0); ciphertextWithTag.set(tag, ct.length); const params: AesGcmParams = { name: ALGORITHM, iv: iv.buffer as ArrayBuffer, tagLength: TAG_BITS, }; if (aad) { params.additionalData = new TextEncoder().encode(aad).buffer as ArrayBuffer; } const plaintextBytes = new Uint8Array( await subtle.decrypt(params, key, ciphertextWithTag.buffer as ArrayBuffer) ); return new TextDecoder().decode(plaintextBytes); } /** * Generate a random AES-256-GCM CryptoKey. * * @param extractable - Whether the key material can be exported (default: true). * Set to `false` for non-extractable keys stored in IndexedDB. */ export async function generateKey(extractable = true): Promise { const subtle = getSubtle(); return subtle.generateKey({ name: ALGORITHM, length: KEY_SIZE_BITS }, extractable, [ 'encrypt', 'decrypt', ]); } /** * Import a hex-encoded key string as a CryptoKey. * * @param hex - 64 hex chars = 32 bytes * @param extractable - Whether the imported key can be exported (default: true) */ export async function keyFromHex(hex: string, extractable = true): Promise { const subtle = getSubtle(); const keyBytes = fromHex(hex); if (keyBytes.length !== KEY_SIZE_BITS / 8) { throw new Error(`AES-256-GCM requires a 32-byte key, got ${keyBytes.length}`); } return subtle.importKey( 'raw', keyBytes.buffer as ArrayBuffer, { name: ALGORITHM, length: KEY_SIZE_BITS }, extractable, ['encrypt', 'decrypt'] ); } /** * Export a CryptoKey to a hex-encoded string. * Only works if the key was created with `extractable: true`. */ export async function keyToHex(key: CryptoKey): Promise { const subtle = getSubtle(); const raw = new Uint8Array(await subtle.exportKey('raw', key)); return toHex(raw); } /** * Derive an AES-256 key from a passphrase using PBKDF2. * * @param passphrase - User passphrase * @param salt - Random salt (at least 16 bytes recommended) * @param iterations - PBKDF2 iterations (default: 600,000 per OWASP 2023) * @param extractable - Whether derived key can be exported (default: false) */ export async function deriveKey( passphrase: string, salt: Uint8Array, iterations = 600_000, extractable = false ): Promise { const subtle = getSubtle(); const encoder = new TextEncoder(); const baseKey = await subtle.importKey( 'raw', encoder.encode(passphrase).buffer as ArrayBuffer, 'PBKDF2', false, ['deriveKey'] ); return subtle.deriveKey( { name: 'PBKDF2', salt: salt.buffer as ArrayBuffer, iterations, hash: 'SHA-256', }, baseKey, { name: ALGORITHM, length: KEY_SIZE_BITS }, extractable, ['encrypt', 'decrypt'] ); }