diff --git a/src/lib/crypto/key-manager.js b/src/lib/crypto/key-manager.js index b1f88b8..d2cd6ab 100644 --- a/src/lib/crypto/key-manager.js +++ b/src/lib/crypto/key-manager.js @@ -5,6 +5,7 @@ import { browser } from '$app/environment'; import { Base64, CryptoUtils } from './index.js'; +import { indexedDBManager } from './indexed-db-manager.js'; import { MasterKeyDerivation } from './master-key-derivation.js'; /** @@ -29,35 +30,54 @@ export class KeyManager { if (!browser) throw new Error('Storage encryption only available in browser'); - // Try to load from localStorage (persists across sessions) - const storedRaw = localStorage.getItem(this.storageEncryptionKeyName); - if (storedRaw) { - const rawBytes = Base64.decode(storedRaw); - this._storageEncKey = await crypto.subtle.importKey( - 'raw', rawBytes, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] + // Try to load from IndexedDB (persistent non-extractable CryptoKey handle) + try { + const storedKey = await indexedDBManager.get(this.storageEncryptionKeyName); + if (storedKey) { + this._storageEncKey = storedKey; + return this._storageEncKey; + } + } catch (error) { + console.warn( + '🔑 Failed to load storage encryption key from IndexedDB; attempting migration or key generation:', + error ); - return this._storageEncKey; } - // Also check sessionStorage for migration from old code + // Migrate legacy key material stored in local/session storage to IndexedDB key handle + const storedRaw = localStorage.getItem(this.storageEncryptionKeyName); const sessionRaw = sessionStorage.getItem(this.storageEncryptionKeyName); - if (sessionRaw) { - // Migrate to localStorage - localStorage.setItem(this.storageEncryptionKeyName, sessionRaw); - sessionStorage.removeItem(this.storageEncryptionKeyName); - const rawBytes = Base64.decode(sessionRaw); + const legacyRaw = storedRaw || sessionRaw; + if (legacyRaw) { + const rawBytes = Base64.decode(legacyRaw); this._storageEncKey = await crypto.subtle.importKey( - 'raw', rawBytes, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] + 'raw', rawBytes, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] ); + localStorage.removeItem(this.storageEncryptionKeyName); + sessionStorage.removeItem(this.storageEncryptionKeyName); + + try { + await indexedDBManager.set(this.storageEncryptionKeyName, this._storageEncKey); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to persist migrated storage key in IndexedDB: ${errorMessage}`); + } + return this._storageEncKey; } - // Generate a new random encryption key + // Generate a new non-extractable random encryption key this._storageEncKey = await crypto.subtle.generateKey( - { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] + { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] ); - const exported = await crypto.subtle.exportKey('raw', this._storageEncKey); - localStorage.setItem(this.storageEncryptionKeyName, Base64.encode(new Uint8Array(exported))); + + try { + await indexedDBManager.set(this.storageEncryptionKeyName, this._storageEncKey); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to persist storage encryption key in IndexedDB: ${errorMessage}`); + } + return this._storageEncKey; } diff --git a/tests/key-manager-storage-key.test.js b/tests/key-manager-storage-key.test.js new file mode 100644 index 0000000..8055a11 --- /dev/null +++ b/tests/key-manager-storage-key.test.js @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Base64 } from '../src/lib/crypto/index.js'; +import { KeyManager } from '../src/lib/crypto/key-manager.js'; +import { indexedDBManager } from '../src/lib/crypto/indexed-db-manager.js'; + +vi.mock('$app/environment', () => ({ + browser: true +})); + +vi.mock('../src/lib/crypto/indexed-db-manager.js', () => { + const db = new Map(); + return { + indexedDBManager: { + get: vi.fn(async (id) => db.get(id) || null), + set: vi.fn(async (id, value) => { + db.set(id, value); + }), + clearForTesting: () => db.clear() + } + }; +}); + +const createStorageMock = () => { + const store = {}; + return { + getItem: vi.fn((key) => (key in store ? store[key] : null)), + setItem: vi.fn((key, value) => { + store[key] = value; + }), + removeItem: vi.fn((key) => { + delete store[key]; + }), + clear: vi.fn(() => { + for (const key of Object.keys(store)) { + delete store[key]; + } + }) + }; +}; + +describe('KeyManager storage encryption key hardening', () => { + let keyManager; + let generateKeySpy; + let importKeySpy; + + beforeEach(() => { + keyManager = new KeyManager(); + + global.localStorage = createStorageMock(); + global.sessionStorage = createStorageMock(); + + indexedDBManager.clearForTesting(); + indexedDBManager.get.mockClear(); + indexedDBManager.set.mockClear(); + + generateKeySpy = vi + .spyOn(globalThis.crypto.subtle, 'generateKey') + .mockResolvedValue({ id: 'generated-storage-key' }); + importKeySpy = vi + .spyOn(globalThis.crypto.subtle, 'importKey') + .mockResolvedValue({ id: 'migrated-storage-key' }); + }); + + it('stores new storage encryption key as non-extractable key handle in IndexedDB', async () => { + const storageKey = await keyManager._getStorageEncryptionKey(); + + expect(storageKey).toEqual({ id: 'generated-storage-key' }); + expect(generateKeySpy).toHaveBeenCalledWith( + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + expect(indexedDBManager.set).toHaveBeenCalledWith('qryptchat_storage_enc_key', storageKey); + expect(localStorage.getItem('qryptchat_storage_enc_key')).toBeNull(); + }); + + it('migrates legacy Base64 key from localStorage into non-extractable IndexedDB key handle', async () => { + const legacyKeyBytes = new Uint8Array(32).fill(7); + localStorage.setItem('qryptchat_storage_enc_key', Base64.encode(legacyKeyBytes)); + + const storageKey = await keyManager._getStorageEncryptionKey(); + + expect(storageKey).toEqual({ id: 'migrated-storage-key' }); + expect(importKeySpy).toHaveBeenCalledWith( + 'raw', + legacyKeyBytes, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + expect(indexedDBManager.set).toHaveBeenCalledWith('qryptchat_storage_enc_key', storageKey); + expect(localStorage.getItem('qryptchat_storage_enc_key')).toBeNull(); + }); + + it('migrates legacy Base64 key from sessionStorage into non-extractable IndexedDB key handle', async () => { + const legacyKeyBytes = new Uint8Array(32).fill(9); + sessionStorage.setItem('qryptchat_storage_enc_key', Base64.encode(legacyKeyBytes)); + + const storageKey = await keyManager._getStorageEncryptionKey(); + + expect(storageKey).toEqual({ id: 'migrated-storage-key' }); + expect(importKeySpy).toHaveBeenCalledWith( + 'raw', + legacyKeyBytes, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + expect(indexedDBManager.set).toHaveBeenCalledWith('qryptchat_storage_enc_key', storageKey); + expect(sessionStorage.getItem('qryptchat_storage_enc_key')).toBeNull(); + }); +});