loop_agent_sdk/
privacy.rs1use hmac::{Hmac, Mac};
26use sha2::Sha256;
27use std::sync::OnceLock;
28use tracing::{info, warn};
29use zeroize::Zeroize;
30
31type HmacSha256 = Hmac<Sha256>;
32
33const FINGERPRINT_PREFIX: &str = "loop_fp_";
35
36static PEPPER: OnceLock<Vec<u8>> = OnceLock::new();
38
39#[derive(Debug, Clone)]
41pub struct PrivacyConfig {
42 pub pepper_secret_name: String,
44 pub aws_region: String,
46 pub test_pepper: Option<Vec<u8>>,
48}
49
50impl Default for PrivacyConfig {
51 fn default() -> Self {
52 Self {
53 pepper_secret_name: std::env::var("PEPPER_SECRET_NAME")
54 .unwrap_or_else(|_| "loop/agent/pepper".to_string()),
55 aws_region: std::env::var("AWS_REGION")
56 .unwrap_or_else(|_| "us-east-1".to_string()),
57 test_pepper: None,
58 }
59 }
60}
61
62impl PrivacyConfig {
63 #[cfg(test)]
66 pub fn for_testing(pepper: &[u8]) -> Self {
67 Self {
68 pepper_secret_name: "test".to_string(),
69 aws_region: "us-east-1".to_string(),
70 test_pepper: Some(pepper.to_vec()),
71 }
72 }
73}
74
75pub struct PrivacyLayer {
77 pepper: Vec<u8>,
78}
79
80impl PrivacyLayer {
81 pub async fn new(config: &PrivacyConfig) -> Result<Self, PrivacyError> {
83 if let Some(pepper) = PEPPER.get() {
85 return Ok(Self { pepper: pepper.clone() });
86 }
87
88 if let Some(ref test_pepper) = config.test_pepper {
90 warn!("Using test pepper - NOT FOR PRODUCTION");
91 let _ = PEPPER.set(test_pepper.clone());
92 return Ok(Self { pepper: test_pepper.clone() });
93 }
94
95 let pepper = Self::load_pepper_from_secrets_manager(config).await?;
97
98 let _ = PEPPER.set(pepper.clone());
100
101 info!("Privacy layer initialized with pepper from Secrets Manager");
102 Ok(Self { pepper })
103 }
104
105 async fn load_pepper_from_secrets_manager(config: &PrivacyConfig) -> Result<Vec<u8>, PrivacyError> {
107 let aws_config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
108 let client = aws_sdk_secretsmanager::Client::new(&aws_config);
109
110 let response = client
111 .get_secret_value()
112 .secret_id(&config.pepper_secret_name)
113 .send()
114 .await
115 .map_err(|e| PrivacyError::SecretLoadFailed(e.to_string()))?;
116
117 let secret_string = response.secret_string()
119 .ok_or_else(|| PrivacyError::SecretLoadFailed("Secret has no string value".into()))?;
120
121 let pepper = base64::Engine::decode(
122 &base64::engine::general_purpose::STANDARD,
123 secret_string.trim(),
124 ).map_err(|e| PrivacyError::SecretLoadFailed(format!("Invalid base64: {}", e)))?;
125
126 if pepper.len() < 32 {
127 return Err(PrivacyError::SecretLoadFailed(
128 "Pepper must be at least 32 bytes".into()
129 ));
130 }
131
132 Ok(pepper)
133 }
134
135 pub fn hash_card_id(&self, mut card_id: String) -> LoopFingerprint {
147 let mut mac = HmacSha256::new_from_slice(&self.pepper)
149 .expect("HMAC can take key of any size");
150
151 mac.update(card_id.as_bytes());
153
154 card_id.zeroize();
156
157 let result = mac.finalize();
159 let hash_bytes = result.into_bytes();
160 let hex_hash = hex::encode(hash_bytes);
161
162 LoopFingerprint(format!("{}{}", FINGERPRINT_PREFIX, hex_hash))
164 }
165
166 pub fn hash_card_bytes(&self, mut card_bytes: Vec<u8>) -> LoopFingerprint {
168 let mut mac = HmacSha256::new_from_slice(&self.pepper)
169 .expect("HMAC can take key of any size");
170
171 mac.update(&card_bytes);
172
173 card_bytes.zeroize();
175
176 let result = mac.finalize();
177 let hex_hash = hex::encode(result.into_bytes());
178
179 LoopFingerprint(format!("{}{}", FINGERPRINT_PREFIX, hex_hash))
180 }
181
182 pub fn verify(&self, card_id: &str, fingerprint: &LoopFingerprint) -> bool {
185 let computed = self.hash_card_id(card_id.to_string());
186 computed.0 == fingerprint.0
187 }
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Hash)]
197pub struct LoopFingerprint(String);
198
199impl LoopFingerprint {
200 pub fn as_str(&self) -> &str {
202 &self.0
203 }
204
205 pub fn into_string(self) -> String {
207 self.0
208 }
209
210 pub fn is_valid_format(s: &str) -> bool {
212 s.starts_with(FINGERPRINT_PREFIX)
213 && s.len() == FINGERPRINT_PREFIX.len() + 64 && s[FINGERPRINT_PREFIX.len()..].chars().all(|c| c.is_ascii_hexdigit())
215 }
216
217 pub fn parse(s: &str) -> Option<Self> {
219 if Self::is_valid_format(s) {
220 Some(Self(s.to_string()))
221 } else {
222 None
223 }
224 }
225}
226
227impl std::fmt::Display for LoopFingerprint {
228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229 write!(f, "{}", self.0)
230 }
231}
232
233impl AsRef<str> for LoopFingerprint {
234 fn as_ref(&self) -> &str {
235 &self.0
236 }
237}
238
239#[derive(Debug, Clone)]
241pub enum PrivacyError {
242 SecretLoadFailed(String),
244 InvalidFingerprint(String),
246}
247
248impl std::fmt::Display for PrivacyError {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 Self::SecretLoadFailed(msg) => write!(f, "Failed to load pepper: {}", msg),
252 Self::InvalidFingerprint(msg) => write!(f, "Invalid fingerprint: {}", msg),
253 }
254 }
255}
256
257impl std::error::Error for PrivacyError {}
258
259pub fn generate_pepper() -> String {
266 use rand::RngCore;
267 let mut pepper = [0u8; 32];
268 rand::thread_rng().fill_bytes(&mut pepper);
269 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, pepper)
270}
271
272pub async fn create_pepper_secret(secret_name: &str) -> Result<(), Box<dyn std::error::Error>> {
275 let pepper_b64 = generate_pepper();
276
277 let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
278 let client = aws_sdk_secretsmanager::Client::new(&config);
279
280 client
281 .create_secret()
282 .name(secret_name)
283 .secret_string(&pepper_b64)
284 .description("Loop Agent SDK - Card fingerprint HMAC pepper. DO NOT DELETE.")
285 .send()
286 .await?;
287
288 info!(secret_name = %secret_name, "Created pepper secret in Secrets Manager");
289 Ok(())
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 fn test_privacy_layer() -> PrivacyLayer {
297 PrivacyLayer {
298 pepper: b"test_pepper_32_bytes_minimum_ok!".to_vec(),
299 }
300 }
301
302 #[test]
303 fn fingerprint_format_is_correct() {
304 let privacy = test_privacy_layer();
305 let fp = privacy.hash_card_id("card_abc123".to_string());
306
307 assert!(fp.as_str().starts_with("loop_fp_"));
308 assert_eq!(fp.as_str().len(), 8 + 64); assert!(LoopFingerprint::is_valid_format(fp.as_str()));
310 }
311
312 #[test]
313 fn same_input_same_output() {
314 let privacy = test_privacy_layer();
315 let fp1 = privacy.hash_card_id("card_abc123".to_string());
316 let fp2 = privacy.hash_card_id("card_abc123".to_string());
317
318 assert_eq!(fp1, fp2);
319 }
320
321 #[test]
322 fn different_input_different_output() {
323 let privacy = test_privacy_layer();
324 let fp1 = privacy.hash_card_id("card_abc123".to_string());
325 let fp2 = privacy.hash_card_id("card_xyz789".to_string());
326
327 assert_ne!(fp1, fp2);
328 }
329
330 #[test]
331 fn verify_works() {
332 let privacy = test_privacy_layer();
333 let fp = privacy.hash_card_id("card_abc123".to_string());
334
335 assert!(privacy.verify("card_abc123", &fp));
336 assert!(!privacy.verify("card_wrong", &fp));
337 }
338
339 #[test]
340 fn invalid_format_rejected() {
341 assert!(!LoopFingerprint::is_valid_format("not_a_fingerprint"));
342 assert!(!LoopFingerprint::is_valid_format("loop_fp_tooshort"));
343 assert!(!LoopFingerprint::is_valid_format("wrong_fp_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"));
344 }
345}