Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 38 additions & 18 deletions src/lib/crypto/key-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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;
}

Expand Down
112 changes: 112 additions & 0 deletions tests/key-manager-storage-key.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading