fix(health): 密文版本标识 v1 前缀 + DEK zeroize
- 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:
@@ -23,3 +23,4 @@ hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
hex = "0.4"
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user