claw_spawn/infrastructure/
crypto.rs1use aes_gcm::{
2 aead::{Aead, KeyInit},
3 Aes256Gcm, Nonce,
4};
5use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
6use rand::RngCore;
7use thiserror::Error;
8use tracing::warn;
9
10#[derive(Error, Debug)]
11pub enum EncryptionError {
12 #[error("Encryption failed: {0}")]
13 EncryptionFailed(String),
14 #[error("Decryption failed: {0}")]
15 DecryptionFailed(String),
16 #[error("Invalid key length")]
17 InvalidKeyLength,
18}
19
20pub struct SecretsEncryption {
21 cipher: Aes256Gcm,
22}
23
24impl SecretsEncryption {
25 pub fn new(key_base64: &str) -> Result<Self, EncryptionError> {
26 let key_bytes = BASE64
27 .decode(key_base64)
28 .map_err(|_| EncryptionError::InvalidKeyLength)?;
29
30 if key_bytes.len() != 32 {
31 return Err(EncryptionError::InvalidKeyLength);
32 }
33
34 let key: [u8; 32] = key_bytes
35 .try_into()
36 .map_err(|_| EncryptionError::InvalidKeyLength)?;
37
38 Self::validate_key_entropy(&key);
40
41 let cipher = Aes256Gcm::new_from_slice(&key)
42 .map_err(|e| EncryptionError::EncryptionFailed(e.to_string()))?;
43
44 Ok(Self { cipher })
45 }
46
47 fn validate_key_entropy(key: &[u8; 32]) {
49 if key.iter().all(|&b| b == 0) {
51 warn!("CRITICAL: Encryption key is all zeros - this is extremely insecure!");
52 return;
53 }
54
55 let first = key[0];
57 if key.iter().all(|&b| b == first) {
58 warn!("CRITICAL: Encryption key has uniform values - this is extremely insecure!");
59 return;
60 }
61
62 let mut unique_bytes = std::collections::HashSet::new();
64 for &b in key.iter() {
65 unique_bytes.insert(b);
66 }
67 let entropy_ratio = unique_bytes.len() as f32 / key.len() as f32;
68
69 if entropy_ratio < 0.5 {
70 warn!(
71 "WARNING: Encryption key has low entropy ({:.1}% unique bytes). Consider using a stronger key.",
72 entropy_ratio * 100.0
73 );
74 }
75
76 let printable_only = key
78 .iter()
79 .all(|&b| b.is_ascii_graphic() || b.is_ascii_whitespace());
80 if printable_only {
81 let as_string = String::from_utf8_lossy(key);
82 if as_string.contains("password")
83 || as_string.contains("secret")
84 || as_string.contains("123")
85 || as_string.contains("key")
86 {
87 warn!("WARNING: Encryption key appears to contain dictionary words or common phrases.");
88 }
89 }
90 }
91
92 pub fn encrypt(&self, plaintext: &str) -> Result<Vec<u8>, EncryptionError> {
93 let mut nonce_bytes = [0u8; 12];
94 rand::thread_rng().fill_bytes(&mut nonce_bytes);
95
96 let nonce = Nonce::from_slice(&nonce_bytes);
97
98 let ciphertext = self
99 .cipher
100 .encrypt(nonce, plaintext.as_bytes())
101 .map_err(|e| EncryptionError::EncryptionFailed(e.to_string()))?;
102
103 let mut result = Vec::with_capacity(12 + ciphertext.len());
104 result.extend_from_slice(&nonce_bytes);
105 result.extend_from_slice(&ciphertext);
106
107 Ok(result)
108 }
109
110 pub fn decrypt(&self, ciphertext: &[u8]) -> Result<String, EncryptionError> {
111 if ciphertext.len() < 12 {
112 return Err(EncryptionError::DecryptionFailed(
113 "Ciphertext too short".to_string(),
114 ));
115 }
116
117 let (nonce_bytes, encrypted) = ciphertext.split_at(12);
118 let nonce = Nonce::from_slice(nonce_bytes);
119
120 let plaintext = self
121 .cipher
122 .decrypt(nonce, encrypted)
123 .map_err(|e| EncryptionError::DecryptionFailed(e.to_string()))?;
124
125 String::from_utf8(plaintext).map_err(|e| EncryptionError::DecryptionFailed(e.to_string()))
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn test_encrypt_decrypt() {
135 let key = "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=";
136 let encryption = SecretsEncryption::new(key).unwrap();
137
138 let plaintext = "my-secret-api-key-12345";
139 let encrypted = encryption.encrypt(plaintext).unwrap();
140 let decrypted = encryption.decrypt(&encrypted).unwrap();
141
142 assert_eq!(plaintext, decrypted);
143 }
144}