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:
@@ -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(/\/+$/, '');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user