use aes_gcm::{ Aes256Gcm, KeyInit, Nonce, aead::{Aead, OsRng, rand_core::RngCore}, }; use base64::{Engine as _, engine::general_purpose::STANDARD}; use hkdf::Hkdf; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; use crate::RuntimeError; const LEGACY_KEY_VERSION: &str = "v1"; const CURRENT_KEY_VERSION: &str = "v2"; const SECRET_ENVELOPE_INFO: &[u8] = b"crank.secret-envelope.v2"; #[derive(Clone)] pub struct SecretCrypto { current_cipher: Aes256Gcm, legacy_cipher: Aes256Gcm, key_version: String, } #[derive(Debug, Serialize, Deserialize)] struct CipherEnvelope { nonce_b64: String, ciphertext_b64: String, } impl SecretCrypto { pub fn new(master_key: &str) -> Result { let trimmed = master_key.trim(); if trimmed.is_empty() { return Err(RuntimeError::SecretCrypto { operation: "initialize secret crypto", details: "CRANK_MASTER_KEY must not be empty".to_owned(), }); } Ok(Self { current_cipher: derive_hkdf_cipher(trimmed)?, legacy_cipher: derive_legacy_cipher(trimmed)?, key_version: CURRENT_KEY_VERSION.to_owned(), }) } pub fn key_version(&self) -> &str { &self.key_version } pub fn encrypt(&self, value: &Value) -> Result { encrypt_value_with_cipher(&self.current_cipher, value, "encrypt secret value") } pub fn decrypt(&self, key_version: &str, ciphertext: &str) -> Result { let envelope: CipherEnvelope = serde_json::from_str(ciphertext).map_err(|error| RuntimeError::SecretCrypto { operation: "decode secret envelope", details: format!("failed to decode secret envelope: {error}"), })?; let nonce_bytes = STANDARD .decode(envelope.nonce_b64) .map_err(|error| RuntimeError::SecretCrypto { operation: "decode secret nonce", details: format!("failed to decode secret nonce: {error}"), })?; let ciphertext_bytes = STANDARD.decode(envelope.ciphertext_b64).map_err(|error| { RuntimeError::SecretCrypto { operation: "decode secret payload", details: format!("failed to decode secret payload: {error}"), } })?; let cipher = self.cipher_for_version(key_version)?; let plaintext = cipher .decrypt(Nonce::from_slice(&nonce_bytes), ciphertext_bytes.as_ref()) .map_err(|error| RuntimeError::SecretCrypto { operation: "decrypt secret value", details: format!("failed to decrypt secret value: {error}"), })?; serde_json::from_slice(&plaintext).map_err(|error| RuntimeError::SecretCrypto { operation: "deserialize secret value", details: format!("failed to deserialize secret value: {error}"), }) } fn cipher_for_version(&self, key_version: &str) -> Result<&Aes256Gcm, RuntimeError> { match key_version { LEGACY_KEY_VERSION => Ok(&self.legacy_cipher), CURRENT_KEY_VERSION => Ok(&self.current_cipher), other => Err(RuntimeError::SecretCrypto { operation: "select secret key version", details: format!("unsupported secret key version: {other}"), }), } } } fn derive_legacy_cipher(master_key: &str) -> Result { let digest = Sha256::digest(master_key.as_bytes()); Aes256Gcm::new_from_slice(digest.as_slice()).map_err(|error| RuntimeError::SecretCrypto { operation: "initialize legacy secret crypto", details: format!("failed to initialize legacy secret crypto: {error}"), }) } fn derive_hkdf_cipher(master_key: &str) -> Result { let hkdf = Hkdf::::new(None, master_key.as_bytes()); let mut key_bytes = [0_u8; 32]; hkdf.expand(SECRET_ENVELOPE_INFO, &mut key_bytes) .map_err(|error| RuntimeError::SecretCrypto { operation: "derive secret key with hkdf", details: format!("failed to derive secret key with HKDF: {error}"), })?; Aes256Gcm::new_from_slice(&key_bytes).map_err(|error| RuntimeError::SecretCrypto { operation: "initialize secret crypto", details: format!("failed to initialize secret crypto: {error}"), }) } fn encrypt_value_with_cipher( cipher: &Aes256Gcm, value: &Value, action: &str, ) -> Result { let plaintext = serde_json::to_vec(value).map_err(|error| RuntimeError::SecretCrypto { operation: "serialize secret value", details: format!("failed to serialize secret value: {error}"), })?; let mut nonce_bytes = [0_u8; 12]; OsRng.fill_bytes(&mut nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes); let ciphertext = cipher .encrypt(nonce, plaintext.as_ref()) .map_err(|error| RuntimeError::SecretCrypto { operation: "encrypt secret value", details: format!("failed to {action}: {error}"), })?; let envelope = CipherEnvelope { nonce_b64: STANDARD.encode(nonce_bytes), ciphertext_b64: STANDARD.encode(ciphertext), }; serde_json::to_string(&envelope).map_err(|error| RuntimeError::SecretCrypto { operation: "encode secret ciphertext", details: format!("failed to encode secret ciphertext: {error}"), }) } #[cfg(test)] fn encrypt_with_legacy_scheme(master_key: &str, value: &Value) -> Result { let cipher = derive_legacy_cipher(master_key)?; encrypt_value_with_cipher(&cipher, value, "encrypt secret value with legacy scheme") } #[cfg(test)] fn current_key_bytes(master_key: &str) -> Result<[u8; 32], RuntimeError> { let hkdf = Hkdf::::new(None, master_key.as_bytes()); let mut key_bytes = [0_u8; 32]; hkdf.expand(SECRET_ENVELOPE_INFO, &mut key_bytes) .map_err(|error| RuntimeError::SecretCrypto { operation: "derive secret key with hkdf", details: format!("failed to derive secret key with HKDF: {error}"), })?; Ok(key_bytes) } #[cfg(test)] fn legacy_key_bytes(master_key: &str) -> [u8; 32] { let digest = Sha256::digest(master_key.as_bytes()); let mut key_bytes = [0_u8; 32]; key_bytes.copy_from_slice(digest.as_slice()); key_bytes } #[cfg(test)] mod tests { use crate::RuntimeError; use serde_json::json; use super::{ CURRENT_KEY_VERSION, LEGACY_KEY_VERSION, SecretCrypto, current_key_bytes, encrypt_with_legacy_scheme, legacy_key_bytes, }; #[test] fn roundtrips_secret_payload_with_current_scheme() { let crypto = SecretCrypto::new("test-master-key").unwrap(); let plaintext = json!({ "token": "top-secret", "username": "demo" }); let ciphertext = crypto.encrypt(&plaintext).unwrap(); let decrypted = crypto.decrypt(CURRENT_KEY_VERSION, &ciphertext).unwrap(); assert_eq!(decrypted, plaintext); assert_eq!(crypto.key_version(), CURRENT_KEY_VERSION); } #[test] fn decrypts_legacy_v1_payloads() { let plaintext = json!({ "token": "top-secret", "username": "demo" }); let ciphertext = encrypt_with_legacy_scheme("test-master-key", &plaintext).unwrap(); let crypto = SecretCrypto::new("test-master-key").unwrap(); let decrypted = crypto.decrypt(LEGACY_KEY_VERSION, &ciphertext).unwrap(); assert_eq!(decrypted, plaintext); } #[test] fn rejects_empty_master_key() { let error = SecretCrypto::new(" ").err().unwrap(); match error { RuntimeError::SecretCrypto { operation, details } => { assert_eq!(operation, "initialize secret crypto"); assert_eq!(details, "CRANK_MASTER_KEY must not be empty"); } other => panic!("unexpected error: {other}"), } } #[test] fn same_master_key_derives_stable_hkdf_key() { let lhs = current_key_bytes("test-master-key").unwrap(); let rhs = current_key_bytes("test-master-key").unwrap(); assert_eq!(lhs, rhs); } #[test] fn current_scheme_key_differs_from_legacy_scheme() { let current = current_key_bytes("test-master-key").unwrap(); let legacy = legacy_key_bytes("test-master-key"); assert_ne!(current, legacy); } #[test] fn different_master_keys_produce_different_ciphertexts() { let plaintext = json!({ "token": "top-secret", "username": "demo" }); let left = SecretCrypto::new("test-master-key-a").unwrap(); let right = SecretCrypto::new("test-master-key-b").unwrap(); let left_ciphertext = left.encrypt(&plaintext).unwrap(); let right_ciphertext = right.encrypt(&plaintext).unwrap(); assert_ne!(left_ciphertext, right_ciphertext); } #[test] fn rejects_unknown_key_version() { let crypto = SecretCrypto::new("test-master-key").unwrap(); let ciphertext = crypto.encrypt(&json!({"token": "top-secret"})).unwrap(); let error = crypto.decrypt("v999", &ciphertext).unwrap_err(); match error { RuntimeError::SecretCrypto { operation, details } => { assert_eq!(operation, "select secret key version"); assert!(details.contains("unsupported secret key version")); } other => panic!("unexpected error: {other}"), } } }