chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
@@ -18,6 +18,9 @@ import {
|
||||
encrypt,
|
||||
decrypt,
|
||||
generateMasterKey,
|
||||
generateSalt,
|
||||
arrayToBase64,
|
||||
base64ToArray,
|
||||
} from './crypto-utils';
|
||||
|
||||
// Cache for keyring availability check
|
||||
@@ -27,9 +30,6 @@ let keyringAvailable: boolean | null = null;
|
||||
const ENCRYPTED_PREFIX = 'enc_';
|
||||
const MASTER_KEY_NAME = 'zclaw-master-key';
|
||||
|
||||
// Cache for the derived crypto key
|
||||
let cachedCryptoKey: CryptoKey | null = null;
|
||||
|
||||
/**
|
||||
* Check if secure storage (keyring) is available
|
||||
*/
|
||||
@@ -138,25 +138,6 @@ export const secureStorage = {
|
||||
* Now with AES-GCM encryption for non-Tauri environments
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get or create the master encryption key for localStorage fallback
|
||||
*/
|
||||
async function getOrCreateMasterKey(): Promise<CryptoKey> {
|
||||
if (cachedCryptoKey) {
|
||||
return cachedCryptoKey;
|
||||
}
|
||||
|
||||
let masterKeyRaw = localStorage.getItem(MASTER_KEY_NAME);
|
||||
|
||||
if (!masterKeyRaw) {
|
||||
masterKeyRaw = generateMasterKey();
|
||||
localStorage.setItem(MASTER_KEY_NAME, masterKeyRaw);
|
||||
}
|
||||
|
||||
cachedCryptoKey = await deriveKey(masterKeyRaw);
|
||||
return cachedCryptoKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a stored value is encrypted (has iv and data fields)
|
||||
*/
|
||||
@@ -169,8 +150,21 @@ function isEncrypted(value: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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<void> {
|
||||
try {
|
||||
@@ -178,45 +172,78 @@ async function writeEncryptedLocalStorage(key: string, value: string): Promise<v
|
||||
|
||||
if (!value) {
|
||||
localStorage.removeItem(encryptedKey);
|
||||
// Also remove legacy unencrypted key
|
||||
localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cryptoKey = await getOrCreateMasterKey();
|
||||
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);
|
||||
localStorage.setItem(encryptedKey, JSON.stringify(encrypted));
|
||||
// Remove legacy unencrypted key if it exists
|
||||
|
||||
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);
|
||||
// Fallback to plaintext if encryption fails (should not happen)
|
||||
localStorage.setItem(key, value);
|
||||
// Do NOT fall back to plaintext — throw to signal the error
|
||||
throw error;
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage failures
|
||||
} catch (error) {
|
||||
console.error('[SecureStorage] Failed to write encrypted localStorage:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and decrypt data from localStorage
|
||||
* Supports both encrypted and legacy unencrypted formats
|
||||
* Supports v2 (random salt), v1 (static salt), and legacy unencrypted formats
|
||||
*/
|
||||
async function readEncryptedLocalStorage(key: string): Promise<string | null> {
|
||||
try {
|
||||
// Try encrypted key first
|
||||
const encryptedKey = ENCRYPTED_PREFIX + key;
|
||||
const encryptedRaw = localStorage.getItem(encryptedKey);
|
||||
|
||||
if (encryptedRaw && isEncrypted(encryptedRaw)) {
|
||||
try {
|
||||
const cryptoKey = await getOrCreateMasterKey();
|
||||
const encrypted = JSON.parse(encryptedRaw);
|
||||
return await decrypt(encrypted, cryptoKey);
|
||||
} catch (error) {
|
||||
console.error('[SecureStorage] Decryption failed:', error);
|
||||
// Fall through to try legacy key
|
||||
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);
|
||||
return await decrypt(
|
||||
{ iv: parsed.iv, data: parsed.data },
|
||||
cryptoKey,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[SecureStorage] v2 decryption failed:', error);
|
||||
// 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);
|
||||
return await decrypt(encrypted, cryptoKey);
|
||||
} catch (error) {
|
||||
console.error('[SecureStorage] v1 decryption failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user