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