/** * crypto.js – SexyChat SubtleCrypto wrapper * * Provides PBKDF2-derived AES-GCM-256 keys for end-to-end encrypted PMs. * * Key facts: * - Keys are derived deterministically from (password, username) so the * same credentials always produce the same key across sessions. * - Keys are marked `extractable: true` so they can be exported as raw bytes * for the AI transit-encryption flow (sent over HTTPS, never stored). * - The server only ever receives ciphertext + nonce. It never sees a key * for user-to-user conversations. */ "use strict"; const SexyChato = (() => { const PBKDF2_ITERATIONS = 100_000; const KEY_LENGTH_BITS = 256; const SALT_PREFIX = "sexychat:v1:"; // ── Base64 helpers ──────────────────────────────────────────────────────── function bufToBase64(buffer) { const bytes = new Uint8Array(buffer); let binary = ""; for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); return btoa(binary); } function base64ToBuf(b64) { const binary = atob(b64); const buf = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) buf[i] = binary.charCodeAt(i); return buf.buffer; } // ── Key derivation ───────────────────────────────────────────────────────── /** * Derive an AES-GCM key from a password and username. * The username acts as a deterministic, per-account salt. * * @param {string} password * @param {string} username * @returns {Promise} */ async function deriveKey(password, username) { const enc = new TextEncoder(); const salt = enc.encode(SALT_PREFIX + username.toLowerCase()); const baseKey = await crypto.subtle.importKey( "raw", enc.encode(password), { name: "PBKDF2" }, false, ["deriveKey"], ); return crypto.subtle.deriveKey( { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256", }, baseKey, { name: "AES-GCM", length: KEY_LENGTH_BITS }, true, // extractable – needed for transit encryption to AI endpoint ["encrypt", "decrypt"], ); } // ── Encrypt / decrypt ───────────────────────────────────────────────────── /** * Encrypt a plaintext string with an AES-GCM key. * * @param {CryptoKey} key * @param {string} plaintext * @returns {Promise<{ ciphertext: string, nonce: string }>} both base64 */ async function encrypt(key, plaintext) { const nonce = crypto.getRandomValues(new Uint8Array(12)); const encoded = new TextEncoder().encode(plaintext); const cipherBuf = await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce }, key, encoded, ); return { ciphertext: bufToBase64(cipherBuf), nonce: bufToBase64(nonce), }; } /** * Decrypt an AES-GCM ciphertext. * * @param {CryptoKey} key * @param {string} ciphertext base64 * @param {string} nonce base64 * @returns {Promise} plaintext */ async function decrypt(key, ciphertext, nonce) { const cipherBuf = base64ToBuf(ciphertext); const nonceBuf = base64ToBuf(nonce); const plainBuf = await crypto.subtle.decrypt( { name: "AES-GCM", iv: nonceBuf }, key, cipherBuf, ); return new TextDecoder().decode(plainBuf); } /** * Export a CryptoKey as a base64-encoded raw byte string. * Used only for the AI transit flow (sent in POST body over HTTPS, * never stored by the server). * * @param {CryptoKey} key * @returns {Promise} base64 */ async function exportKeyBase64(key) { const raw = await crypto.subtle.exportKey("raw", key); return bufToBase64(raw); } // ── Public API ───────────────────────────────────────────────────────────── return { deriveKey, encrypt, decrypt, exportKeyBase64 }; })();