loop-agent-sdk 0.1.0

Trustless agent SDK for Loop Protocol — intent-based execution on Solana.
Documentation
//! Loop Agent SDK - Privacy Layer
//! 
//! Blind indexing for card fingerprints.
//! Loop never stores raw card IDs — only one-way cryptographic tags.
//! 
//! ## Security Model
//! 
//! - **Pepper**: System-wide secret stored in AWS Secrets Manager
//! - **Algorithm**: HMAC-SHA256
//! - **Output**: `loop_fp_{hex_hash}`
//! - **Irreversible**: Even with DB access, card IDs cannot be recovered
//! 
//! ## Double-Blind Vaulting
//! 
//! 1. Webhook arrives with `card_id`
//! 2. Immediately hash to `loop_fp_*`
//! 3. Purge raw `card_id` from memory
//! 4. All subsequent operations use only the fingerprint
//! 
//! This means:
//! - Logs never contain card IDs
//! - DynamoDB never sees card IDs
//! - Even Loop engineers cannot reverse fingerprints

use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::sync::OnceLock;
use tracing::{info, warn};
use zeroize::Zeroize;

type HmacSha256 = Hmac<Sha256>;

/// Prefix for all Loop fingerprints
const FINGERPRINT_PREFIX: &str = "loop_fp_";

/// Cached pepper (loaded once from Secrets Manager)
static PEPPER: OnceLock<Vec<u8>> = OnceLock::new();

/// Privacy configuration
#[derive(Debug, Clone)]
pub struct PrivacyConfig {
    /// AWS Secrets Manager secret name for pepper
    pub pepper_secret_name: String,
    /// AWS region for Secrets Manager
    pub aws_region: String,
    /// Fallback pepper for testing (NEVER use in production)
    pub test_pepper: Option<Vec<u8>>,
}

impl Default for PrivacyConfig {
    fn default() -> Self {
        Self {
            pepper_secret_name: std::env::var("PEPPER_SECRET_NAME")
                .unwrap_or_else(|_| "loop/agent/pepper".to_string()),
            aws_region: std::env::var("AWS_REGION")
                .unwrap_or_else(|_| "us-east-1".to_string()),
            test_pepper: None,
        }
    }
}

impl PrivacyConfig {
    /// Create config for testing with explicit pepper
    /// WARNING: Only use in tests, never in production
    #[cfg(test)]
    pub fn for_testing(pepper: &[u8]) -> Self {
        Self {
            pepper_secret_name: "test".to_string(),
            aws_region: "us-east-1".to_string(),
            test_pepper: Some(pepper.to_vec()),
        }
    }
}

/// Privacy layer for card fingerprint hashing
pub struct PrivacyLayer {
    pepper: Vec<u8>,
}

impl PrivacyLayer {
    /// Initialize privacy layer with pepper from Secrets Manager
    pub async fn new(config: &PrivacyConfig) -> Result<Self, PrivacyError> {
        // Check if pepper is already cached
        if let Some(pepper) = PEPPER.get() {
            return Ok(Self { pepper: pepper.clone() });
        }
        
        // Use test pepper if provided (testing only)
        if let Some(ref test_pepper) = config.test_pepper {
            warn!("Using test pepper - NOT FOR PRODUCTION");
            let _ = PEPPER.set(test_pepper.clone());
            return Ok(Self { pepper: test_pepper.clone() });
        }
        
        // Load from Secrets Manager
        let pepper = Self::load_pepper_from_secrets_manager(config).await?;
        
        // Cache for future use
        let _ = PEPPER.set(pepper.clone());
        
        info!("Privacy layer initialized with pepper from Secrets Manager");
        Ok(Self { pepper })
    }
    
    /// Load pepper from AWS Secrets Manager
    async fn load_pepper_from_secrets_manager(config: &PrivacyConfig) -> Result<Vec<u8>, PrivacyError> {
        let aws_config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
        let client = aws_sdk_secretsmanager::Client::new(&aws_config);
        
        let response = client
            .get_secret_value()
            .secret_id(&config.pepper_secret_name)
            .send()
            .await
            .map_err(|e| PrivacyError::SecretLoadFailed(e.to_string()))?;
        
        // Secret should be stored as base64-encoded bytes
        let secret_string = response.secret_string()
            .ok_or_else(|| PrivacyError::SecretLoadFailed("Secret has no string value".into()))?;
        
        let pepper = base64::Engine::decode(
            &base64::engine::general_purpose::STANDARD,
            secret_string.trim(),
        ).map_err(|e| PrivacyError::SecretLoadFailed(format!("Invalid base64: {}", e)))?;
        
        if pepper.len() < 32 {
            return Err(PrivacyError::SecretLoadFailed(
                "Pepper must be at least 32 bytes".into()
            ));
        }
        
        Ok(pepper)
    }
    
    /// Hash a card ID to a Loop fingerprint
    /// 
    /// # Double-Blind Protocol
    /// 
    /// This function:
    /// 1. Takes ownership of `card_id` (caller loses access)
    /// 2. Hashes immediately
    /// 3. Zeroizes the input from memory
    /// 4. Returns only the fingerprint
    /// 
    /// After calling this, the raw card_id no longer exists in memory.
    pub fn hash_card_id(&self, mut card_id: String) -> LoopFingerprint {
        // Create HMAC
        let mut mac = HmacSha256::new_from_slice(&self.pepper)
            .expect("HMAC can take key of any size");
        
        // Hash the card_id
        mac.update(card_id.as_bytes());
        
        // CRITICAL: Zeroize the raw card_id from memory
        card_id.zeroize();
        
        // Finalize and convert to hex
        let result = mac.finalize();
        let hash_bytes = result.into_bytes();
        let hex_hash = hex::encode(hash_bytes);
        
        // Return prefixed fingerprint
        LoopFingerprint(format!("{}{}", FINGERPRINT_PREFIX, hex_hash))
    }
    
    /// Hash card ID bytes (for binary inputs)
    pub fn hash_card_bytes(&self, mut card_bytes: Vec<u8>) -> LoopFingerprint {
        let mut mac = HmacSha256::new_from_slice(&self.pepper)
            .expect("HMAC can take key of any size");
        
        mac.update(&card_bytes);
        
        // Zeroize raw bytes
        card_bytes.zeroize();
        
        let result = mac.finalize();
        let hex_hash = hex::encode(result.into_bytes());
        
        LoopFingerprint(format!("{}{}", FINGERPRINT_PREFIX, hex_hash))
    }
    
    /// Verify a fingerprint matches a card_id
    /// Used for testing/validation only
    pub fn verify(&self, card_id: &str, fingerprint: &LoopFingerprint) -> bool {
        let computed = self.hash_card_id(card_id.to_string());
        computed.0 == fingerprint.0
    }
}

/// A Loop fingerprint (one-way hash of card_id)
/// 
/// Format: `loop_fp_{64 hex characters}`
/// 
/// This type ensures fingerprints are always properly formatted
/// and cannot be confused with raw card IDs.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct LoopFingerprint(String);

impl LoopFingerprint {
    /// Get the fingerprint string
    pub fn as_str(&self) -> &str {
        &self.0
    }
    
    /// Convert to owned string
    pub fn into_string(self) -> String {
        self.0
    }
    
    /// Check if a string is a valid Loop fingerprint format
    pub fn is_valid_format(s: &str) -> bool {
        s.starts_with(FINGERPRINT_PREFIX) 
            && s.len() == FINGERPRINT_PREFIX.len() + 64  // 64 hex chars = 32 bytes
            && s[FINGERPRINT_PREFIX.len()..].chars().all(|c| c.is_ascii_hexdigit())
    }
    
    /// Parse from string (validates format)
    pub fn parse(s: &str) -> Option<Self> {
        if Self::is_valid_format(s) {
            Some(Self(s.to_string()))
        } else {
            None
        }
    }
}

impl std::fmt::Display for LoopFingerprint {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl AsRef<str> for LoopFingerprint {
    fn as_ref(&self) -> &str {
        &self.0
    }
}

/// Privacy layer errors
#[derive(Debug, Clone)]
pub enum PrivacyError {
    /// Failed to load pepper from Secrets Manager
    SecretLoadFailed(String),
    /// Invalid fingerprint format
    InvalidFingerprint(String),
}

impl std::fmt::Display for PrivacyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::SecretLoadFailed(msg) => write!(f, "Failed to load pepper: {}", msg),
            Self::InvalidFingerprint(msg) => write!(f, "Invalid fingerprint: {}", msg),
        }
    }
}

impl std::error::Error for PrivacyError {}

// ============================================================================
// SECRETS MANAGER SETUP HELPER
// ============================================================================

/// Generate a new random pepper (32 bytes)
/// Run this once to create the initial secret
pub fn generate_pepper() -> String {
    use rand::RngCore;
    let mut pepper = [0u8; 32];
    rand::thread_rng().fill_bytes(&mut pepper);
    base64::Engine::encode(&base64::engine::general_purpose::STANDARD, pepper)
}

/// Create the pepper secret in AWS Secrets Manager
/// Run this during initial setup
pub async fn create_pepper_secret(secret_name: &str) -> Result<(), Box<dyn std::error::Error>> {
    let pepper_b64 = generate_pepper();
    
    let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
    let client = aws_sdk_secretsmanager::Client::new(&config);
    
    client
        .create_secret()
        .name(secret_name)
        .secret_string(&pepper_b64)
        .description("Loop Agent SDK - Card fingerprint HMAC pepper. DO NOT DELETE.")
        .send()
        .await?;
    
    info!(secret_name = %secret_name, "Created pepper secret in Secrets Manager");
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    
    fn test_privacy_layer() -> PrivacyLayer {
        PrivacyLayer {
            pepper: b"test_pepper_32_bytes_minimum_ok!".to_vec(),
        }
    }
    
    #[test]
    fn fingerprint_format_is_correct() {
        let privacy = test_privacy_layer();
        let fp = privacy.hash_card_id("card_abc123".to_string());
        
        assert!(fp.as_str().starts_with("loop_fp_"));
        assert_eq!(fp.as_str().len(), 8 + 64); // prefix + 64 hex chars
        assert!(LoopFingerprint::is_valid_format(fp.as_str()));
    }
    
    #[test]
    fn same_input_same_output() {
        let privacy = test_privacy_layer();
        let fp1 = privacy.hash_card_id("card_abc123".to_string());
        let fp2 = privacy.hash_card_id("card_abc123".to_string());
        
        assert_eq!(fp1, fp2);
    }
    
    #[test]
    fn different_input_different_output() {
        let privacy = test_privacy_layer();
        let fp1 = privacy.hash_card_id("card_abc123".to_string());
        let fp2 = privacy.hash_card_id("card_xyz789".to_string());
        
        assert_ne!(fp1, fp2);
    }
    
    #[test]
    fn verify_works() {
        let privacy = test_privacy_layer();
        let fp = privacy.hash_card_id("card_abc123".to_string());
        
        assert!(privacy.verify("card_abc123", &fp));
        assert!(!privacy.verify("card_wrong", &fp));
    }
    
    #[test]
    fn invalid_format_rejected() {
        assert!(!LoopFingerprint::is_valid_format("not_a_fingerprint"));
        assert!(!LoopFingerprint::is_valid_format("loop_fp_tooshort"));
        assert!(!LoopFingerprint::is_valid_format("wrong_fp_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"));
    }
}