Files
zclaw_openfang/desktop/tests/lib/secure-storage.test.ts
iven d266a1435f 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>
2026-03-21 17:13:50 +08:00

188 lines
5.9 KiB
TypeScript

/**
* 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();
});
});
});