1use std::io::Write;
25use std::path::PathBuf;
26use std::sync::OnceLock;
27
28pub const PSEUDONYM_PREFIX: &str = "p:";
31
32#[must_use]
34pub fn enabled() -> bool {
35 lean_ctx::core::config::Config::load()
36 .gateway_server
37 .pseudonymize_persons
38 .unwrap_or(false)
39}
40
41#[must_use]
44pub fn effective_person(person: &str) -> String {
45 if enabled() {
46 pseudonymize(person)
47 } else {
48 person.to_string()
49 }
50}
51
52#[must_use]
56pub fn pseudonymize(person: &str) -> String {
57 let normalized = person.trim().to_lowercase();
58 if normalized.starts_with(PSEUDONYM_PREFIX) {
59 return normalized;
60 }
61 let key = salt();
62 let hash = blake3::keyed_hash(&key, normalized.as_bytes());
63 let hex = hash.to_hex();
64 format!("{PSEUDONYM_PREFIX}{}", &hex.as_str()[..16])
65}
66
67#[must_use]
70pub fn person_match_keys(person: &str) -> Vec<String> {
71 let raw = person.trim().to_string();
72 let pseudo = pseudonymize(person);
73 if raw == pseudo {
74 vec![raw]
75 } else {
76 vec![raw, pseudo]
77 }
78}
79
80fn salt_path() -> PathBuf {
81 lean_ctx::core::paths::data_dir()
82 .unwrap_or_else(|_| PathBuf::from("."))
83 .join("gateway_pii_salt")
84}
85
86fn salt() -> [u8; 32] {
89 static SALT: OnceLock<[u8; 32]> = OnceLock::new();
90 *SALT.get_or_init(|| {
91 let path = salt_path();
92 if let Ok(raw) = std::fs::read_to_string(&path)
93 && let Some(bytes) = decode_hex_32(raw.trim())
94 {
95 return bytes;
96 }
97 let mut bytes = [0u8; 32];
98 if getrandom::fill(&mut bytes).is_err() {
99 let fallback = blake3::hash(
102 format!("{}:{:?}", path.display(), std::time::SystemTime::now()).as_bytes(),
103 );
104 bytes.copy_from_slice(fallback.as_bytes());
105 }
106 persist_salt(&path, &bytes);
107 bytes
108 })
109}
110
111fn persist_salt(path: &std::path::Path, bytes: &[u8; 32]) {
112 let hex: String = bytes.iter().fold(String::new(), |mut acc, b| {
113 use std::fmt::Write as _;
114 let _ = write!(acc, "{b:02x}");
115 acc
116 });
117 if let Some(parent) = path.parent() {
118 let _ = std::fs::create_dir_all(parent);
119 }
120 let mut opts = std::fs::OpenOptions::new();
121 opts.write(true).create_new(true);
122 #[cfg(unix)]
123 {
124 use std::os::unix::fs::OpenOptionsExt;
125 opts.mode(0o600);
126 }
127 match opts.open(path) {
128 Ok(mut f) => {
129 let _ = f.write_all(hex.as_bytes());
130 }
131 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} Err(e) => {
133 tracing::warn!(
134 "gateway PII salt not persisted ({}): {e} — pseudonyms will rotate on restart",
135 path.display()
136 );
137 }
138 }
139}
140
141fn decode_hex_32(s: &str) -> Option<[u8; 32]> {
142 if s.len() != 64 {
143 return None;
144 }
145 let mut out = [0u8; 32];
146 for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
147 let hi = (chunk[0] as char).to_digit(16)?;
148 let lo = (chunk[1] as char).to_digit(16)?;
149 out[i] = u8::try_from(hi * 16 + lo).ok()?;
150 }
151 Some(out)
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn pseudonym_is_stable_normalized_and_prefixed() {
160 let _iso = lean_ctx::core::data_dir::isolated_data_dir();
161 let a = pseudonymize("Yves@Acme.com ");
162 let b = pseudonymize("yves@acme.com");
163 assert_eq!(a, b, "normalization must collapse case/whitespace");
164 assert!(a.starts_with(PSEUDONYM_PREFIX));
165 assert_eq!(a.len(), PSEUDONYM_PREFIX.len() + 16);
166 assert_eq!(pseudonymize(&a), a);
168 assert_ne!(pseudonymize("mara@acme.com"), a);
170 }
171
172 #[test]
173 fn salt_persists_across_calls() {
174 let _iso = lean_ctx::core::data_dir::isolated_data_dir();
175 let first = pseudonymize("someone@acme.com");
176 assert_eq!(pseudonymize("someone@acme.com"), first);
178 }
179
180 #[test]
181 fn match_keys_cover_raw_and_pseudonym() {
182 let _iso = lean_ctx::core::data_dir::isolated_data_dir();
183 let keys = person_match_keys("gdpr@acme.com");
184 assert_eq!(keys.len(), 2);
185 assert_eq!(keys[0], "gdpr@acme.com");
186 assert!(keys[1].starts_with(PSEUDONYM_PREFIX));
187 }
188
189 #[test]
190 fn hex_roundtrip() {
191 let bytes = [7u8; 32];
192 let hex = lean_ctx::core::agent_identity::hex_encode(&bytes);
193 assert_eq!(decode_hex_32(&hex), Some(bytes));
194 assert_eq!(decode_hex_32("zz"), None);
195 }
196}