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:
iven
2026-03-21 17:13:50 +08:00
parent a199434e08
commit d266a1435f
2 changed files with 324 additions and 20 deletions

View File

@@ -2,20 +2,36 @@
* ZCLAW Secure Storage
*
* Provides secure credential storage using the OS keyring/keychain.
* Falls back to localStorage when not running in Tauri or if keyring is unavailable.
* Falls back to encrypted localStorage when not running in Tauri or if keyring is unavailable.
*
* Platform support:
* - Windows: DPAPI
* - macOS: Keychain
* - Linux: Secret Service API (gnome-keyring, kwallet, etc.)
* - Fallback: AES-GCM encrypted localStorage
*/
import { invoke } from '@tauri-apps/api/core';
import { isTauriRuntime } from './tauri-gateway';
import {
arrayToBase64,
base64ToArray,
deriveKey,
encrypt,
decrypt,
generateMasterKey,
} from './crypto-utils';
// Cache for keyring availability check
let keyringAvailable: boolean | null = null;
// Encryption constants for localStorage fallback
const ENCRYPTED_PREFIX = 'enc_';
const MASTER_KEY_NAME = 'zclaw-master-key';
// Cache for the derived crypto key
let cachedCryptoKey: CryptoKey | null = null;
/**
* Check if secure storage (keyring) is available
*/
@@ -41,7 +57,7 @@ export async function isSecureStorageAvailable(): Promise<boolean> {
/**
* Secure storage interface
* Uses OS keyring when available, falls back to localStorage
* Uses OS keyring when available, falls back to encrypted localStorage
*/
export const secureStorage = {
/**
@@ -59,16 +75,16 @@ export const secureStorage = {
} else {
await invoke('secure_store_delete', { key });
}
// Also write to localStorage as backup/migration support
writeLocalStorageBackup(key, trimmedValue);
// Also write encrypted backup to localStorage for migration support
await writeEncryptedLocalStorage(key, trimmedValue);
return;
} catch (error) {
console.warn('[SecureStorage] Failed to use keyring, falling back to localStorage:', error);
console.warn('[SecureStorage] Failed to use keyring, falling back to encrypted localStorage:', error);
}
}
// Fallback to localStorage
writeLocalStorageBackup(key, trimmedValue);
// Fallback to encrypted localStorage
await writeEncryptedLocalStorage(key, trimmedValue);
},
/**
@@ -83,15 +99,15 @@ export const secureStorage = {
if (value !== null && value !== undefined && value !== '') {
return value;
}
// If keyring returned empty, try localStorage fallback for migration
return readLocalStorageBackup(key);
// If keyring returned empty, try encrypted localStorage fallback for migration
return await readEncryptedLocalStorage(key);
} catch (error) {
console.warn('[SecureStorage] Failed to read from keyring, trying localStorage:', error);
console.warn('[SecureStorage] Failed to read from keyring, trying encrypted localStorage:', error);
}
}
// Fallback to localStorage
return readLocalStorageBackup(key);
// Fallback to encrypted localStorage
return await readEncryptedLocalStorage(key);
},
/**
@@ -121,7 +137,116 @@ export const secureStorage = {
/**
* localStorage backup functions for migration and fallback
* Now with AES-GCM encryption for non-Tauri environments
*/
/**
* Get or create the master encryption key for localStorage fallback
*/
async function getOrCreateMasterKey(): Promise<CryptoKey> {
if (cachedCryptoKey) {
return cachedCryptoKey;
}
let masterKeyRaw = localStorage.getItem(MASTER_KEY_NAME);
if (!masterKeyRaw) {
masterKeyRaw = generateMasterKey();
localStorage.setItem(MASTER_KEY_NAME, masterKeyRaw);
}
cachedCryptoKey = await deriveKey(masterKeyRaw);
return cachedCryptoKey;
}
/**
* Check if a stored value is encrypted (has iv and data fields)
*/
function isEncrypted(value: string): boolean {
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed.iv === 'string' && typeof parsed.data === 'string';
} catch {
return false;
}
}
/**
* Write encrypted data to localStorage
*/
async function writeEncryptedLocalStorage(key: string, value: string): Promise<void> {
try {
const encryptedKey = ENCRYPTED_PREFIX + key;
if (!value) {
localStorage.removeItem(encryptedKey);
// Also remove legacy unencrypted key
localStorage.removeItem(key);
return;
}
try {
const cryptoKey = await getOrCreateMasterKey();
const encrypted = await encrypt(value, cryptoKey);
localStorage.setItem(encryptedKey, JSON.stringify(encrypted));
// Remove legacy unencrypted key if it exists
localStorage.removeItem(key);
} catch (error) {
console.error('[SecureStorage] Encryption failed:', error);
// Fallback to plaintext if encryption fails (should not happen)
localStorage.setItem(key, value);
}
} catch {
// Ignore localStorage failures
}
}
/**
* Read and decrypt data from localStorage
* Supports both encrypted and legacy unencrypted formats
*/
async function readEncryptedLocalStorage(key: string): Promise<string | null> {
try {
// Try encrypted key first
const encryptedKey = ENCRYPTED_PREFIX + key;
const encryptedRaw = localStorage.getItem(encryptedKey);
if (encryptedRaw && isEncrypted(encryptedRaw)) {
try {
const cryptoKey = await getOrCreateMasterKey();
const encrypted = JSON.parse(encryptedRaw);
return await decrypt(encrypted, cryptoKey);
} catch (error) {
console.error('[SecureStorage] Decryption failed:', error);
// Fall through to try legacy key
}
}
// Try legacy unencrypted key for backward compatibility
const legacyValue = localStorage.getItem(key);
if (legacyValue !== null) {
return legacyValue;
}
return null;
} catch {
return null;
}
}
/**
* Clear data from localStorage (both encrypted and legacy)
*/
function clearLocalStorageBackup(key: string): void {
try {
localStorage.removeItem(ENCRYPTED_PREFIX + key);
localStorage.removeItem(key);
} catch {
// Ignore localStorage failures
}
}
// Keep synchronous versions for backward compatibility (deprecated)
function writeLocalStorageBackup(key: string, value: string): void {
try {
if (value) {
@@ -142,14 +267,6 @@ function readLocalStorageBackup(key: string): string | null {
}
}
function clearLocalStorageBackup(key: string): void {
try {
localStorage.removeItem(key);
} catch {
// Ignore localStorage failures
}
}
/**
* Synchronous versions for compatibility with existing code
* These use localStorage only and are provided for gradual migration

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