From 3f7490e1bac492fee2724f8a49a579a528318303 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:44:30 +0000 Subject: [PATCH 1/3] Initial plan From 66174b58ecac7fe2813b1d5e03376a59fab47368 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:55:57 +0000 Subject: [PATCH 2/3] fix: move storage encryption key to IndexedDB non-extractable handle --- src/lib/crypto/key-manager.js | 53 +++++++++------ tests/key-manager-storage-key.test.js | 93 +++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 19 deletions(-) create mode 100644 tests/key-manager-storage-key.test.js diff --git a/src/lib/crypto/key-manager.js b/src/lib/crypto/key-manager.js index b1f88b8..d28276a 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,49 @@ 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'] - ); - return this._storageEncKey; + // 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:', error); } - // 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) { + console.warn('🔑 Failed to persist migrated storage key in IndexedDB:', error); + } + 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) { + console.warn('🔑 Failed to persist storage encryption key in IndexedDB:', error); + } + 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..baa3a66 --- /dev/null +++ b/tests/key-manager-storage-key.test.js @@ -0,0 +1,93 @@ +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); + }), + __clear: () => 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.__clear(); + 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 () => { + localStorage.setItem('qryptchat_storage_enc_key', Base64.encode(new Uint8Array([1, 2, 3, 4]))); + + const storageKey = await keyManager._getStorageEncryptionKey(); + + expect(storageKey).toEqual({ id: 'migrated-storage-key' }); + expect(importKeySpy).toHaveBeenCalledWith( + 'raw', + new Uint8Array([1, 2, 3, 4]), + { 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(); + }); +}); From 7671528b94a46f7815ef1e46c3e7148e6bc86a0b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:59:37 +0000 Subject: [PATCH 3/3] test: cover sessionStorage migration for storage encryption key --- src/lib/crypto/key-manager.js | 11 ++++++++--- tests/key-manager-storage-key.test.js | 27 +++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/lib/crypto/key-manager.js b/src/lib/crypto/key-manager.js index d28276a..d2cd6ab 100644 --- a/src/lib/crypto/key-manager.js +++ b/src/lib/crypto/key-manager.js @@ -38,7 +38,10 @@ export class KeyManager { return this._storageEncKey; } } catch (error) { - console.warn('🔑 Failed to load storage encryption key from IndexedDB:', error); + console.warn( + '🔑 Failed to load storage encryption key from IndexedDB; attempting migration or key generation:', + error + ); } // Migrate legacy key material stored in local/session storage to IndexedDB key handle @@ -56,7 +59,8 @@ export class KeyManager { try { await indexedDBManager.set(this.storageEncryptionKeyName, this._storageEncKey); } catch (error) { - console.warn('🔑 Failed to persist migrated storage key in IndexedDB:', 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; @@ -70,7 +74,8 @@ export class KeyManager { try { await indexedDBManager.set(this.storageEncryptionKeyName, this._storageEncKey); } catch (error) { - console.warn('🔑 Failed to persist storage encryption key in IndexedDB:', 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 index baa3a66..8055a11 100644 --- a/tests/key-manager-storage-key.test.js +++ b/tests/key-manager-storage-key.test.js @@ -15,7 +15,7 @@ vi.mock('../src/lib/crypto/indexed-db-manager.js', () => { set: vi.fn(async (id, value) => { db.set(id, value); }), - __clear: () => db.clear() + clearForTesting: () => db.clear() } }; }); @@ -49,7 +49,7 @@ describe('KeyManager storage encryption key hardening', () => { global.localStorage = createStorageMock(); global.sessionStorage = createStorageMock(); - indexedDBManager.__clear(); + indexedDBManager.clearForTesting(); indexedDBManager.get.mockClear(); indexedDBManager.set.mockClear(); @@ -75,14 +75,15 @@ describe('KeyManager storage encryption key hardening', () => { }); it('migrates legacy Base64 key from localStorage into non-extractable IndexedDB key handle', async () => { - localStorage.setItem('qryptchat_storage_enc_key', Base64.encode(new Uint8Array([1, 2, 3, 4]))); + 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', - new Uint8Array([1, 2, 3, 4]), + legacyKeyBytes, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] @@ -90,4 +91,22 @@ describe('KeyManager storage encryption key hardening', () => { 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(); + }); });