fix(health): 密文版本标识 v1 前缀 + DEK zeroize
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- encrypt() 输出格式改为 v1|Base64(nonce+ciphertext)
- decrypt() 兼容旧格式(无版本前缀)
- aes_key/hmac_key 改用 Zeroizing<[u8; 32]>,Drop 时覆写内存
- 新增 encrypt_has_version_prefix + decrypt_legacy_no_prefix 测试
This commit is contained in:
iven
2026-04-28 11:27:41 +08:00
parent 988f6cd6a5
commit 3284a59c55
2 changed files with 62 additions and 17 deletions

View File

@@ -23,3 +23,4 @@ hmac = "0.12"
sha2 = "0.10"
base64 = "0.22"
hex = "0.4"
zeroize = { version = "1", features = ["derive"] }

View File

@@ -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<Sha256>;
#[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 = <Sha256 as Digest>::digest(b"erp-health-aes-dev-key-DO-NOT-USE-IN-PROD");
let hmac_key = <Sha256 as Digest>::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<String> {
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<String> {
// 剥离版本前缀(兼容旧格式无前缀)
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 = <HmacSha256 as hmac::Mac>::new_from_slice(&self.hmac_key)
let mut mac = <HmacSha256 as hmac::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);
}
}