use aes_gcm::{ Aes256Gcm, KeyInit, Nonce, aead::{Aead, OsRng, rand_core::RngCore}, }; use base64::{Engine as _, engine::general_purpose::STANDARD}; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; use crate::error::ApiError; #[derive(Clone)] pub struct SecretCrypto { 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(ApiError::internal("CRANK_MASTER_KEY must not be empty")); } let digest = Sha256::digest(trimmed.as_bytes()); let cipher = Aes256Gcm::new_from_slice(digest.as_slice()).map_err(|error| { ApiError::internal(format!("failed to initialize secret crypto: {error}")) })?; Ok(Self { cipher, key_version: "v1".to_owned(), }) } pub fn key_version(&self) -> &str { &self.key_version } pub fn encrypt(&self, value: &Value) -> Result { let plaintext = serde_json::to_vec(value).map_err(|error| { ApiError::internal(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 = self .cipher .encrypt(nonce, plaintext.as_ref()) .map_err(|error| { ApiError::internal(format!("failed to encrypt secret value: {error}")) })?; let envelope = CipherEnvelope { nonce_b64: STANDARD.encode(nonce_bytes), ciphertext_b64: STANDARD.encode(ciphertext), }; serde_json::to_string(&envelope).map_err(|error| { ApiError::internal(format!("failed to encode secret ciphertext: {error}")) }) } #[cfg(test)] pub fn decrypt(&self, ciphertext: &str) -> Result { let envelope: CipherEnvelope = serde_json::from_str(ciphertext).map_err(|error| { ApiError::internal(format!("failed to decode secret envelope: {error}")) })?; let nonce_bytes = STANDARD.decode(envelope.nonce_b64).map_err(|error| { ApiError::internal(format!("failed to decode secret nonce: {error}")) })?; let ciphertext_bytes = STANDARD.decode(envelope.ciphertext_b64).map_err(|error| { ApiError::internal(format!("failed to decode secret payload: {error}")) })?; let plaintext = self .cipher .decrypt(Nonce::from_slice(&nonce_bytes), ciphertext_bytes.as_ref()) .map_err(|error| { ApiError::internal(format!("failed to decrypt secret value: {error}")) })?; serde_json::from_slice(&plaintext).map_err(|error| { ApiError::internal(format!("failed to deserialize secret value: {error}")) }) } } #[cfg(test)] mod tests { use serde_json::json; use super::SecretCrypto; #[test] fn roundtrips_secret_payload() { 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(&ciphertext).unwrap(); assert_eq!(decrypted, plaintext); } }