diff --git a/packages/app/src/utils/crypto.ts b/packages/app/src/utils/crypto.ts new file mode 100644 index 00000000..c6f39ae1 --- /dev/null +++ b/packages/app/src/utils/crypto.ts @@ -0,0 +1,144 @@ +/** + * AES-256-GCM encryption utilities using Web Crypto API. + * PBKDF2 key derivation with 100K+ iterations. + */ + +const PBKDF2_ITERATIONS = 100_000 +const SALT_LENGTH = 16 +const IV_LENGTH = 12 + +export async function generateSalt(): Promise { + return crypto.getRandomValues(new Uint8Array(SALT_LENGTH)) +} + +export async function deriveKey(password: string, salt: Uint8Array): Promise { + const enc = new TextEncoder() + const keyMaterial = await crypto.subtle.importKey( + 'raw', + enc.encode(password), + 'PBKDF2', + false, + ['deriveKey'], + ) + + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: PBKDF2_ITERATIONS, + hash: 'SHA-256', + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ) +} + +export interface EncryptedPayload { + ciphertext: ArrayBuffer + iv: Uint8Array +} + +export async function encrypt(data: string, key: CryptoKey): Promise { + const enc = new TextEncoder() + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)) + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + enc.encode(data), + ) + return { ciphertext, iv } +} + +export async function decrypt( + ciphertext: ArrayBuffer, + iv: Uint8Array, + key: CryptoKey, +): Promise { + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + ciphertext, + ) + return new TextDecoder().decode(plaintext) +} + +/** + * Convenience: encrypt a string and return a storable format. + * Returns base64-encoded JSON with iv + ciphertext. + */ +export async function encryptToString(data: string, key: CryptoKey): Promise { + const { ciphertext, iv } = await encrypt(data, key) + const combined = new Uint8Array(iv.length + ciphertext.byteLength) + combined.set(iv) + combined.set(new Uint8Array(ciphertext), iv.length) + return bufferToBase64(combined) +} + +/** + * Convenience: decrypt a base64-encoded encrypted string. + */ +export async function decryptFromString(encoded: string, key: CryptoKey): Promise { + const combined = base64ToBuffer(encoded) + const iv = combined.slice(0, IV_LENGTH) + const ciphertext = combined.slice(IV_LENGTH) + return decrypt(ciphertext.buffer, iv, key) +} + +function bufferToBase64(buffer: Uint8Array): string { + let binary = '' + for (let i = 0; i < buffer.length; i++) { + binary += String.fromCharCode(buffer[i]) + } + return btoa(binary) +} + +function base64ToBuffer(base64: string): Uint8Array { + 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 +} + +/** + * Check if encryption should be enabled. + * Disabled in dev mode with VITE_DISABLE_CRYPTO=true. + */ +export function isCryptoEnabled(): boolean { + try { + return import.meta.env.VITE_DISABLE_CRYPTO !== 'true' + } catch { + return true + } +} + +/** + * Session key management — held in memory only. + */ +let sessionKey: CryptoKey | null = null +let sessionSalt: Uint8Array | null = null + +export function setSessionKey(key: CryptoKey, salt: Uint8Array): void { + sessionKey = key + sessionSalt = salt +} + +export function getSessionKey(): CryptoKey | null { + return sessionKey +} + +export function getSessionSalt(): Uint8Array | null { + return sessionSalt +} + +export function clearSessionKey(): void { + sessionKey = null + sessionSalt = null +} + +export function hasSessionKey(): boolean { + return sessionKey !== null +} diff --git a/packages/app/src/utils/idb-storage.ts b/packages/app/src/utils/idb-storage.ts new file mode 100644 index 00000000..852ff162 --- /dev/null +++ b/packages/app/src/utils/idb-storage.ts @@ -0,0 +1,100 @@ +import type { Conversation } from '@aiui/core/types/message' +import { + isCryptoEnabled, + getSessionKey, + encryptToString, + decryptFromString, +} from './crypto' + +const DB_NAME = 'aiui-store' +const DB_VERSION = 1 +const STORE_NAME = 'conversations' + +let dbPromise: Promise | null = null + +export function openDB(): Promise { + if (dbPromise) return dbPromise + dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + request.onupgradeneeded = () => { + const db = request.result + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' }) + store.createIndex('updatedAt', 'updatedAt', { unique: false }) + } + } + request.onsuccess = () => resolve(request.result) + request.onerror = () => { + dbPromise = null + reject(request.error) + } + }) + return dbPromise +} + +interface EncryptedRecord { + id: string + updatedAt: number + encrypted: string +} + +async function encryptConversation(conv: Conversation): Promise { + const key = getSessionKey() + if (!isCryptoEnabled() || !key) return conv + const encrypted = await encryptToString(JSON.stringify(conv), key) + return { id: conv.id, updatedAt: conv.updatedAt, encrypted } +} + +async function decryptConversation(record: EncryptedRecord | Conversation): Promise { + if (!('encrypted' in record)) return record as Conversation + const key = getSessionKey() + if (!key) return null + try { + const json = await decryptFromString(record.encrypted, key) + return JSON.parse(json) as Conversation + } catch { + return null // Wrong passphrase or corrupted data + } +} + +export async function saveConversation(conv: Conversation): Promise { + const db = await openDB() + const record = await encryptConversation(conv) + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite') + tx.objectStore(STORE_NAME).put(record) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) +} + +export async function loadAllConversations(): Promise> { + const db = await openDB() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly') + const request = tx.objectStore(STORE_NAME).index('updatedAt').getAll() + request.onsuccess = async () => { + const map = new Map() + for (const record of request.result) { + const conv = await decryptConversation(record) + if (conv) map.set(conv.id, conv) + } + resolve(map) + } + request.onerror = () => reject(request.error) + }) +} + +export async function deleteConversation(id: string): Promise { + const db = await openDB() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite') + tx.objectStore(STORE_NAME).delete(id) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) +} + +export function isIDBAvailable(): boolean { + return typeof indexedDB !== 'undefined' +}