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"
|
sha2 = "0.10"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
hex = "0.4"
|
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 base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use erp_core::error::{AppError, AppResult};
|
use erp_core::error::{AppError, AppResult};
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
#[derive(Clone)]
|
const CIPHER_VERSION: &str = "v1";
|
||||||
|
|
||||||
pub struct HealthCrypto {
|
pub struct HealthCrypto {
|
||||||
aes_key: [u8; 32],
|
aes_key: Zeroizing<[u8; 32]>,
|
||||||
hmac_key: [u8; 32],
|
hmac_key: Zeroizing<[u8; 32]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HealthCrypto {
|
impl HealthCrypto {
|
||||||
@@ -25,10 +27,16 @@ impl HealthCrypto {
|
|||||||
"Encryption keys must be 32 bytes each".into(),
|
"Encryption keys must be 32 bytes each".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let mut aes = [0u8; 32];
|
let aes = Zeroizing::new({
|
||||||
let mut hmac = [0u8; 32];
|
let mut a = [0u8; 32];
|
||||||
aes.copy_from_slice(&aes_key);
|
a.copy_from_slice(&aes_key);
|
||||||
hmac.copy_from_slice(&hmac_key);
|
a
|
||||||
|
});
|
||||||
|
let hmac = Zeroizing::new({
|
||||||
|
let mut h = [0u8; 32];
|
||||||
|
h.copy_from_slice(&hmac_key);
|
||||||
|
h
|
||||||
|
});
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
aes_key: aes,
|
aes_key: aes,
|
||||||
hmac_key: hmac,
|
hmac_key: hmac,
|
||||||
@@ -41,10 +49,16 @@ impl HealthCrypto {
|
|||||||
use sha2::Digest;
|
use sha2::Digest;
|
||||||
let aes_key = <Sha256 as Digest>::digest(b"erp-health-aes-dev-key-DO-NOT-USE-IN-PROD");
|
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 hmac_key = <Sha256 as Digest>::digest(b"erp-health-hmac-dev-key-DO-NOT-USE-IN-PROD");
|
||||||
let mut aes = [0u8; 32];
|
let aes = Zeroizing::new({
|
||||||
let mut hmac = [0u8; 32];
|
let mut a = [0u8; 32];
|
||||||
aes.copy_from_slice(&aes_key);
|
a.copy_from_slice(&aes_key);
|
||||||
hmac.copy_from_slice(&hmac_key);
|
a
|
||||||
|
});
|
||||||
|
let hmac = Zeroizing::new({
|
||||||
|
let mut h = [0u8; 32];
|
||||||
|
h.copy_from_slice(&hmac_key);
|
||||||
|
h
|
||||||
|
});
|
||||||
Self {
|
Self {
|
||||||
aes_key: aes,
|
aes_key: aes,
|
||||||
hmac_key: hmac,
|
hmac_key: hmac,
|
||||||
@@ -52,7 +66,7 @@ impl HealthCrypto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn encrypt(&self, plaintext: &str) -> AppResult<String> {
|
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)))?;
|
.map_err(|e| AppError::Internal(format!("AES init failed: {}", e)))?;
|
||||||
let nonce_bytes = uuid::Uuid::now_v7();
|
let nonce_bytes = uuid::Uuid::now_v7();
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes.as_bytes()[..12]);
|
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)))?;
|
.map_err(|e| AppError::Internal(format!("Encryption failed: {}", e)))?;
|
||||||
let mut combined = nonce_bytes.as_bytes()[..12].to_vec();
|
let mut combined = nonce_bytes.as_bytes()[..12].to_vec();
|
||||||
combined.extend_from_slice(&ciphertext);
|
combined.extend_from_slice(&ciphertext);
|
||||||
Ok(BASE64.encode(&combined))
|
Ok(format!("{}|{}", CIPHER_VERSION, BASE64.encode(&combined)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decrypt(&self, encoded: &str) -> AppResult<String> {
|
pub fn decrypt(&self, encoded: &str) -> AppResult<String> {
|
||||||
|
// 剥离版本前缀(兼容旧格式无前缀)
|
||||||
|
let b64 = if let Some(pos) = encoded.find('|') {
|
||||||
|
&encoded[pos + 1..]
|
||||||
|
} else {
|
||||||
|
encoded
|
||||||
|
};
|
||||||
let combined = BASE64
|
let combined = BASE64
|
||||||
.decode(encoded)
|
.decode(b64)
|
||||||
.map_err(|e| AppError::Internal(format!("Base64 decode failed: {}", e)))?;
|
.map_err(|e| AppError::Internal(format!("Base64 decode failed: {}", e)))?;
|
||||||
if combined.len() < 12 {
|
if combined.len() < 12 {
|
||||||
return Err(AppError::Internal("Ciphertext too short".into()));
|
return Err(AppError::Internal("Ciphertext too short".into()));
|
||||||
}
|
}
|
||||||
let (nonce_bytes, ciphertext) = combined.split_at(12);
|
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)))?;
|
.map_err(|e| AppError::Internal(format!("AES init failed: {}", e)))?;
|
||||||
let plaintext = cipher
|
let plaintext = cipher
|
||||||
.decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
|
.decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
|
||||||
@@ -82,7 +102,7 @@ impl HealthCrypto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn hmac_hash(&self, value: &str) -> String {
|
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");
|
.expect("HMAC key length is valid");
|
||||||
mac.update(value.as_bytes());
|
mac.update(value.as_bytes());
|
||||||
hex::encode(mac.finalize().into_bytes())
|
hex::encode(mac.finalize().into_bytes())
|
||||||
@@ -152,7 +172,7 @@ mod tests {
|
|||||||
fn decrypt_too_short_fails() {
|
fn decrypt_too_short_fails() {
|
||||||
let crypto = test_crypto();
|
let crypto = test_crypto();
|
||||||
let short = BASE64.encode(b"short");
|
let short = BASE64.encode(b"short");
|
||||||
assert!(crypto.decrypt(&short).is_err());
|
assert!(crypto.decrypt(&format!("v1|{}", short)).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -166,4 +186,28 @@ mod tests {
|
|||||||
let result = HealthCrypto::from_keys("ab", "cd");
|
let result = HealthCrypto::from_keys("ab", "cd");
|
||||||
assert!(result.is_err());
|
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