palisade_errors/obfuscation.rs
1//! Error code obfuscation (always on).
2//!
3//! Makes systematic error code fingerprinting harder by adding per-session
4//! offsets to error codes. The same semantic error will have different codes
5//! across sessions, making it harder for attackers to build a code map.
6//!
7//! # Security Model
8//!
9//! - **Namespace preserved**: Still "CFG", "IO", etc. (needed for Display)
10//! - **Category preserved**: Still Configuration, I/O, etc.
11//! - **Numeric code obfuscated**: E-CFG-100 becomes E-CFG-103, E-CFG-107, etc.
12//! - **Session-specific**: Different salt per connection/session
13//! - **Deterministic within session**: Same error = same obfuscated code
14//!
15//! # Threat Mitigation
16//!
17//! **Without obfuscation:**
18//! ```text
19//! Attacker triggers 100 errors, sees:
20//! E-CFG-100 (repeated 50x)
21//! E-CFG-101 (repeated 30x)
22//! E-CFG-104 (repeated 20x)
23//!
24//! Maps to source code, identifies:
25//! - 100 = parser.rs:42
26//! - 101 = validator.rs:89
27//! - 104 = permissions.rs:156
28//! ```
29//!
30//! **With obfuscation:**
31//! ```text
32//! Session 1: E-CFG-103, E-CFG-104, E-CFG-107
33//! Session 2: E-CFG-101, E-CFG-102, E-CFG-105
34//! Session 3: E-CFG-106, E-CFG-107, E-CFG-110
35//!
36//! Attacker cannot correlate codes across sessions.
37//! Fingerprinting requires compromising a session to learn its salt.
38//! ```
39//!
40//! # Performance
41//!
42//! Overhead:
43//! Initialize session salt: 352 ps (2.8T ops/sec)
44//! Obfuscate error code: 14 ns (71.4M ops/sec)
45//! Generate random salt: 72 ns (13.9M ops/sec)
46//! Error with obfuscation: 243 ns (4.1M errors/sec)
47
48use crate::ErrorCode;
49use std::cell::Cell;
50use std::sync::atomic::{AtomicU64, Ordering};
51
52// Thread-local session salt for error code obfuscation.
53//
54// Each thread/session has its own salt and doesn't share with others.
55thread_local! {
56 static SESSION_SALT: Cell<u8> = const { Cell::new(0) };
57}
58
59/// Counter mixed into generated salts to avoid repeats under high call rates.
60static SALT_COUNTER: AtomicU64 = AtomicU64::new(0x9E37_79B9_7F4A_7C15);
61
62/// Initialize session-specific error code salt.
63///
64/// Call this once per session/connection to enable per-session obfuscation.
65/// The salt affects all errors created in this thread until re-initialized.
66///
67/// # Arguments
68///
69/// * `seed` - Any u32 value (session ID, connection hash, random number)
70///
71/// # Implementation Note
72///
73/// We use only the lower 3 bits (0-7 range) to keep codes within
74/// their namespace ranges and avoid collisions.
75#[inline]
76pub fn init_session_salt(seed: u32) {
77 // Use lower 3 bits: gives us 8 different offsets (0-7)
78 // This keeps codes well within their 100-range namespaces
79 let salt = (seed & 0b111) as u8;
80 SESSION_SALT.with(|v| v.set(salt));
81}
82
83/// Get current session salt value.
84///
85/// Useful for debugging or logging which salt is active.
86#[inline]
87pub fn get_session_salt() -> u32 {
88 SESSION_SALT.with(|v| v.get() as u32)
89}
90
91/// Clear session salt (revert to no obfuscation).
92///
93/// Useful for testing or when switching contexts.
94#[inline]
95pub fn clear_session_salt() {
96 SESSION_SALT.with(|v| v.set(0));
97}
98
99#[inline]
100fn splitmix64(mut x: u64) -> u64 {
101 x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
102 x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
103 x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
104 x ^ (x >> 31)
105}
106
107/// Apply obfuscation to an error code using current session salt.
108///
109/// Creates a new ErrorCode with:
110/// - Same namespace (e.g., "CFG")
111/// - Same category (e.g., Configuration)
112/// - Offset numeric code (e.g., 100 → 103)
113///
114/// The offset wraps within the namespace's range to avoid collisions.
115///
116/// # Example
117///
118/// ```rust
119/// use palisade_errors::{obfuscation, definitions};
120///
121/// // Base: E-CFG-100
122/// obfuscation::init_session_salt(3);
123/// let obfuscated = obfuscation::obfuscate_code(&definitions::CFG_PARSE_FAILED);
124/// // Result: E-CFG-103
125///
126/// obfuscation::init_session_salt(7);
127/// let obfuscated = obfuscation::obfuscate_code(&definitions::CFG_PARSE_FAILED);
128/// // Result: E-CFG-107
129/// ```
130///
131/// # Namespace Safety
132///
133/// The obfuscation ensures codes stay within their namespace:
134/// - CFG (100-199): offsets wrap within 100-199
135/// - IO (800-899): offsets wrap within 800-899
136/// - etc.
137#[inline]
138pub fn obfuscate_code(base: &ErrorCode) -> ErrorCode {
139 let salt = get_session_salt();
140 let base_code = base.code();
141
142 // Calculate namespace boundaries
143 // E.g., for 150: namespace_base = 100, offset = 50
144 let namespace_base = (base_code / 100) * 100;
145 let offset = base_code % 100;
146
147 // Add salt and wrap within namespace (0-99 range per namespace)
148 let new_offset = (offset + salt as u16) % 100;
149 let new_code = namespace_base + new_offset;
150
151 // Create new code with same namespace and category
152 ErrorCode::const_new(base.namespace(), new_code, base.category(), base.impact())
153}
154
155/// Generate a random session salt using system entropy.
156///
157/// Useful for automatically initializing sessions without manual seed management.
158///
159/// # Example
160///
161/// ```rust
162/// use palisade_errors::obfuscation;
163///
164/// // Auto-generate salt for this session
165/// let salt = obfuscation::generate_random_salt();
166/// obfuscation::init_session_salt(salt);
167/// ```
168#[inline]
169pub fn generate_random_salt() -> u32 {
170 let now_nanos = std::time::SystemTime::now()
171 .duration_since(std::time::UNIX_EPOCH)
172 .map_or(0_u64, |d| d.as_nanos() as u64);
173 let counter = SALT_COUNTER.fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed);
174 let stack_hint = (&now_nanos as *const u64 as usize) as u64;
175
176 let mixed = splitmix64(now_nanos ^ counter.rotate_left(11) ^ stack_hint.rotate_left(17));
177 (mixed ^ (mixed >> 32)) as u32
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use crate::{ErrorCode, ImpactScore, OperationCategory};
184
185 #[test]
186 fn obfuscation_stays_within_namespace() {
187 let base = ErrorCode::const_new(
188 &crate::codes::namespaces::CFG,
189 150,
190 OperationCategory::Configuration,
191 ImpactScore::new(100),
192 );
193
194 for salt in 0..8 {
195 init_session_salt(salt);
196 let obfuscated = obfuscate_code(&base);
197
198 // Should stay in 100-199 range
199 assert!(obfuscated.code() >= 100, "Code {} below namespace", obfuscated.code());
200 assert!(obfuscated.code() <= 199, "Code {} above namespace", obfuscated.code());
201
202 // Namespace and category unchanged
203 assert_eq!(obfuscated.namespace().as_str(), "CFG");
204 assert_eq!(obfuscated.category(), OperationCategory::Configuration);
205 }
206 }
207
208 #[test]
209 fn different_salts_produce_different_codes() {
210 let base = ErrorCode::const_new(
211 &crate::codes::namespaces::CFG,
212 100,
213 OperationCategory::Configuration,
214 ImpactScore::new(100),
215 );
216
217 init_session_salt(0);
218 let code1 = obfuscate_code(&base);
219
220 init_session_salt(5);
221 let code2 = obfuscate_code(&base);
222
223 assert_ne!(code1.code(), code2.code());
224 }
225
226 #[test]
227 fn same_salt_produces_same_code() {
228 let base = ErrorCode::const_new(
229 &crate::codes::namespaces::CFG,
230 100,
231 OperationCategory::Configuration,
232 ImpactScore::new(100),
233 );
234
235 init_session_salt(3);
236 let code1 = obfuscate_code(&base);
237 let code2 = obfuscate_code(&base);
238
239 assert_eq!(code1.code(), code2.code());
240 }
241
242 #[test]
243 fn obfuscation_at_namespace_boundary() {
244 // Test edge cases at namespace boundaries
245 let base_low = ErrorCode::const_new(
246 &crate::codes::namespaces::CFG,
247 100,
248 OperationCategory::Configuration,
249 ImpactScore::new(100),
250 );
251 let base_high = ErrorCode::const_new(
252 &crate::codes::namespaces::CFG,
253 199,
254 OperationCategory::Configuration,
255 ImpactScore::new(100),
256 );
257
258 init_session_salt(7);
259 let obf_low = obfuscate_code(&base_low);
260 let obf_high = obfuscate_code(&base_high);
261
262 assert!(obf_low.code() >= 100 && obf_low.code() <= 199);
263 assert!(obf_high.code() >= 100 && obf_high.code() <= 199);
264 }
265
266 #[test]
267 fn wrapping_behavior() {
268 // Code at 195 + salt 7 = should wrap to 102
269 let base = ErrorCode::const_new(
270 &crate::codes::namespaces::CFG,
271 195,
272 OperationCategory::Configuration,
273 ImpactScore::new(100),
274 );
275 init_session_salt(7);
276 let obfuscated = obfuscate_code(&base);
277
278 // 195 % 100 = 95, (95 + 7) % 100 = 2, 100 + 2 = 102
279 assert_eq!(obfuscated.code(), 102);
280 }
281
282 #[test]
283 fn clear_salt_resets_to_zero() {
284 init_session_salt(5);
285 assert_eq!(get_session_salt(), 5);
286
287 clear_session_salt();
288 assert_eq!(get_session_salt(), 0);
289 }
290
291 #[test]
292 fn random_salt_generation() {
293 let salt1 = generate_random_salt();
294 let salt2 = generate_random_salt();
295
296 // Should be different (extremely high probability)
297 assert_ne!(salt1, salt2);
298
299 // Should be valid when used
300 init_session_salt(salt1);
301 assert_eq!(get_session_salt(), salt1 & 0b111);
302 }
303
304 #[test]
305 fn obfuscation_formatting() {
306 let base = ErrorCode::const_new(
307 &crate::codes::namespaces::CFG,
308 100,
309 OperationCategory::Configuration,
310 ImpactScore::new(100),
311 );
312 init_session_salt(3);
313 let obfuscated = obfuscate_code(&base);
314
315 assert_eq!(obfuscated.to_string(), "E-CFG-103");
316 }
317
318 #[test]
319 fn multiple_namespaces() {
320 init_session_salt(4);
321
322 let cfg = ErrorCode::const_new(
323 &crate::codes::namespaces::CFG,
324 100,
325 OperationCategory::Configuration,
326 ImpactScore::new(100),
327 );
328 let io = ErrorCode::const_new(
329 &crate::codes::namespaces::IO,
330 800,
331 OperationCategory::IO,
332 ImpactScore::new(100),
333 );
334
335 let cfg_obf = obfuscate_code(&cfg);
336 let io_obf = obfuscate_code(&io);
337
338 // Each stays in its namespace
339 assert_eq!(cfg_obf.code(), 104); // 100 + 4
340 assert_eq!(io_obf.code(), 804); // 800 + 4
341 }
342
343 #[test]
344 fn salt_is_thread_local() {
345 clear_session_salt();
346 init_session_salt(5);
347
348 let child = std::thread::spawn(get_session_salt)
349 .join()
350 .expect("thread should not panic");
351
352 // Child thread should not inherit caller's salt.
353 assert_eq!(child, 0);
354 // Caller thread must keep its own session salt.
355 assert_eq!(get_session_salt(), 5);
356 clear_session_salt();
357 }
358}