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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user