From 0a5bb5b811c354a2d0fff09c43b5c6cb8fc88a5f Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:15:32 +0000 Subject: [PATCH 1/2] fix(security): harden encryption key management and storage - Centralize secure key management in src/lib/crypto.ts using Web Crypto API. - Implement non-extractable CryptoKey storage in IndexedDB (using structured clone). - Migrate LLM API keys encryption from localStorage to secure IndexedDB store. - Migrate key-store master key from JWK string to non-extractable CryptoKey. - Add fake-indexeddb for robust testing of secure storage logic. - Implement backward-compatible migration paths for all existing encrypted data. - Add comprehensive test suites for crypto utilities and migration flows. Co-authored-by: d-oit <6849456+d-oit@users.noreply.github.com> --- package.json | 1 + pnpm-lock.yaml | 9 ++ src/lib/__tests__/crypto.test.ts | 53 +++++++ src/lib/__tests__/key-store-migration.test.ts | 103 ++++++++++++ src/lib/crypto.ts | 150 ++++++++++++++++++ src/lib/key-store.ts | 87 +++++----- .../__tests__/encryption-migration.test.ts | 56 +++++++ src/lib/llm/encryption.ts | 84 ++++------ src/test/setup.ts | 26 +-- 9 files changed, 449 insertions(+), 120 deletions(-) create mode 100644 src/lib/__tests__/crypto.test.ts create mode 100644 src/lib/__tests__/key-store-migration.test.ts create mode 100644 src/lib/crypto.ts create mode 100644 src/lib/llm/__tests__/encryption-migration.test.ts diff --git a/package.json b/package.json index 62dde077..099d82b4 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "eslint-plugin-react": "~7.37.5", "eslint-plugin-react-hooks": "~7.1.1", "eslint-plugin-react-refresh": "~0.4.6", + "fake-indexeddb": "^6.2.5", "globals": "~17.6.0", "graphology-types": "~0.24.8", "jsdom": "~29.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52904611..c63d1190 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,9 @@ importers: eslint-plugin-react-refresh: specifier: ~0.4.6 version: 0.4.26(eslint@8.57.1) + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 globals: specifier: ~17.6.0 version: 17.6.0 @@ -2000,6 +2003,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -5678,6 +5685,8 @@ snapshots: expect-type@1.3.0: {} + fake-indexeddb@6.2.5: {} + fast-deep-equal@3.1.3: {} fast-equals@5.4.0: {} diff --git a/src/lib/__tests__/crypto.test.ts b/src/lib/__tests__/crypto.test.ts new file mode 100644 index 00000000..2b457d21 --- /dev/null +++ b/src/lib/__tests__/crypto.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { getOrCreateKey, encrypt, decrypt, hasKey, deleteKey } from '../crypto'; + +describe('Crypto Utilities', () => { + const TEST_KEY_ID = 'test-encryption-key'; + + beforeEach(async () => { + await deleteKey(TEST_KEY_ID); + }); + + afterEach(async () => { + await deleteKey(TEST_KEY_ID); + }); + + it('should create and store a non-extractable key', async () => { + const key = await getOrCreateKey(TEST_KEY_ID); + expect(key.extractable).toBe(false); + expect(key.type).toBe('secret'); + expect(key.algorithm.name).toBe('AES-GCM'); + + const exists = await hasKey(TEST_KEY_ID); + expect(exists).toBe(true); + + const sameKey = await getOrCreateKey(TEST_KEY_ID); + // When retrieved from IndexedDB, it might not be the exact same object reference + // but should have the same properties. + expect(sameKey.extractable).toBe(false); + expect(sameKey.algorithm.name).toBe('AES-GCM'); + }); + + it('should encrypt and decrypt values', async () => { + const key = await getOrCreateKey(TEST_KEY_ID); + const plaintext = 'secret message 123'; + + const encrypted = await encrypt(plaintext, key); + expect(encrypted).not.toBe(plaintext); + expect(typeof encrypted).toBe('string'); + + const decrypted = await decrypt(encrypted, key); + expect(decrypted).toBe(plaintext); + }); + + it('should throw error when decrypting with wrong key', async () => { + const key1 = await getOrCreateKey(TEST_KEY_ID); + const plaintext = 'secret'; + const encrypted = await encrypt(plaintext, key1); + + await deleteKey(TEST_KEY_ID); + const key2 = await getOrCreateKey(TEST_KEY_ID); // Different key + + await expect(decrypt(encrypted, key2)).rejects.toThrow(); + }); +}); diff --git a/src/lib/__tests__/key-store-migration.test.ts b/src/lib/__tests__/key-store-migration.test.ts new file mode 100644 index 00000000..6735537d --- /dev/null +++ b/src/lib/__tests__/key-store-migration.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { keyStore } from '../key-store'; +import { deleteKey, hasKey, getOrCreateKey } from '../crypto'; + +describe('KeyStore Secure Migration', () => { + const CRYPTO_KEY_ID = 'dks:key-store:encryption-key'; + + beforeEach(async () => { + await deleteKey(CRYPTO_KEY_ID); + // Clear the legacy key from IndexedDB if it exists (mocking legacy state) + const request = indexedDB.open('dks:key-store', 1); + await new Promise((resolve, reject) => { + request.onsuccess = (e: any) => { + const db = e.target.result; + if (db.objectStoreNames.contains('keys')) { + const tx = db.transaction('keys', 'readwrite'); + tx.objectStore('keys').delete('__encryption_key__'); + tx.oncomplete = resolve; + } else { + resolve(null); + } + }; + request.onerror = reject; + request.onupgradeneeded = (e: any) => { + const db = e.target.result; + if (!db.objectStoreNames.contains('keys')) { + db.createObjectStore('keys', { keyPath: 'id' }); + } + }; + }); + }); + + it('should encrypt new values using non-extractable keys', async () => { + await keyStore.set('test-key', 'test-value'); + const value = await keyStore.get('test-key'); + expect(value).toBe('test-value'); + + const key = await getOrCreateKey(CRYPTO_KEY_ID); + expect(key.extractable).toBe(false); + }); + + it('should migrate legacy JWK key to secure crypto-store', async () => { + // 1. Manually put a legacy JWK into the old store + const legacyKey = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + const jwk = await crypto.subtle.exportKey('jwk', legacyKey); + + const openReq = indexedDB.open('dks:key-store', 1); + await new Promise((resolve) => { + openReq.onsuccess = (e: any) => { + const db = e.target.result; + const tx = db.transaction('keys', 'readwrite'); + tx.objectStore('keys').put({ id: '__encryption_key__', value: JSON.stringify(jwk) }); + tx.oncomplete = resolve; + }; + }); + + // 2. Encrypt something with the legacy key (mocking legacy encrypted data) + // We need to use the exact same format: enc:v1: + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode('legacy-data'); + const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, legacyKey, encoded); + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(encrypted), iv.length); + const legacyEncryptedValue = 'enc:v1:' + btoa(String.fromCharCode(...combined)); + + await new Promise((resolve) => { + const openReq2 = indexedDB.open('dks:key-store', 1); + openReq2.onsuccess = (e: any) => { + const db = e.target.result; + const tx = db.transaction('keys', 'readwrite'); + tx.objectStore('keys').put({ id: 'legacy-item', value: legacyEncryptedValue }); + tx.oncomplete = resolve; + }; + }); + + // 3. Access the item via keyStore — it should trigger migration and still be able to decrypt + const value = await keyStore.get('legacy-item'); + expect(value).toBe('legacy-data'); + + // 4. Verify migration happened + const hasSecureKey = await hasKey(CRYPTO_KEY_ID); + expect(hasSecureKey).toBe(true); + const secureKey = await getOrCreateKey(CRYPTO_KEY_ID); + expect(secureKey.extractable).toBe(false); + + // 5. Verify legacy key is gone + const openReq3 = indexedDB.open('dks:key-store', 1); + const legacyKeyStored = await new Promise((resolve) => { + openReq3.onsuccess = (e: any) => { + const db = e.target.result; + const tx = db.transaction('keys', 'readonly'); + const req = tx.objectStore('keys').get('__encryption_key__'); + req.onsuccess = () => resolve(req.result); + }; + }); + expect(legacyKeyStored).toBeUndefined(); + }); +}); diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 00000000..5f7c0a14 --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,150 @@ +import { logger } from './logger'; + +const DB_NAME = 'dks:crypto-store'; +const DB_VERSION = 1; +const STORE_NAME = 'keys'; + +/** + * Open the IndexedDB database for secure key storage. + * IndexedDB supports storing CryptoKey objects directly via structured clone. + */ +function openDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onerror = () => reject(new Error(String(request.error))); + request.onsuccess = () => resolve(request.result); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'id' }); + } + }; + }); +} + +/** + * Get or create a non-extractable AES-GCM encryption key. + * By setting extractable to false, the raw key bytes cannot be retrieved + * via crypto.subtle.exportKey, providing stronger protection against key theft. + */ +export async function getOrCreateKey(id: string, options: { extractable?: boolean } = {}): Promise { + const db = await openDB(); + const stored = await new Promise<{ id: string; key: CryptoKey } | undefined>((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const request = store.get(id); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(new Error(String(request.error))); + }); + + if (stored?.key) { + return stored.key; + } + + const extractable = options.extractable ?? false; + const key = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + extractable, + ['encrypt', 'decrypt'], + ); + + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + store.put({ id, key }); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(new Error(String(tx.error))); + }); + + return key; +} + +/** + * Import an existing key and store it as a non-extractable key. + */ +export async function importAndStoreKey(id: string, jwk: JsonWebKey, options: { extractable?: boolean } = {}): Promise { + const db = await openDB(); + const extractable = options.extractable ?? false; + const key = await crypto.subtle.importKey( + 'jwk', + jwk, + { name: 'AES-GCM', length: 256 }, + extractable, + ['encrypt', 'decrypt'], + ); + + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + store.put({ id, key }); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(new Error(String(tx.error))); + }); + + return key; +} + +/** + * Encrypt a plaintext string using AES-GCM and the provided key. + * Returns a base64 string containing the IV and ciphertext. + */ +export async function encrypt(plaintext: string, key: CryptoKey): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode(plaintext); + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + encoded, + ); + + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(encrypted), iv.length); + + return btoa(String.fromCharCode(...combined)); +} + +/** + * Decrypt an encrypted base64 string using AES-GCM and the provided key. + */ +export async function decrypt(encrypted: string, key: CryptoKey): Promise { + const combined = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0)); + const iv = combined.slice(0, 12); + const ciphertext = combined.slice(12); + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + ciphertext, + ); + + return new TextDecoder().decode(decrypted); +} + +/** + * Check if a key exists in the store. + */ +export async function hasKey(id: string): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const request = store.count(id); + request.onsuccess = () => resolve(request.result > 0); + request.onerror = () => reject(new Error(String(request.error))); + }); +} + +/** + * Delete a key from the store. + */ +export async function deleteKey(id: string): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + store.delete(id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(new Error(String(tx.error))); + }); +} diff --git a/src/lib/key-store.ts b/src/lib/key-store.ts index c1a40183..51689286 100644 --- a/src/lib/key-store.ts +++ b/src/lib/key-store.ts @@ -1,4 +1,5 @@ import { logger } from './logger'; +import { getOrCreateKey, encrypt, decrypt, importAndStoreKey, hasKey } from './crypto'; const DB_NAME = 'dks:key-store'; const DB_VERSION = 1; @@ -56,63 +57,59 @@ async function deleteRaw(id: string): Promise { }); } -async function getEncryptionKey(): Promise { - const stored = await getRaw(ENCRYPTION_KEY_ID); - if (stored) { - try { - return await crypto.subtle.importKey( - 'jwk', - JSON.parse(stored) as JsonWebKey, - { name: 'AES-GCM', length: 256 }, - true, - ['encrypt', 'decrypt'], - ); - } catch (err) { - logger.warn('Encryption key is corrupted, generating a new one', err); - await deleteRaw(ENCRYPTION_KEY_ID); +/** + * Get the encryption key for the key-store. + * Migrates existing legacy keys (stored as JWK in the same DB) to the secure crypto-store. + */ +async function getStoreEncryptionKey(): Promise { + // Use a dedicated ID for the key-store's key in the crypto-store + const CRYPTO_KEY_ID = 'dks:key-store:encryption-key'; + + try { + // Check if we have it in the new store first + if (await hasKey(CRYPTO_KEY_ID)) { + return await getOrCreateKey(CRYPTO_KEY_ID, { extractable: false }); } + } catch (err) { + logger.debug('Error checking for key in crypto-store', err); + } + + try { + // Check if we have a legacy JWK to migrate + const legacyJwkString = await getRaw(ENCRYPTION_KEY_ID); + if (legacyJwkString) { + try { + const jwk = JSON.parse(legacyJwkString) as JsonWebKey; + const key = await importAndStoreKey(CRYPTO_KEY_ID, jwk, { extractable: false }); + // Cleanup legacy JWK from the old store + await deleteRaw(ENCRYPTION_KEY_ID); + logger.info('Migrated key-store encryption key to secure storage'); + return key; + } catch (migrationErr) { + logger.error('Failed to migrate legacy key-store encryption key', migrationErr); + } + } + } catch (err) { + logger.debug('No legacy key found or error reading it', err); } - const key = await crypto.subtle.generateKey( - { name: 'AES-GCM', length: 256 }, - true, - ['encrypt', 'decrypt'], - ); - const exported = await crypto.subtle.exportKey('jwk', key); - await setRaw(ENCRYPTION_KEY_ID, JSON.stringify(exported)); - return key; + // Final fallback: generate a new key if migration failed or no legacy key exists + return await getOrCreateKey(CRYPTO_KEY_ID, { extractable: false }); } async function encryptValue(plaintext: string): Promise { - const key = await getEncryptionKey(); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const encoded = new TextEncoder().encode(plaintext); - const encrypted = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - key, - encoded, - ); - const combined = new Uint8Array(iv.length + encrypted.byteLength); - combined.set(iv, 0); - combined.set(new Uint8Array(encrypted), iv.length); - return ENCRYPTED_PREFIX + btoa(String.fromCharCode(...combined)); + const key = await getStoreEncryptionKey(); + const encrypted = await encrypt(plaintext, key); + return ENCRYPTED_PREFIX + encrypted; } async function decryptValue(encrypted: string): Promise { if (!encrypted.startsWith(ENCRYPTED_PREFIX)) { return encrypted; } - const key = await getEncryptionKey(); - const base64 = encrypted.slice(ENCRYPTED_PREFIX.length); - const combined = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); - const iv = combined.slice(0, 12); - const ciphertext = combined.slice(12); - const decrypted = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv }, - key, - ciphertext, - ); - return new TextDecoder().decode(decrypted); + const key = await getStoreEncryptionKey(); + const ciphertext = encrypted.slice(ENCRYPTED_PREFIX.length); + return await decrypt(ciphertext, key); } export const keyStore = { diff --git a/src/lib/llm/__tests__/encryption-migration.test.ts b/src/lib/llm/__tests__/encryption-migration.test.ts new file mode 100644 index 00000000..469f5eae --- /dev/null +++ b/src/lib/llm/__tests__/encryption-migration.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { encryptApiKey, decryptApiKey } from '../encryption'; +import { deleteKey, hasKey, getOrCreateKey } from '../../crypto'; + +describe('LLM Encryption Secure Migration', () => { + const CRYPTO_KEY_ID = 'dks:llm:encryption-key'; + const ENCRYPTION_KEY_STORAGE = 'dks:llm-encryption-key'; + + beforeEach(async () => { + await deleteKey(CRYPTO_KEY_ID); + localStorage.removeItem(ENCRYPTION_KEY_STORAGE); + }); + + it('should encrypt new API keys using non-extractable keys', async () => { + const encrypted = await encryptApiKey('test-api-key'); + expect(encrypted.startsWith('enc:v1:')).toBe(true); + + const decrypted = await decryptApiKey(encrypted); + expect(decrypted).toBe('test-api-key'); + + const key = await getOrCreateKey(CRYPTO_KEY_ID); + expect(key.extractable).toBe(false); + }); + + it('should migrate legacy JWK key from localStorage to secure crypto-store', async () => { + // 1. Manually put a legacy JWK into localStorage + const legacyKey = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + const jwk = await crypto.subtle.exportKey('jwk', legacyKey); + localStorage.setItem(ENCRYPTION_KEY_STORAGE, JSON.stringify(jwk)); + + // 2. Encrypt something with the legacy key + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode('legacy-api-key'); + const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, legacyKey, encoded); + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(encrypted), iv.length); + const legacyEncryptedValue = 'enc:v1:' + btoa(String.fromCharCode(...combined)); + + // 3. Decrypt it — should trigger migration + const decrypted = await decryptApiKey(legacyEncryptedValue); + expect(decrypted).toBe('legacy-api-key'); + + // 4. Verify migration happened + expect(await hasKey(CRYPTO_KEY_ID)).toBe(true); + const secureKey = await getOrCreateKey(CRYPTO_KEY_ID); + expect(secureKey.extractable).toBe(false); + + // 5. Verify localStorage is cleaned up + expect(localStorage.getItem(ENCRYPTION_KEY_STORAGE)).toBeNull(); + }); +}); diff --git a/src/lib/llm/encryption.ts b/src/lib/llm/encryption.ts index 990b22f1..05de0711 100644 --- a/src/lib/llm/encryption.ts +++ b/src/lib/llm/encryption.ts @@ -1,37 +1,44 @@ import { logger } from '../logger'; +import { getOrCreateKey, encrypt, decrypt, importAndStoreKey, hasKey } from '../crypto'; const ENCRYPTION_KEY_STORAGE = 'dks:llm-encryption-key'; const ENCRYPTED_PREFIX = 'enc:v1:'; /** - * Get or create the AES-GCM encryption key. - * The key is stored in localStorage as a JWK for persistence across sessions. + * Get the AES-GCM encryption key from the secure crypto-store. + * Migrates existing legacy keys from localStorage to IndexedDB. */ async function getKey(): Promise { - const stored = localStorage.getItem(ENCRYPTION_KEY_STORAGE); - if (stored) { - try { - return await crypto.subtle.importKey( - 'jwk', - JSON.parse(stored) as JsonWebKey, - { name: 'AES-GCM', length: 256 }, - true, - ['encrypt', 'decrypt'], - ); - } catch (err) { - logger.warn('Encryption key is corrupted, generating a new one', err); - localStorage.removeItem(ENCRYPTION_KEY_STORAGE); + const CRYPTO_KEY_ID = 'dks:llm:encryption-key'; + + try { + if (await hasKey(CRYPTO_KEY_ID)) { + return await getOrCreateKey(CRYPTO_KEY_ID, { extractable: false }); } + } catch (err) { + logger.debug('Error checking for key in crypto-store', err); } - const key = await crypto.subtle.generateKey( - { name: 'AES-GCM', length: 256 }, - true, - ['encrypt', 'decrypt'], - ); - const exported = await crypto.subtle.exportKey('jwk', key); - localStorage.setItem(ENCRYPTION_KEY_STORAGE, JSON.stringify(exported)); - return key; + try { + // Fallback to migration from localStorage + const stored = localStorage.getItem(ENCRYPTION_KEY_STORAGE); + if (stored) { + try { + const jwk = JSON.parse(stored) as JsonWebKey; + const key = await importAndStoreKey(CRYPTO_KEY_ID, jwk, { extractable: false }); + localStorage.removeItem(ENCRYPTION_KEY_STORAGE); + logger.info('Migrated LLM encryption key to secure storage'); + return key; + } catch (migrationErr) { + logger.warn('Failed to migrate LLM encryption key, generating a new one', migrationErr); + localStorage.removeItem(ENCRYPTION_KEY_STORAGE); + } + } + } catch (err) { + logger.debug('No legacy key found in localStorage', err); + } + + return await getOrCreateKey(CRYPTO_KEY_ID, { extractable: false }); } /** @@ -40,20 +47,8 @@ async function getKey(): Promise { */ export async function encryptApiKey(plaintext: string): Promise { const key = await getKey(); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const encoded = new TextEncoder().encode(plaintext); - const encrypted = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - key, - encoded, - ); - - // Combine IV + ciphertext and base64 encode - const combined = new Uint8Array(iv.length + encrypted.byteLength); - combined.set(iv, 0); - combined.set(new Uint8Array(encrypted), iv.length); - - return ENCRYPTED_PREFIX + btoa(String.fromCharCode(...combined)); + const encrypted = await encrypt(plaintext, key); + return ENCRYPTED_PREFIX + encrypted; } /** @@ -68,19 +63,8 @@ export async function decryptApiKey(encrypted: string): Promise { } const key = await getKey(); - const base64 = encrypted.slice(ENCRYPTED_PREFIX.length); - const combined = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); - - const iv = combined.slice(0, 12); - const ciphertext = combined.slice(12); - - const decrypted = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv }, - key, - ciphertext, - ); - - return new TextDecoder().decode(decrypted); + const ciphertext = encrypted.slice(ENCRYPTED_PREFIX.length); + return await decrypt(ciphertext, key); } /** diff --git a/src/test/setup.ts b/src/test/setup.ts index efca0e99..6b3f4493 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,30 +1,6 @@ import '@testing-library/jest-dom/vitest'; import { vi } from 'vitest'; - -// Mock IndexedDB for happy-dom/jsdom environment -const indexedDB = { - open: vi.fn().mockReturnValue({ - onupgradeneeded: null, - onsuccess: null, - onerror: null, - result: { - objectStoreNames: { - contains: vi.fn().mockReturnValue(true), - }, - createObjectStore: vi.fn(), - transaction: vi.fn().mockReturnValue({ - objectStore: vi.fn().mockReturnValue({ - put: vi.fn().mockReturnValue({ onsuccess: null, onerror: null }), - get: vi.fn().mockReturnValue({ onsuccess: null, onerror: null }), - delete: vi.fn().mockReturnValue({ onsuccess: null, onerror: null }), - }), - onerror: null, - }), - }, - }), -}; - -vi.stubGlobal('indexedDB', indexedDB); +import 'fake-indexeddb/auto'; // Mock scrollIntoView which is missing in many DOM emulations if (typeof window !== 'undefined') { From 494a51edff6781a7875bc9d38f59c1eccf684bd1 Mon Sep 17 00:00:00 2001 From: d-oit Date: Fri, 3 Jul 2026 10:35:28 +0200 Subject: [PATCH 2/2] fix(security): address DeepSource and Codacy code quality issues - Remove unused imports (vi, afterEach, logger) - Replace 'any' types with proper Event/IDBOpenDBRequest/Promise/unknown types - Convert function declarations to const arrow functions to avoid global scope warnings - Use template literals instead of string concatenation - Add explicit type casts for IndexedDB request.result All changes maintain existing behavior while improving type safety and code quality. --- src/lib/__tests__/crypto.test.ts | 2 +- src/lib/__tests__/key-store-migration.test.ts | 40 +++++++++---------- src/lib/crypto.ts | 9 ++--- src/lib/key-store.ts | 29 +++++++------- .../__tests__/encryption-migration.test.ts | 4 +- src/lib/llm/encryption.ts | 4 +- 6 files changed, 42 insertions(+), 46 deletions(-) diff --git a/src/lib/__tests__/crypto.test.ts b/src/lib/__tests__/crypto.test.ts index 2b457d21..9d80b744 100644 --- a/src/lib/__tests__/crypto.test.ts +++ b/src/lib/__tests__/crypto.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { getOrCreateKey, encrypt, decrypt, hasKey, deleteKey } from '../crypto'; describe('Crypto Utilities', () => { diff --git a/src/lib/__tests__/key-store-migration.test.ts b/src/lib/__tests__/key-store-migration.test.ts index 6735537d..8f9295ac 100644 --- a/src/lib/__tests__/key-store-migration.test.ts +++ b/src/lib/__tests__/key-store-migration.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { keyStore } from '../key-store'; import { deleteKey, hasKey, getOrCreateKey } from '../crypto'; @@ -9,20 +9,20 @@ describe('KeyStore Secure Migration', () => { await deleteKey(CRYPTO_KEY_ID); // Clear the legacy key from IndexedDB if it exists (mocking legacy state) const request = indexedDB.open('dks:key-store', 1); - await new Promise((resolve, reject) => { - request.onsuccess = (e: any) => { - const db = e.target.result; + await new Promise((resolve, reject) => { + request.onsuccess = (e: Event) => { + const db = (e.target as IDBOpenDBRequest).result; if (db.objectStoreNames.contains('keys')) { const tx = db.transaction('keys', 'readwrite'); tx.objectStore('keys').delete('__encryption_key__'); - tx.oncomplete = resolve; + tx.oncomplete = () => resolve(); } else { - resolve(null); + resolve(); } }; request.onerror = reject; - request.onupgradeneeded = (e: any) => { - const db = e.target.result; + request.onupgradeneeded = (e: IDBVersionChangeEvent) => { + const db = (e.target as IDBOpenDBRequest).result; if (!db.objectStoreNames.contains('keys')) { db.createObjectStore('keys', { keyPath: 'id' }); } @@ -49,12 +49,12 @@ describe('KeyStore Secure Migration', () => { const jwk = await crypto.subtle.exportKey('jwk', legacyKey); const openReq = indexedDB.open('dks:key-store', 1); - await new Promise((resolve) => { - openReq.onsuccess = (e: any) => { - const db = e.target.result; + await new Promise((resolve) => { + openReq.onsuccess = (e: Event) => { + const db = (e.target as IDBOpenDBRequest).result; const tx = db.transaction('keys', 'readwrite'); tx.objectStore('keys').put({ id: '__encryption_key__', value: JSON.stringify(jwk) }); - tx.oncomplete = resolve; + tx.oncomplete = () => resolve(); }; }); @@ -66,15 +66,15 @@ describe('KeyStore Secure Migration', () => { const combined = new Uint8Array(iv.length + encrypted.byteLength); combined.set(iv, 0); combined.set(new Uint8Array(encrypted), iv.length); - const legacyEncryptedValue = 'enc:v1:' + btoa(String.fromCharCode(...combined)); + const legacyEncryptedValue = `enc:v1:${btoa(String.fromCharCode(...combined))}`; - await new Promise((resolve) => { + await new Promise((resolve) => { const openReq2 = indexedDB.open('dks:key-store', 1); - openReq2.onsuccess = (e: any) => { - const db = e.target.result; + openReq2.onsuccess = (e: Event) => { + const db = (e.target as IDBOpenDBRequest).result; const tx = db.transaction('keys', 'readwrite'); tx.objectStore('keys').put({ id: 'legacy-item', value: legacyEncryptedValue }); - tx.oncomplete = resolve; + tx.oncomplete = () => resolve(); }; }); @@ -90,9 +90,9 @@ describe('KeyStore Secure Migration', () => { // 5. Verify legacy key is gone const openReq3 = indexedDB.open('dks:key-store', 1); - const legacyKeyStored = await new Promise((resolve) => { - openReq3.onsuccess = (e: any) => { - const db = e.target.result; + const legacyKeyStored = await new Promise((resolve) => { + openReq3.onsuccess = (e: Event) => { + const db = (e.target as IDBOpenDBRequest).result; const tx = db.transaction('keys', 'readonly'); const req = tx.objectStore('keys').get('__encryption_key__'); req.onsuccess = () => resolve(req.result); diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index 5f7c0a14..cb2e9570 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -1,5 +1,3 @@ -import { logger } from './logger'; - const DB_NAME = 'dks:crypto-store'; const DB_VERSION = 1; const STORE_NAME = 'keys'; @@ -8,8 +6,8 @@ const STORE_NAME = 'keys'; * Open the IndexedDB database for secure key storage. * IndexedDB supports storing CryptoKey objects directly via structured clone. */ -function openDB(): Promise { - return new Promise((resolve, reject) => { +const openDB = (): Promise => + new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => reject(new Error(String(request.error))); request.onsuccess = () => resolve(request.result); @@ -20,7 +18,6 @@ function openDB(): Promise { } }; }); -} /** * Get or create a non-extractable AES-GCM encryption key. @@ -33,7 +30,7 @@ export async function getOrCreateKey(id: string, options: { extractable?: boolea const tx = db.transaction(STORE_NAME, 'readonly'); const store = tx.objectStore(STORE_NAME); const request = store.get(id); - request.onsuccess = () => resolve(request.result); + request.onsuccess = () => resolve(request.result as { id: string; key: CryptoKey } | undefined); request.onerror = () => reject(new Error(String(request.error))); }); diff --git a/src/lib/key-store.ts b/src/lib/key-store.ts index 51689286..ca943cac 100644 --- a/src/lib/key-store.ts +++ b/src/lib/key-store.ts @@ -7,8 +7,8 @@ const STORE_NAME = 'keys'; const ENCRYPTION_KEY_ID = '__encryption_key__'; const ENCRYPTED_PREFIX = 'enc:v1:'; -function openDB(): Promise { - return new Promise((resolve, reject) => { +const openDB = (): Promise => + new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => reject(new Error(String(request.error))); request.onsuccess = () => resolve(request.result); @@ -19,9 +19,8 @@ function openDB(): Promise { } }; }); -} -async function getRaw(id: string): Promise { +const getRaw = async (id: string): Promise => { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readonly'); @@ -33,9 +32,9 @@ async function getRaw(id: string): Promise { }; request.onerror = () => reject(new Error(String(request.error))); }); -} +}; -async function setRaw(id: string, value: string): Promise { +const setRaw = async (id: string, value: string): Promise => { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readwrite'); @@ -44,9 +43,9 @@ async function setRaw(id: string, value: string): Promise { tx.oncomplete = () => resolve(); tx.onerror = () => reject(new Error(String(tx.error))); }); -} +}; -async function deleteRaw(id: string): Promise { +const deleteRaw = async (id: string): Promise => { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readwrite'); @@ -55,13 +54,13 @@ async function deleteRaw(id: string): Promise { tx.oncomplete = () => resolve(); tx.onerror = () => reject(new Error(String(tx.error))); }); -} +}; /** * Get the encryption key for the key-store. * Migrates existing legacy keys (stored as JWK in the same DB) to the secure crypto-store. */ -async function getStoreEncryptionKey(): Promise { +const getStoreEncryptionKey = async (): Promise => { // Use a dedicated ID for the key-store's key in the crypto-store const CRYPTO_KEY_ID = 'dks:key-store:encryption-key'; @@ -95,22 +94,22 @@ async function getStoreEncryptionKey(): Promise { // Final fallback: generate a new key if migration failed or no legacy key exists return await getOrCreateKey(CRYPTO_KEY_ID, { extractable: false }); -} +}; -async function encryptValue(plaintext: string): Promise { +const encryptValue = async (plaintext: string): Promise => { const key = await getStoreEncryptionKey(); const encrypted = await encrypt(plaintext, key); return ENCRYPTED_PREFIX + encrypted; -} +}; -async function decryptValue(encrypted: string): Promise { +const decryptValue = async (encrypted: string): Promise => { if (!encrypted.startsWith(ENCRYPTED_PREFIX)) { return encrypted; } const key = await getStoreEncryptionKey(); const ciphertext = encrypted.slice(ENCRYPTED_PREFIX.length); return await decrypt(ciphertext, key); -} +}; export const keyStore = { async get(id: string): Promise { diff --git a/src/lib/llm/__tests__/encryption-migration.test.ts b/src/lib/llm/__tests__/encryption-migration.test.ts index 469f5eae..4395c613 100644 --- a/src/lib/llm/__tests__/encryption-migration.test.ts +++ b/src/lib/llm/__tests__/encryption-migration.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { encryptApiKey, decryptApiKey } from '../encryption'; import { deleteKey, hasKey, getOrCreateKey } from '../../crypto'; @@ -39,7 +39,7 @@ describe('LLM Encryption Secure Migration', () => { const combined = new Uint8Array(iv.length + encrypted.byteLength); combined.set(iv, 0); combined.set(new Uint8Array(encrypted), iv.length); - const legacyEncryptedValue = 'enc:v1:' + btoa(String.fromCharCode(...combined)); + const legacyEncryptedValue = `enc:v1:${btoa(String.fromCharCode(...combined))}`; // 3. Decrypt it — should trigger migration const decrypted = await decryptApiKey(legacyEncryptedValue); diff --git a/src/lib/llm/encryption.ts b/src/lib/llm/encryption.ts index 05de0711..63e810ef 100644 --- a/src/lib/llm/encryption.ts +++ b/src/lib/llm/encryption.ts @@ -8,7 +8,7 @@ const ENCRYPTED_PREFIX = 'enc:v1:'; * Get the AES-GCM encryption key from the secure crypto-store. * Migrates existing legacy keys from localStorage to IndexedDB. */ -async function getKey(): Promise { +const getKey = async (): Promise => { const CRYPTO_KEY_ID = 'dks:llm:encryption-key'; try { @@ -39,7 +39,7 @@ async function getKey(): Promise { } return await getOrCreateKey(CRYPTO_KEY_ID, { extractable: false }); -} +}; /** * Encrypt a plaintext string using AES-GCM.