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:
co-authored by
Claude Opus 4.6
parent
258a03d7f1
commit
ee7f0ede1f
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user