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

@@ -10,16 +10,41 @@
* - WebSocket path: /ws
* - REST API: http://127.0.0.1:50051/api/*
* - Config format: TOML
*
* Security:
* - Device keys stored in OS keyring when available
* - Supports WSS (WebSocket Secure) for production
*/
import nacl from 'tweetnacl';
import type { GatewayConfigSnapshot, GatewayModelChoice } from './gateway-config';
import {
storeDeviceKeys,
getDeviceKeys,
deleteDeviceKeys,
} from './secure-storage';
// === WSS Configuration ===
/**
* Whether to use WSS (WebSocket Secure) instead of WS.
* Set VITE_USE_WSS=true in production environments.
*/
const USE_WSS = import.meta.env.VITE_USE_WSS === 'true';
/**
* Default protocol based on WSS configuration.
*/
const DEFAULT_WS_PROTOCOL = USE_WSS ? 'wss://' : 'ws://';
// OpenFang endpoints (actual port is 50051, not 4200)
// Note: REST API uses relative path to leverage Vite proxy for CORS bypass
export const DEFAULT_GATEWAY_URL = 'ws://127.0.0.1:50051/ws';
export const REST_API_URL = ''; // Empty = use relative path (Vite proxy)
export const FALLBACK_GATEWAY_URLS = [DEFAULT_GATEWAY_URL, 'ws://127.0.0.1:4200/ws'];
export const DEFAULT_GATEWAY_URL = `${DEFAULT_WS_PROTOCOL}127.0.0.1:50051/ws`;
export const REST_API_URL = ''; // Empty = use relative path (Vite proxy)
export const FALLBACK_GATEWAY_URLS = [
DEFAULT_GATEWAY_URL,
`${DEFAULT_WS_PROTOCOL}127.0.0.1:4200/ws`,
];
const GATEWAY_URL_STORAGE_KEY = 'zclaw_gateway_url';
const GATEWAY_TOKEN_STORAGE_KEY = 'zclaw_gateway_token';
@@ -114,7 +139,36 @@ export function setStoredGatewayToken(token: string): string {
} else {
localStorage.removeItem(GATEWAY_TOKEN_STORAGE_KEY);
}
} catch { /* ignore localStorage failures */ }
} catch {
/* ignore localStorage failures */
}
return normalized;
}
// === URL Normalization ===
/**
* Normalize a gateway URL to ensure correct protocol and path.
* - Ensures ws:// or wss:// protocol based on configuration
* - Ensures /ws path suffix
* - Handles both localhost and IP addresses
*/
export function normalizeGatewayUrl(url: string): string {
let normalized = url.trim();
// Remove trailing slashes except for protocol
normalized = normalized.replace(/\/+$/, '');
// Ensure protocol
if (!normalized.startsWith('ws://') && !normalized.startsWith('wss://')) {
normalized = USE_WSS ? `wss://${normalized}` : `ws://${normalized}`;
}
// Ensure /ws path
if (!normalized.endsWith('/ws')) {
normalized = `${normalized}/ws`;
}
return normalized;
}
@@ -152,41 +206,35 @@ async function generateDeviceKeys(): Promise<DeviceKeys> {
};
}
/**
* Load device keys from secure storage.
* Uses OS keyring when available, falls back to localStorage.
*/
async function loadDeviceKeys(): Promise<DeviceKeys> {
// Try to load from localStorage
const stored = localStorage.getItem('zclaw_device_keys');
if (stored) {
// Try to load from secure storage (keyring or localStorage fallback)
const storedKeys = await getDeviceKeys();
if (storedKeys) {
try {
const parsed = JSON.parse(stored);
const publicKey = b64Decode(parsed.publicKeyBase64);
const secretKey = b64Decode(parsed.secretKeyBase64);
const deviceId = await deriveDeviceId(publicKey);
// Validate that the stored deviceId matches the derived one
if (parsed.deviceId && parsed.deviceId !== deviceId) {
console.warn('[GatewayClient] Stored deviceId mismatch, regenerating keys');
throw new Error('Device ID mismatch');
}
const deviceId = await deriveDeviceId(storedKeys.publicKey);
return {
deviceId,
publicKey,
secretKey,
publicKeyBase64: parsed.publicKeyBase64,
publicKey: storedKeys.publicKey,
secretKey: storedKeys.secretKey,
publicKeyBase64: b64Encode(storedKeys.publicKey),
};
} catch (e) {
// Invalid stored keys, generate new ones
console.warn('[GatewayClient] Failed to load stored keys:', e);
// Invalid stored keys, clear and regenerate
await deleteDeviceKeys();
}
}
// Generate new keys
const keys = await generateDeviceKeys();
localStorage.setItem('zclaw_device_keys', JSON.stringify({
deviceId: keys.deviceId,
publicKeyBase64: keys.publicKeyBase64,
secretKeyBase64: b64Encode(keys.secretKey),
}));
// Store in secure storage (keyring when available, localStorage fallback)
await storeDeviceKeys(keys.publicKey, keys.secretKey);
return keys;
}
@@ -203,12 +251,12 @@ export async function getLocalDeviceIdentity(): Promise<LocalDeviceIdentity> {
* Clear cached device keys to force regeneration on next connect.
* Useful when device signature validation fails repeatedly.
*/
export function clearDeviceKeys(): void {
export async function clearDeviceKeys(): Promise<void> {
try {
localStorage.removeItem('zclaw_device_keys');
await deleteDeviceKeys();
console.log('[GatewayClient] Device keys cleared');
} catch {
// Ignore localStorage errors
} catch (e) {
console.warn('[GatewayClient] Failed to clear device keys:', e);
}
}
@@ -220,17 +268,6 @@ function b64Encode(bytes: Uint8Array): string {
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function b64Decode(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;
}
function buildDeviceAuthPayload(params: {
clientId: string;
clientMode: string;
@@ -1563,10 +1600,6 @@ export function getGatewayClient(opts?: ConstructorParameters<typeof GatewayClie
return _client;
}
function normalizeGatewayUrl(url: string): string {
return url.replace(/\/+$/, '');
}