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
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>
498 lines
14 KiB
TypeScript
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;
|
|
}
|