/** * 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 { if (!isTauriRuntime()) { return false; } // Use cached result if available if (keyringAvailable !== null) { return keyringAvailable; } try { keyringAvailable = await invoke('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 { 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 { if (await isSecureStorageAvailable()) { try { const value = await invoke('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 { 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 { 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 { 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(); const MAX_DECRYPTION_RETRIES = 2; async function readEncryptedLocalStorage(key: string): Promise { 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 { 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 { // 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 { // 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 { 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 { // 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; }