security(phase-9): complete security hardening

- Add safeJsonParse utility with schema validation
- Migrate tokens to OS keyring storage
- Add Ed25519 key encryption at rest
- Enable WSS configuration option
- Fix JSON.parse in HandParamsForm, WorkflowEditor, WorkflowList
- Update test mock data to match valid status values

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-15 19:22:51 +08:00
parent e3d164e9d2
commit a6b1255dc0
10 changed files with 499 additions and 74 deletions

View File

@@ -179,3 +179,201 @@ export const secureStorageSync = {
clearLocalStorageBackup(key);
},
};
// === 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 {
// Ignore
}
} 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 {
// Ignore localStorage errors
}
}
/**
* 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 {
// Ignore
}
}
return null;
}