chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
103
crates/zclaw-saas/src/crypto.rs
Normal file
103
crates/zclaw-saas/src/crypto.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
//! 通用加密工具 (AES-256-GCM)
|
||||
//!
|
||||
//! 提供 API Key、TOTP secret 等敏感数据的加密/解密。
|
||||
//! 存储格式: `enc:<base64(nonce(12 bytes) || ciphertext)>`
|
||||
|
||||
use aes_gcm::aead::{Aead, KeyInit, OsRng};
|
||||
use aes_gcm::aead::rand_core::RngCore;
|
||||
use aes_gcm::{Aes256Gcm, Nonce};
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
|
||||
/// 加密值的前缀标识
|
||||
pub const ENCRYPTED_PREFIX: &str = "enc:";
|
||||
|
||||
/// AES-256-GCM nonce 长度 (12 字节)
|
||||
const NONCE_SIZE: usize = 12;
|
||||
|
||||
/// 加密明文值 (AES-256-GCM, 随机 nonce)
|
||||
///
|
||||
/// 返回格式: `enc:<base64(nonce(12 bytes) || ciphertext)>`
|
||||
/// 每次加密使用随机 nonce,相同明文产生不同密文。
|
||||
pub fn encrypt_value(plaintext: &str, key: &[u8; 32]) -> SaasResult<String> {
|
||||
let cipher = Aes256Gcm::new_from_slice(key)
|
||||
.map_err(|e| SaasError::Encryption(format!("加密初始化失败: {}", e)))?;
|
||||
|
||||
let mut nonce_bytes = [0u8; NONCE_SIZE];
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = cipher.encrypt(nonce, plaintext.as_bytes())
|
||||
.map_err(|e| SaasError::Encryption(format!("加密失败: {}", e)))?;
|
||||
|
||||
let mut combined = nonce_bytes.to_vec();
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
|
||||
Ok(format!("{}{}", ENCRYPTED_PREFIX, data_encoding::BASE64.encode(&combined)))
|
||||
}
|
||||
|
||||
/// 解密 `enc:` 前缀的加密值
|
||||
///
|
||||
/// 仅支持新格式 (随机 nonce),不支持旧格式 (固定 nonce)。
|
||||
/// 旧格式数据应通过一次性迁移函数转换。
|
||||
pub fn decrypt_value(encrypted: &str, key: &[u8; 32]) -> SaasResult<String> {
|
||||
let encoded = encrypted.strip_prefix(ENCRYPTED_PREFIX)
|
||||
.ok_or_else(|| SaasError::Encryption("加密值格式无效 (缺少 enc: 前缀)".into()))?;
|
||||
|
||||
let raw = data_encoding::BASE64.decode(encoded.as_bytes())
|
||||
.map_err(|_| SaasError::Encryption("加密值 Base64 解码失败".into()))?;
|
||||
|
||||
if raw.len() <= NONCE_SIZE {
|
||||
return Err(SaasError::Encryption("加密值数据不完整".into()));
|
||||
}
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(key)
|
||||
.map_err(|e| SaasError::Encryption(format!("解密初始化失败: {}", e)))?;
|
||||
|
||||
let (nonce_bytes, ciphertext) = raw.split_at(NONCE_SIZE);
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
|
||||
let plaintext = cipher.decrypt(nonce, ciphertext)
|
||||
.map_err(|_| SaasError::Encryption("解密失败 (密钥可能已变更)".into()))?;
|
||||
|
||||
String::from_utf8(plaintext)
|
||||
.map_err(|_| SaasError::Encryption("解密后数据无效 UTF-8".into()))
|
||||
}
|
||||
|
||||
/// 检查值是否已加密 (以 `enc:` 开头)
|
||||
pub fn is_encrypted(value: &str) -> bool {
|
||||
value.starts_with(ENCRYPTED_PREFIX)
|
||||
}
|
||||
|
||||
/// 批量迁移: 将旧的固定 nonce 加密值重新加密为随机 nonce 格式
|
||||
///
|
||||
/// 输入为旧格式 (固定 nonce `zclaw_totp_nce`) 加密的 base64 数据,
|
||||
/// 输出为新格式 `enc:<base64(random_nonce || ciphertext)>`。
|
||||
pub fn re_encrypt_from_legacy(legacy_base64: &str, legacy_key: &[u8; 32], new_key: &[u8; 32]) -> SaasResult<String> {
|
||||
// 先用旧 nonce 解密
|
||||
let cipher = Aes256Gcm::new_from_slice(legacy_key)
|
||||
.map_err(|e| SaasError::Encryption(format!("解密初始化失败: {}", e)))?;
|
||||
|
||||
let raw = data_encoding::BASE64.decode(legacy_base64.as_bytes())
|
||||
.or_else(|_| data_encoding::BASE32.decode(legacy_base64.as_bytes()))
|
||||
.map_err(|_| SaasError::Encryption("旧格式 Base64/Base32 解码失败".into()))?;
|
||||
|
||||
// 尝试新格式 (前 12 字节为 nonce)
|
||||
if raw.len() > NONCE_SIZE {
|
||||
let (nonce_bytes, ciphertext) = raw.split_at(NONCE_SIZE);
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
if let Ok(plaintext_bytes) = cipher.decrypt(nonce, ciphertext) {
|
||||
let plaintext = String::from_utf8(plaintext_bytes)
|
||||
.map_err(|_| SaasError::Encryption("旧格式解密后数据无效".into()))?;
|
||||
return encrypt_value(&plaintext, new_key);
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到旧格式: 固定 nonce
|
||||
let legacy_nonce = Nonce::from_slice(b"zclaw_totp_nce");
|
||||
let plaintext_bytes = cipher.decrypt(legacy_nonce, raw.as_ref())
|
||||
.map_err(|_| SaasError::Encryption("旧格式解密失败".into()))?;
|
||||
let plaintext = String::from_utf8(plaintext_bytes)
|
||||
.map_err(|_| SaasError::Encryption("旧格式解密后数据无效".into()))?;
|
||||
|
||||
encrypt_value(&plaintext, new_key)
|
||||
}
|
||||
Reference in New Issue
Block a user