Skip to main content

lean_ctx/proxy/
pii.rs

1//! Person pseudonymization (enterprise#39, GDPR/DSGVO).
2//!
3//! `usage_events.person` is normally an e-mail address — personal data under
4//! GDPR. With `[gateway_server].pseudonymize_persons = true` the gateway
5//! replaces it at the identity choke-point (`attach_gateway_tags`) with a
6//! stable keyed hash: `p:<16 hex>`. One choke-point means budget ledgers,
7//! usage rows, dashboards, metrics and logs all see only the pseudonym.
8//!
9//! Properties:
10//!
11//! - **Stable per install**: keyed BLAKE3 with a per-install salt
12//!   (`<data_dir>/gateway_pii_salt`, created on first use, 0600). The same
13//!   person always maps to the same pseudonym, so per-person budgets and
14//!   rollups keep working.
15//! - **Not reversible** without the salt file; the salt never leaves the host.
16//! - **Re-identifiable on purpose** by the operator (GDPR Art. 15/17 requires
17//!   acting on "the data of person X"): `pseudonymize("x@acme.com")` recomputes
18//!   the key, which is exactly what the `gateway gdpr` CLI does.
19//!
20//! Normalization: e-mail-ish inputs are trimmed + lowercased before hashing so
21//! `A@Acme.com` and `a@acme.com` land on one pseudonym.
22
23use std::io::Write;
24use std::path::PathBuf;
25use std::sync::OnceLock;
26
27/// Pseudonym prefix — makes pseudonymized rows self-describing in exports,
28/// dashboards and GDPR tooling.
29pub const PSEUDONYM_PREFIX: &str = "p:";
30
31/// True when the deployment opted into pseudonymization.
32#[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/// Applies the configured person policy: pseudonym when enabled, identity
41/// otherwise. The single entry point for the auth guard.
42#[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/// The stable pseudonym for a person: `p:` + first 16 hex chars of
52/// `BLAKE3_keyed(salt, normalized_person)`. Already-pseudonymized inputs pass
53/// through unchanged (idempotent — safe on re-tagged requests).
54#[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/// All storage keys a GDPR request for `person` must match: the raw value
67/// (pre-pseudonymization rows, or pseudonymization off) and the pseudonym.
68#[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
85/// Loads (or creates once) the per-install salt. Process-cached: the salt is
86/// immutable for the lifetime of an install.
87fn 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            // Extremely unlikely; fall back to a hash of the path + boot time
99            // rather than aborting the request path.
100            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 => {} // raced by a sibling process
131        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        // Idempotent: pseudonymizing a pseudonym is a no-op.
166        assert_eq!(pseudonymize(&a), a);
167        // Different persons → different pseudonyms.
168        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        // Salt is cached in-process; the file must exist for restarts.
176        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}