Skip to main content

loop_agent_sdk/
privacy.rs

1//! Loop Agent SDK - Privacy Layer
2//! 
3//! Blind indexing for card fingerprints.
4//! Loop never stores raw card IDs — only one-way cryptographic tags.
5//! 
6//! ## Security Model
7//! 
8//! - **Pepper**: System-wide secret stored in AWS Secrets Manager
9//! - **Algorithm**: HMAC-SHA256
10//! - **Output**: `loop_fp_{hex_hash}`
11//! - **Irreversible**: Even with DB access, card IDs cannot be recovered
12//! 
13//! ## Double-Blind Vaulting
14//! 
15//! 1. Webhook arrives with `card_id`
16//! 2. Immediately hash to `loop_fp_*`
17//! 3. Purge raw `card_id` from memory
18//! 4. All subsequent operations use only the fingerprint
19//! 
20//! This means:
21//! - Logs never contain card IDs
22//! - DynamoDB never sees card IDs
23//! - Even Loop engineers cannot reverse fingerprints
24
25use hmac::{Hmac, Mac};
26use sha2::Sha256;
27use std::sync::OnceLock;
28use tracing::{info, warn};
29use zeroize::Zeroize;
30
31type HmacSha256 = Hmac<Sha256>;
32
33/// Prefix for all Loop fingerprints
34const FINGERPRINT_PREFIX: &str = "loop_fp_";
35
36/// Cached pepper (loaded once from Secrets Manager)
37static PEPPER: OnceLock<Vec<u8>> = OnceLock::new();
38
39/// Privacy configuration
40#[derive(Debug, Clone)]
41pub struct PrivacyConfig {
42    /// AWS Secrets Manager secret name for pepper
43    pub pepper_secret_name: String,
44    /// AWS region for Secrets Manager
45    pub aws_region: String,
46    /// Fallback pepper for testing (NEVER use in production)
47    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    /// Create config for testing with explicit pepper
64    /// WARNING: Only use in tests, never in production
65    #[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
75/// Privacy layer for card fingerprint hashing
76pub struct PrivacyLayer {
77    pepper: Vec<u8>,
78}
79
80impl PrivacyLayer {
81    /// Initialize privacy layer with pepper from Secrets Manager
82    pub async fn new(config: &PrivacyConfig) -> Result<Self, PrivacyError> {
83        // Check if pepper is already cached
84        if let Some(pepper) = PEPPER.get() {
85            return Ok(Self { pepper: pepper.clone() });
86        }
87        
88        // Use test pepper if provided (testing only)
89        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        // Load from Secrets Manager
96        let pepper = Self::load_pepper_from_secrets_manager(config).await?;
97        
98        // Cache for future use
99        let _ = PEPPER.set(pepper.clone());
100        
101        info!("Privacy layer initialized with pepper from Secrets Manager");
102        Ok(Self { pepper })
103    }
104    
105    /// Load pepper from AWS Secrets Manager
106    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        // Secret should be stored as base64-encoded bytes
118        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    /// Hash a card ID to a Loop fingerprint
136    /// 
137    /// # Double-Blind Protocol
138    /// 
139    /// This function:
140    /// 1. Takes ownership of `card_id` (caller loses access)
141    /// 2. Hashes immediately
142    /// 3. Zeroizes the input from memory
143    /// 4. Returns only the fingerprint
144    /// 
145    /// After calling this, the raw card_id no longer exists in memory.
146    pub fn hash_card_id(&self, mut card_id: String) -> LoopFingerprint {
147        // Create HMAC
148        let mut mac = HmacSha256::new_from_slice(&self.pepper)
149            .expect("HMAC can take key of any size");
150        
151        // Hash the card_id
152        mac.update(card_id.as_bytes());
153        
154        // CRITICAL: Zeroize the raw card_id from memory
155        card_id.zeroize();
156        
157        // Finalize and convert to hex
158        let result = mac.finalize();
159        let hash_bytes = result.into_bytes();
160        let hex_hash = hex::encode(hash_bytes);
161        
162        // Return prefixed fingerprint
163        LoopFingerprint(format!("{}{}", FINGERPRINT_PREFIX, hex_hash))
164    }
165    
166    /// Hash card ID bytes (for binary inputs)
167    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        // Zeroize raw bytes
174        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    /// Verify a fingerprint matches a card_id
183    /// Used for testing/validation only
184    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/// A Loop fingerprint (one-way hash of card_id)
191/// 
192/// Format: `loop_fp_{64 hex characters}`
193/// 
194/// This type ensures fingerprints are always properly formatted
195/// and cannot be confused with raw card IDs.
196#[derive(Debug, Clone, PartialEq, Eq, Hash)]
197pub struct LoopFingerprint(String);
198
199impl LoopFingerprint {
200    /// Get the fingerprint string
201    pub fn as_str(&self) -> &str {
202        &self.0
203    }
204    
205    /// Convert to owned string
206    pub fn into_string(self) -> String {
207        self.0
208    }
209    
210    /// Check if a string is a valid Loop fingerprint format
211    pub fn is_valid_format(s: &str) -> bool {
212        s.starts_with(FINGERPRINT_PREFIX) 
213            && s.len() == FINGERPRINT_PREFIX.len() + 64  // 64 hex chars = 32 bytes
214            && s[FINGERPRINT_PREFIX.len()..].chars().all(|c| c.is_ascii_hexdigit())
215    }
216    
217    /// Parse from string (validates format)
218    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/// Privacy layer errors
240#[derive(Debug, Clone)]
241pub enum PrivacyError {
242    /// Failed to load pepper from Secrets Manager
243    SecretLoadFailed(String),
244    /// Invalid fingerprint format
245    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
259// ============================================================================
260// SECRETS MANAGER SETUP HELPER
261// ============================================================================
262
263/// Generate a new random pepper (32 bytes)
264/// Run this once to create the initial secret
265pub 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
272/// Create the pepper secret in AWS Secrets Manager
273/// Run this during initial setup
274pub 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); // prefix + 64 hex chars
309        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}