/** * AES-GCM encrypt/decrypt over the Web Crypto API (no dependencies). * * Ciphertext and IV are returned as base64 strings so they map directly onto * the backend's `EncryptedField { data, iv, auth_tag }` wire shape. */ const encoder = new TextEncoder(); const decoder = new TextDecoder(); const b64 = { encode(bytes: Uint8Array): string { let bin = ''; for (const b of bytes) bin += String.fromCharCode(b); return btoa(bin); }, decode(str: string): Uint8Array { const bin = atob(str); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes; }, }; /** Random 12-byte IV for AES-GCM. */ function randomIv(): Uint8Array { return crypto.getRandomValues(new Uint8Array(12)); } export interface CipherPayload { /** base64 ciphertext */ data: string; /** base64 12-byte IV */ iv: string; } /** Encrypt a UTF-8 string under the given AES-GCM key. */ export async function encrypt( plaintext: string, key: CryptoKey, ): Promise { const iv = randomIv(); const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: iv as BufferSource }, key, encoder.encode(plaintext) as BufferSource, ); return { data: b64.encode(new Uint8Array(ciphertext)), iv: b64.encode(iv), }; } /** Decrypt a base64 payload. Throws on tamper / wrong key. */ export async function decrypt( payload: CipherPayload, key: CryptoKey, ): Promise { const iv = b64.decode(payload.iv); const plaintext = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: iv as BufferSource }, key, b64.decode(payload.data) as BufferSource, ); return decoder.decode(plaintext); } /** Encrypt a JSON-serializable object. */ export async function encryptJson( obj: T, key: CryptoKey, ): Promise { return encrypt(JSON.stringify(obj), key); } /** Decrypt a payload and JSON.parse into T. Throws on tamper / wrong key / bad JSON. */ export async function decryptJson( payload: CipherPayload, key: CryptoKey, ): Promise { return JSON.parse(await decrypt(payload, key)) as T; }