feat(security): add AES-GCM encryption for localStorage fallback
- Encrypt credentials before storing in localStorage when OS keyring unavailable - Decrypt on retrieval with automatic fallback - Backward compatible with existing unencrypted data (migration on next set) - Add comprehensive unit tests (11 test cases) Security: Credentials are now encrypted using AES-GCM when OS keyring is unavailable, preventing plaintext exposure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
187
desktop/tests/lib/secure-storage.test.ts
Normal file
187
desktop/tests/lib/secure-storage.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Tests for secure-storage.ts
|
||||
*
|
||||
* These tests verify that credentials are encrypted when stored in localStorage
|
||||
* (fallback mode when OS keyring is unavailable).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock Tauri runtime to return false (non-Tauri environment)
|
||||
vi.mock('../../src/lib/tauri-gateway', () => ({
|
||||
isTauriRuntime: () => false,
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
import { secureStorage } from '../../src/lib/secure-storage';
|
||||
|
||||
describe('secureStorage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('encryption fallback', () => {
|
||||
it('should encrypt data when storing to localStorage', async () => {
|
||||
const key = 'test-key';
|
||||
const value = 'secret-value';
|
||||
|
||||
await secureStorage.set(key, value);
|
||||
|
||||
// Check that localStorage doesn't contain plaintext
|
||||
const encryptedKey = 'enc_' + key;
|
||||
const stored = localStorage.getItem(encryptedKey);
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored).not.toBe(value);
|
||||
expect(stored).not.toContain('secret-value');
|
||||
|
||||
// Should be JSON with iv and data fields
|
||||
const parsed = JSON.parse(stored!);
|
||||
expect(parsed).toHaveProperty('iv');
|
||||
expect(parsed).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should decrypt data when retrieving from localStorage', async () => {
|
||||
const key = 'test-key';
|
||||
const value = 'secret-value';
|
||||
|
||||
await secureStorage.set(key, value);
|
||||
const retrieved = await secureStorage.get(key);
|
||||
|
||||
expect(retrieved).toBe(value);
|
||||
});
|
||||
|
||||
it('should handle special characters in values', async () => {
|
||||
const key = 'test-key';
|
||||
const value = 'p@ssw0rd!#$%^&*(){}[]|\\:";\'<>?,./~`';
|
||||
|
||||
await secureStorage.set(key, value);
|
||||
const retrieved = await secureStorage.get(key);
|
||||
|
||||
expect(retrieved).toBe(value);
|
||||
});
|
||||
|
||||
it('should handle Unicode characters in values', async () => {
|
||||
const key = 'test-key';
|
||||
const value = '密码测试123テスト🔑🔐';
|
||||
|
||||
await secureStorage.set(key, value);
|
||||
const retrieved = await secureStorage.get(key);
|
||||
|
||||
expect(retrieved).toBe(value);
|
||||
});
|
||||
|
||||
it('should handle empty string by removing the key', async () => {
|
||||
const key = 'test-key';
|
||||
const value = 'initial-value';
|
||||
|
||||
await secureStorage.set(key, value);
|
||||
expect(await secureStorage.get(key)).toBe(value);
|
||||
|
||||
// Setting empty string should remove the key
|
||||
await secureStorage.set(key, '');
|
||||
const encryptedKey = 'enc_' + key;
|
||||
expect(localStorage.getItem(encryptedKey)).toBeNull();
|
||||
expect(await secureStorage.get(key)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle null by removing the key', async () => {
|
||||
const key = 'test-key';
|
||||
const value = 'initial-value';
|
||||
|
||||
await secureStorage.set(key, value);
|
||||
expect(await secureStorage.get(key)).toBe(value);
|
||||
|
||||
// Delete should remove the key
|
||||
await secureStorage.delete(key);
|
||||
const encryptedKey = 'enc_' + key;
|
||||
expect(localStorage.getItem(encryptedKey)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('backward compatibility', () => {
|
||||
it('should read unencrypted legacy data', async () => {
|
||||
const key = 'legacy-key';
|
||||
const value = 'legacy-value';
|
||||
|
||||
// Simulate legacy unencrypted storage
|
||||
localStorage.setItem(key, value);
|
||||
|
||||
const retrieved = await secureStorage.get(key);
|
||||
expect(retrieved).toBe(value);
|
||||
});
|
||||
|
||||
it('should migrate unencrypted data to encrypted on next set', async () => {
|
||||
const key = 'legacy-key';
|
||||
const value = 'legacy-value';
|
||||
const newValue = 'new-encrypted-value';
|
||||
|
||||
// Simulate legacy unencrypted storage
|
||||
localStorage.setItem(key, value);
|
||||
|
||||
// Read should return legacy value
|
||||
const retrieved = await secureStorage.get(key);
|
||||
expect(retrieved).toBe(value);
|
||||
|
||||
// Write should encrypt the new value
|
||||
await secureStorage.set(key, newValue);
|
||||
|
||||
// Legacy key should be removed, encrypted key should exist
|
||||
expect(localStorage.getItem(key)).toBeNull();
|
||||
const encryptedKey = 'enc_' + key;
|
||||
const stored = localStorage.getItem(encryptedKey);
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored).not.toContain(newValue);
|
||||
|
||||
// Should retrieve the new encrypted value
|
||||
expect(await secureStorage.get(key)).toBe(newValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encryption strength', () => {
|
||||
it('should use different IV for each encryption', async () => {
|
||||
const key = 'test-key';
|
||||
const value = 'same-value';
|
||||
|
||||
await secureStorage.set(key, value);
|
||||
const encrypted1 = localStorage.getItem('enc_' + key);
|
||||
|
||||
await secureStorage.set(key, value);
|
||||
const encrypted2 = localStorage.getItem('enc_' + key);
|
||||
|
||||
// Both should be encrypted versions of the same value
|
||||
expect(encrypted1).not.toBe(encrypted2);
|
||||
|
||||
// But both should decrypt to the same value
|
||||
const parsed1 = JSON.parse(encrypted1!);
|
||||
const parsed2 = JSON.parse(encrypted2!);
|
||||
expect(parsed1.iv).not.toBe(parsed2.iv); // Different IVs
|
||||
expect(parsed1.data).not.toBe(parsed2.data); // Different ciphertext
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should return null for non-existent keys', async () => {
|
||||
const retrieved = await secureStorage.get('non-existent-key');
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle corrupted encrypted data gracefully', async () => {
|
||||
const key = 'corrupted-key';
|
||||
const value = 'valid-value';
|
||||
|
||||
// Store valid encrypted data
|
||||
await secureStorage.set(key, value);
|
||||
|
||||
// Corrupt the encrypted data
|
||||
const encryptedKey = 'enc_' + key;
|
||||
const encrypted = localStorage.getItem(encryptedKey);
|
||||
const parsed = JSON.parse(encrypted!);
|
||||
parsed.data = 'corrupted-data';
|
||||
localStorage.setItem(encryptedKey, JSON.stringify(parsed));
|
||||
|
||||
// Should return null for corrupted data
|
||||
const retrieved = await secureStorage.get(key);
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user