Skip to main content

lean_ctx_proxy/
pii.rs

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