use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::sync::OnceLock;
use tracing::{info, warn};
use zeroize::Zeroize;
type HmacSha256 = Hmac<Sha256>;
const FINGERPRINT_PREFIX: &str = "loop_fp_";
static PEPPER: OnceLock<Vec<u8>> = OnceLock::new();
#[derive(Debug, Clone)]
pub struct PrivacyConfig {
pub pepper_secret_name: String,
pub aws_region: String,
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 {
#[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()),
}
}
}
pub struct PrivacyLayer {
pepper: Vec<u8>,
}
impl PrivacyLayer {
pub async fn new(config: &PrivacyConfig) -> Result<Self, PrivacyError> {
if let Some(pepper) = PEPPER.get() {
return Ok(Self { pepper: pepper.clone() });
}
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() });
}
let pepper = Self::load_pepper_from_secrets_manager(config).await?;
let _ = PEPPER.set(pepper.clone());
info!("Privacy layer initialized with pepper from Secrets Manager");
Ok(Self { pepper })
}
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()))?;
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)
}
pub fn hash_card_id(&self, mut card_id: String) -> LoopFingerprint {
let mut mac = HmacSha256::new_from_slice(&self.pepper)
.expect("HMAC can take key of any size");
mac.update(card_id.as_bytes());
card_id.zeroize();
let result = mac.finalize();
let hash_bytes = result.into_bytes();
let hex_hash = hex::encode(hash_bytes);
LoopFingerprint(format!("{}{}", FINGERPRINT_PREFIX, hex_hash))
}
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);
card_bytes.zeroize();
let result = mac.finalize();
let hex_hash = hex::encode(result.into_bytes());
LoopFingerprint(format!("{}{}", FINGERPRINT_PREFIX, hex_hash))
}
pub fn verify(&self, card_id: &str, fingerprint: &LoopFingerprint) -> bool {
let computed = self.hash_card_id(card_id.to_string());
computed.0 == fingerprint.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct LoopFingerprint(String);
impl LoopFingerprint {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
pub fn is_valid_format(s: &str) -> bool {
s.starts_with(FINGERPRINT_PREFIX)
&& s.len() == FINGERPRINT_PREFIX.len() + 64 && s[FINGERPRINT_PREFIX.len()..].chars().all(|c| c.is_ascii_hexdigit())
}
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
}
}
#[derive(Debug, Clone)]
pub enum PrivacyError {
SecretLoadFailed(String),
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 {}
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)
}
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); 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"));
}
}