Files
zclaw_openfang/desktop/src/lib/secure-storage.ts
iven af0acff2aa
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(desktop): QA 驱动的 6 项缺陷修复
P0-C1: SecureStorage 解密失败上限 — 添加 per-key 失败计数器,
超过 2 次自动清除过期加密数据,阻断无限重试循环

P0-C2: Bootstrap 空指针防护 — connectionStore 中 relayModels[0]?.id
添加 null guard,抛出用户友好错误

P1-H1: 侧边栏对话列表去重 — ConversationList 添加按 ID 去重逻辑,
保留最新版本后按 updatedAt 排序

P1-H2: 搜索框过滤生效 — Sidebar 传递 searchQuery 给 ConversationList,
支持按标题和消息内容过滤

P1-H3: 模型选择器 fallback — 当 SaaS 和 config 均无模型时,
提供 6 个默认模型(GLM/GPT/DeepSeek/Qwen/Claude)

P1-H4: 详情面板错误友好化 — RightPanel 中 JS 错误替换为
'连接状态获取失败,请重新连接'

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 07:57:53 +08:00

498 lines
14 KiB
TypeScript

/**
* ZCLAW Secure Storage
*
* Provides secure credential storage using the OS keyring/keychain.
* 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 {
deriveKey,
encrypt,
decrypt,
generateMasterKey,
generateSalt,
arrayToBase64,
base64ToArray,
} from './crypto-utils';
import { createLogger } from './logger';
const logger = createLogger('secure-storage');
// 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';
/**
* Check if secure storage (keyring) is available
*/
export async function isSecureStorageAvailable(): Promise<boolean> {
if (!isTauriRuntime()) {
return false;
}
// Use cached result if available
if (keyringAvailable !== null) {
return keyringAvailable;
}
try {
keyringAvailable = await invoke<boolean>('secure_store_is_available');
return keyringAvailable;
} catch (error) {
console.warn('[SecureStorage] Keyring not available:', error);
keyringAvailable = false;
return false;
}
}
/**
* Secure storage interface
* Uses OS keyring when available, falls back to encrypted localStorage
*/
export const secureStorage = {
/**
* Store a value securely
* @param key - Storage key
* @param value - Value to store
*/
async set(key: string, value: string): Promise<void> {
const trimmedValue = value.trim();
if (await isSecureStorageAvailable()) {
try {
if (trimmedValue) {
await invoke('secure_store_set', { key, value: trimmedValue });
} else {
await invoke('secure_store_delete', { key });
}
// 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 encrypted localStorage:', error);
}
}
// Fallback to encrypted localStorage
await writeEncryptedLocalStorage(key, trimmedValue);
},
/**
* Retrieve a value from secure storage
* @param key - Storage key
* @returns Stored value or null if not found
*/
async get(key: string): Promise<string | null> {
if (await isSecureStorageAvailable()) {
try {
const value = await invoke<string>('secure_store_get', { key });
if (value !== null && value !== undefined && value !== '') {
return value;
}
// 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 encrypted localStorage:', error);
}
}
// Fallback to encrypted localStorage
return await readEncryptedLocalStorage(key);
},
/**
* Delete a value from secure storage
* @param key - Storage key
*/
async delete(key: string): Promise<void> {
if (await isSecureStorageAvailable()) {
try {
await invoke('secure_store_delete', { key });
} catch (error) {
console.warn('[SecureStorage] Failed to delete from keyring:', error);
}
}
// Always clear localStorage backup
clearLocalStorageBackup(key);
},
/**
* Check if secure storage is being used (vs localStorage fallback)
*/
async isUsingKeyring(): Promise<boolean> {
return isSecureStorageAvailable();
},
};
/**
* localStorage backup functions for migration and fallback
* Now with AES-GCM encryption for non-Tauri environments
*/
/**
* 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 (e) {
logger.debug('isEncrypted check failed', { error: e });
return false;
}
}
/**
* Check if a stored value uses the v2 format (with random salt)
*/
function isV2Encrypted(value: string): boolean {
try {
const parsed = JSON.parse(value);
return parsed && parsed.version === 2 && typeof parsed.salt === 'string' && typeof parsed.iv === 'string' && typeof parsed.data === 'string';
} catch (e) {
logger.debug('isV2Encrypted check failed', { error: e });
return false;
}
}
/**
* Write encrypted data to localStorage
* Uses random salt per encryption (v2 format) for forward secrecy
*/
async function writeEncryptedLocalStorage(key: string, value: string): Promise<void> {
try {
const encryptedKey = ENCRYPTED_PREFIX + key;
if (!value) {
localStorage.removeItem(encryptedKey);
localStorage.removeItem(key);
return;
}
try {
let masterKeyRaw = localStorage.getItem(MASTER_KEY_NAME);
if (!masterKeyRaw) {
masterKeyRaw = generateMasterKey();
localStorage.setItem(MASTER_KEY_NAME, masterKeyRaw);
}
// Generate a random salt for each encryption (v2 format)
const salt = generateSalt(16);
const cryptoKey = await deriveKey(masterKeyRaw, salt);
const encrypted = await encrypt(value, cryptoKey);
const encryptedPayload = {
version: 2,
salt: arrayToBase64(salt),
iv: encrypted.iv,
data: encrypted.data,
};
localStorage.setItem(encryptedKey, JSON.stringify(encryptedPayload));
localStorage.removeItem(key);
} catch (error) {
console.error('[SecureStorage] Encryption failed:', error);
// Do NOT fall back to plaintext — throw to signal the error
throw error;
}
} catch (error) {
console.error('[SecureStorage] Failed to write encrypted localStorage:', error);
throw error;
}
}
/**
* Read and decrypt data from localStorage
* Supports v2 (random salt), v1 (static salt), and legacy unencrypted formats
*/
// Track per-key decryption failures to break infinite retry loops
const decryptionFailures = new Map<string, number>();
const MAX_DECRYPTION_RETRIES = 2;
async function readEncryptedLocalStorage(key: string): Promise<string | null> {
try {
// If this key has already failed too many times, skip immediately
const failCount = decryptionFailures.get(key) ?? 0;
if (failCount >= MAX_DECRYPTION_RETRIES) {
return null;
}
const encryptedKey = ENCRYPTED_PREFIX + key;
const encryptedRaw = localStorage.getItem(encryptedKey);
if (encryptedRaw) {
const masterKeyRaw = localStorage.getItem(MASTER_KEY_NAME);
// Try v2 format (random salt)
if (masterKeyRaw && isV2Encrypted(encryptedRaw)) {
try {
const parsed = JSON.parse(encryptedRaw);
const salt = base64ToArray(parsed.salt);
const cryptoKey = await deriveKey(masterKeyRaw, salt);
const result = await decrypt(
{ iv: parsed.iv, data: parsed.data },
cryptoKey,
);
decryptionFailures.delete(key);
return result;
} catch (error) {
console.warn('[SecureStorage] v2 decryption failed for key:', key);
decryptionFailures.set(key, failCount + 1);
// Fall through to try v1
}
}
// Try v1 format (static salt, backward compat)
if (masterKeyRaw && isEncrypted(encryptedRaw)) {
try {
const cryptoKey = await deriveKey(masterKeyRaw); // uses legacy static salt
const encrypted = JSON.parse(encryptedRaw);
const result = await decrypt(encrypted, cryptoKey);
decryptionFailures.delete(key);
return result;
} catch (error) {
console.warn('[SecureStorage] v1 decryption failed for key:', key);
decryptionFailures.set(key, failCount + 1);
// Both v2 and v1 failed — clear stale encrypted data to stop retrying
if (failCount + 1 >= MAX_DECRYPTION_RETRIES) {
localStorage.removeItem(encryptedKey);
console.warn('[SecureStorage] Cleared stale encrypted data for key:', key);
}
}
}
}
// Try legacy unencrypted key for backward compatibility
const legacyValue = localStorage.getItem(key);
if (legacyValue !== null) {
return legacyValue;
}
return null;
} catch (e) {
logger.debug('readEncryptedLocalStorage failed', { error: e });
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 (e) {
logger.debug('clearLocalStorageBackup failed', { error: e });
}
}
// === Device Keys Secure Storage ===
/**
* Storage keys for Ed25519 device keys
*/
const DEVICE_KEYS_PRIVATE_KEY = 'zclaw_device_keys_private';
const DEVICE_KEYS_PUBLIC_KEY = 'zclaw_device_keys_public';
const DEVICE_KEYS_CREATED = 'zclaw_device_keys_created';
const DEVICE_KEYS_LEGACY = 'zclaw_device_keys'; // Old format for migration
/**
* Ed25519 SignKeyPair interface (compatible with tweetnacl)
*/
export interface Ed25519KeyPair {
publicKey: Uint8Array;
secretKey: Uint8Array;
}
/**
* Legacy device keys format (stored in localStorage)
* Used for migration from the old format.
*/
interface LegacyDeviceKeys {
deviceId: string;
publicKeyBase64: string;
secretKeyBase64: string;
}
/**
* Base64 URL-safe encode (without padding)
*/
function base64UrlEncode(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/**
* Base64 URL-safe decode
*/
function base64UrlDecode(str: string): Uint8Array {
str = str.replace(/-/g, '+').replace(/_/g, '/');
while (str.length % 4) str += '=';
const binary = atob(str);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* Store device keys securely.
* The secret key is stored in the OS keyring when available,
* falling back to localStorage with a warning.
*
* @param publicKey - Ed25519 public key (32 bytes)
* @param secretKey - Ed25519 secret key (64 bytes)
*/
export async function storeDeviceKeys(
publicKey: Uint8Array,
secretKey: Uint8Array
): Promise<void> {
const publicKeyBase64 = base64UrlEncode(publicKey);
const secretKeyBase64 = base64UrlEncode(secretKey);
const createdAt = Date.now().toString();
if (await isSecureStorageAvailable()) {
// Store secret key in keyring (most secure)
await secureStorage.set(DEVICE_KEYS_PRIVATE_KEY, secretKeyBase64);
// Public key and metadata can go to localStorage (non-sensitive)
localStorage.setItem(DEVICE_KEYS_PUBLIC_KEY, publicKeyBase64);
localStorage.setItem(DEVICE_KEYS_CREATED, createdAt);
// Clear legacy format if present
try {
localStorage.removeItem(DEVICE_KEYS_LEGACY);
} catch (e) {
logger.debug('Failed to clear legacy device keys from localStorage', { error: e });
}
} else {
// Fallback: store in localStorage (less secure, but better than nothing)
console.warn(
'[SecureStorage] Keyring not available, using localStorage fallback for device keys. ' +
'Consider running in Tauri for secure key storage.'
);
localStorage.setItem(
DEVICE_KEYS_LEGACY,
JSON.stringify({
publicKeyBase64,
secretKeyBase64,
createdAt,
})
);
}
}
/**
* Retrieve device keys from secure storage.
* Attempts to read from keyring first, then falls back to localStorage.
* Also handles migration from the legacy format.
*
* @returns Key pair or null if not found
*/
export async function getDeviceKeys(): Promise<Ed25519KeyPair | null> {
// Try keyring storage first (new format)
if (await isSecureStorageAvailable()) {
const secretKeyBase64 = await secureStorage.get(DEVICE_KEYS_PRIVATE_KEY);
const publicKeyBase64 = localStorage.getItem(DEVICE_KEYS_PUBLIC_KEY);
if (secretKeyBase64 && publicKeyBase64) {
try {
return {
publicKey: base64UrlDecode(publicKeyBase64),
secretKey: base64UrlDecode(secretKeyBase64),
};
} catch (e) {
console.warn('[SecureStorage] Failed to decode keys from keyring:', e);
}
}
}
// Try legacy format (localStorage)
const legacyStored = localStorage.getItem(DEVICE_KEYS_LEGACY);
if (legacyStored) {
try {
const parsed: LegacyDeviceKeys = JSON.parse(legacyStored);
return {
publicKey: base64UrlDecode(parsed.publicKeyBase64),
secretKey: base64UrlDecode(parsed.secretKeyBase64),
};
} catch (e) {
console.warn('[SecureStorage] Failed to decode legacy keys:', e);
}
}
return null;
}
/**
* Delete device keys from all storage locations.
* Used when keys need to be regenerated.
*/
export async function deleteDeviceKeys(): Promise<void> {
// Delete from keyring
if (await isSecureStorageAvailable()) {
await secureStorage.delete(DEVICE_KEYS_PRIVATE_KEY);
}
// Delete from localStorage
try {
localStorage.removeItem(DEVICE_KEYS_PUBLIC_KEY);
localStorage.removeItem(DEVICE_KEYS_CREATED);
localStorage.removeItem(DEVICE_KEYS_LEGACY);
} catch (e) {
logger.debug('Failed to delete device keys from localStorage', { error: e });
}
}
/**
* Check if device keys exist in any storage.
*/
export async function hasDeviceKeys(): Promise<boolean> {
const keys = await getDeviceKeys();
return keys !== null;
}
/**
* Get the creation timestamp of stored device keys.
* Returns null if keys don't exist or timestamp is unavailable.
*/
export async function getDeviceKeysCreatedAt(): Promise<number | null> {
// Try new format
const created = localStorage.getItem(DEVICE_KEYS_CREATED);
if (created) {
const parsed = parseInt(created, 10);
if (!isNaN(parsed)) {
return parsed;
}
}
// Try legacy format
const legacyStored = localStorage.getItem(DEVICE_KEYS_LEGACY);
if (legacyStored) {
try {
const parsed = JSON.parse(legacyStored);
if (typeof parsed.createdAt === 'number' || typeof parsed.createdAt === 'string') {
return parseInt(String(parsed.createdAt), 10);
}
} catch (e) {
logger.debug('Failed to parse legacy device keys createdAt', { error: e });
}
}
return null;
}