feat(app): add AES-256-GCM encryption with PBKDF2 key derivation

Create crypto.ts with Web Crypto API utilities: PBKDF2 key derivation
(100K iterations, SHA-256), AES-256-GCM encrypt/decrypt, session key
management. Modify idb-storage.ts to transparently encrypt/decrypt
conversations when a session key is set. Disabled in dev mode via
VITE_DISABLE_CRYPTO=true.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 20:51:31 +00:00
co-authored by Claude Opus 4.6
parent 258a03d7f1
commit ee7f0ede1f
2 changed files with 244 additions and 0 deletions
+144
View File
@@ -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<Uint8Array> {
return crypto.getRandomValues(new Uint8Array(SALT_LENGTH))
}
export async function deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
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<EncryptedPayload> {
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<string> {
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<string> {
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<string> {
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
}
+100
View File
@@ -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<IDBDatabase> | null = null
export function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise
dbPromise = new Promise<IDBDatabase>((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<EncryptedRecord | Conversation> {
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<Conversation | null> {
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<void> {
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<Map<string, Conversation>> {
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<string, Conversation>()
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<void> {
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'
}