Skip to main content

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}