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