From 3284a59c55240b537e7b1951976fe9a0348caeaf Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 28 Apr 2026 11:27:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(health):=20=E5=AF=86=E6=96=87=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E6=A0=87=E8=AF=86=20v1=20=E5=89=8D=E7=BC=80=20+=20DEK?= =?UTF-8?q?=20zeroize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - encrypt() 输出格式改为 v1|Base64(nonce+ciphertext) - decrypt() 兼容旧格式(无版本前缀) - aes_key/hmac_key 改用 Zeroizing<[u8; 32]>,Drop 时覆写内存 - 新增 encrypt_has_version_prefix + decrypt_legacy_no_prefix 测试 --- crates/erp-health/Cargo.toml | 1 + crates/erp-health/src/crypto.rs | 78 ++++++++++++++++++++++++++------- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/crates/erp-health/Cargo.toml b/crates/erp-health/Cargo.toml index 306304e..470e5e6 100644 --- a/crates/erp-health/Cargo.toml +++ b/crates/erp-health/Cargo.toml @@ -23,3 +23,4 @@ hmac = "0.12" sha2 = "0.10" base64 = "0.22" hex = "0.4" +zeroize = { version = "1", features = ["derive"] } diff --git a/crates/erp-health/src/crypto.rs b/crates/erp-health/src/crypto.rs index 88217a0..71e5350 100644 --- a/crates/erp-health/src/crypto.rs +++ b/crates/erp-health/src/crypto.rs @@ -3,15 +3,17 @@ use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use hmac::{Hmac, Mac}; use sha2::Sha256; +use zeroize::Zeroizing; use erp_core::error::{AppError, AppResult}; type HmacSha256 = Hmac; -#[derive(Clone)] +const CIPHER_VERSION: &str = "v1"; + pub struct HealthCrypto { - aes_key: [u8; 32], - hmac_key: [u8; 32], + aes_key: Zeroizing<[u8; 32]>, + hmac_key: Zeroizing<[u8; 32]>, } impl HealthCrypto { @@ -25,10 +27,16 @@ impl HealthCrypto { "Encryption keys must be 32 bytes each".into(), )); } - let mut aes = [0u8; 32]; - let mut hmac = [0u8; 32]; - aes.copy_from_slice(&aes_key); - hmac.copy_from_slice(&hmac_key); + let aes = Zeroizing::new({ + let mut a = [0u8; 32]; + a.copy_from_slice(&aes_key); + a + }); + let hmac = Zeroizing::new({ + let mut h = [0u8; 32]; + h.copy_from_slice(&hmac_key); + h + }); Ok(Self { aes_key: aes, hmac_key: hmac, @@ -41,10 +49,16 @@ impl HealthCrypto { use sha2::Digest; let aes_key = ::digest(b"erp-health-aes-dev-key-DO-NOT-USE-IN-PROD"); let hmac_key = ::digest(b"erp-health-hmac-dev-key-DO-NOT-USE-IN-PROD"); - let mut aes = [0u8; 32]; - let mut hmac = [0u8; 32]; - aes.copy_from_slice(&aes_key); - hmac.copy_from_slice(&hmac_key); + let aes = Zeroizing::new({ + let mut a = [0u8; 32]; + a.copy_from_slice(&aes_key); + a + }); + let hmac = Zeroizing::new({ + let mut h = [0u8; 32]; + h.copy_from_slice(&hmac_key); + h + }); Self { aes_key: aes, hmac_key: hmac, @@ -52,7 +66,7 @@ impl HealthCrypto { } pub fn encrypt(&self, plaintext: &str) -> AppResult { - let cipher = Aes256Gcm::new_from_slice(&self.aes_key) + let cipher = Aes256Gcm::new_from_slice(&*self.aes_key) .map_err(|e| AppError::Internal(format!("AES init failed: {}", e)))?; let nonce_bytes = uuid::Uuid::now_v7(); let nonce = Nonce::from_slice(&nonce_bytes.as_bytes()[..12]); @@ -61,18 +75,24 @@ impl HealthCrypto { .map_err(|e| AppError::Internal(format!("Encryption failed: {}", e)))?; let mut combined = nonce_bytes.as_bytes()[..12].to_vec(); combined.extend_from_slice(&ciphertext); - Ok(BASE64.encode(&combined)) + Ok(format!("{}|{}", CIPHER_VERSION, BASE64.encode(&combined))) } pub fn decrypt(&self, encoded: &str) -> AppResult { + // 剥离版本前缀(兼容旧格式无前缀) + let b64 = if let Some(pos) = encoded.find('|') { + &encoded[pos + 1..] + } else { + encoded + }; let combined = BASE64 - .decode(encoded) + .decode(b64) .map_err(|e| AppError::Internal(format!("Base64 decode failed: {}", e)))?; if combined.len() < 12 { return Err(AppError::Internal("Ciphertext too short".into())); } let (nonce_bytes, ciphertext) = combined.split_at(12); - let cipher = Aes256Gcm::new_from_slice(&self.aes_key) + let cipher = Aes256Gcm::new_from_slice(&*self.aes_key) .map_err(|e| AppError::Internal(format!("AES init failed: {}", e)))?; let plaintext = cipher .decrypt(Nonce::from_slice(nonce_bytes), ciphertext) @@ -82,7 +102,7 @@ impl HealthCrypto { } pub fn hmac_hash(&self, value: &str) -> String { - let mut mac = ::new_from_slice(&self.hmac_key) + let mut mac = ::new_from_slice(&*self.hmac_key) .expect("HMAC key length is valid"); mac.update(value.as_bytes()); hex::encode(mac.finalize().into_bytes()) @@ -152,7 +172,7 @@ mod tests { fn decrypt_too_short_fails() { let crypto = test_crypto(); let short = BASE64.encode(b"short"); - assert!(crypto.decrypt(&short).is_err()); + assert!(crypto.decrypt(&format!("v1|{}", short)).is_err()); } #[test] @@ -166,4 +186,28 @@ mod tests { let result = HealthCrypto::from_keys("ab", "cd"); assert!(result.is_err()); } + + #[test] + fn encrypt_has_version_prefix() { + let crypto = test_crypto(); + let encrypted = crypto.encrypt("test").unwrap(); + assert!(encrypted.starts_with("v1|"), "密文应以 v1| 开头"); + } + + #[test] + fn decrypt_legacy_no_prefix() { + let crypto = test_crypto(); + // 手动构造旧格式密文(无版本前缀) + let cipher = Aes256Gcm::new_from_slice(&*crypto.aes_key).unwrap(); + let nonce_bytes = uuid::Uuid::now_v7(); + let nonce = Nonce::from_slice(&nonce_bytes.as_bytes()[..12]); + let ciphertext = cipher.encrypt(nonce, b"legacy-test".as_ref()).unwrap(); + let mut combined = nonce_bytes.as_bytes()[..12].to_vec(); + combined.extend_from_slice(&ciphertext); + let legacy = BASE64.encode(&combined); + + // 旧格式(无 v1| 前缀)仍可解密 + let decrypted = crypto.decrypt(&legacy).unwrap(); + assert_eq!("legacy-test", decrypted); + } }