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::sync::atomic::{AtomicU32, Ordering};
50
51/// Thread-local session salt for error code obfuscation.
52///
53/// Uses relaxed ordering since we don't need synchronization -
54/// each thread/session has its own salt and doesn't care about others.
55static SESSION_SALT: AtomicU32 = AtomicU32::new(0);
56
57/// Initialize session-specific error code salt.
58///
59/// Call this once per session/connection to enable per-session obfuscation.
60/// The salt affects all errors created in this thread until re-initialized.
61///
62/// # Arguments
63///
64/// * `seed` - Any u32 value (session ID, connection hash, random number)
65///
66/// # Implementation Note
67///
68/// We use only the lower 3 bits (0-7 range) to keep codes within
69/// their namespace ranges and avoid collisions.
70#[inline]
71pub fn init_session_salt(seed: u32) {
72 // Use lower 3 bits: gives us 8 different offsets (0-7)
73 // This keeps codes well within their 100-range namespaces
74 let salt = seed & 0b111;
75 SESSION_SALT.store(salt, Ordering::Relaxed);
76}
77
78/// Get current session salt value.
79///
80/// Useful for debugging or logging which salt is active.
81#[inline]
82pub fn get_session_salt() -> u32 {
83 SESSION_SALT.load(Ordering::Relaxed)
84}
85
86/// Clear session salt (revert to no obfuscation).
87///
88/// Useful for testing or when switching contexts.
89#[inline]
90pub fn clear_session_salt() {
91 SESSION_SALT.store(0, Ordering::Relaxed);
92}
93
94/// Apply obfuscation to an error code using current session salt.
95///
96/// Creates a new ErrorCode with:
97/// - Same namespace (e.g., "CFG")
98/// - Same category (e.g., Configuration)
99/// - Offset numeric code (e.g., 100 → 103)
100///
101/// The offset wraps within the namespace's range to avoid collisions.
102///
103/// # Example
104///
105/// ```rust
106/// use palisade_errors::{obfuscation, definitions};
107///
108/// // Base: E-CFG-100
109/// obfuscation::init_session_salt(3);
110/// let obfuscated = obfuscation::obfuscate_code(&definitions::CFG_PARSE_FAILED);
111/// // Result: E-CFG-103
112///
113/// obfuscation::init_session_salt(7);
114/// let obfuscated = obfuscation::obfuscate_code(&definitions::CFG_PARSE_FAILED);
115/// // Result: E-CFG-107
116/// ```
117///
118/// # Namespace Safety
119///
120/// The obfuscation ensures codes stay within their namespace:
121/// - CFG (100-199): offsets wrap within 100-199
122/// - IO (800-899): offsets wrap within 800-899
123/// - etc.
124#[inline]
125pub fn obfuscate_code(base: &ErrorCode) -> ErrorCode {
126 let salt = get_session_salt();
127 let base_code = base.code();
128
129 // Calculate namespace boundaries
130 // E.g., for 150: namespace_base = 100, offset = 50
131 let namespace_base = (base_code / 100) * 100;
132 let offset = base_code % 100;
133
134 // Add salt and wrap within namespace (0-99 range per namespace)
135 let new_offset = (offset + salt as u16) % 100;
136 let new_code = namespace_base + new_offset;
137
138 // Create new code with same namespace and category
139 ErrorCode::const_new(base.namespace(), new_code, base.category(), base.impact())
140}
141
142/// Generate a random session salt using system entropy.
143///
144/// Useful for automatically initializing sessions without manual seed management.
145///
146/// # Example
147///
148/// ```rust
149/// use palisade_errors::obfuscation;
150///
151/// // Auto-generate salt for this session
152/// let salt = obfuscation::generate_random_salt();
153/// obfuscation::init_session_salt(salt);
154/// ```
155#[inline]
156pub fn generate_random_salt() -> u32 {
157 use std::collections::hash_map::RandomState;
158 use std::hash::{BuildHasher, Hasher};
159
160 let random_state = RandomState::new();
161 let mut hasher = random_state.build_hasher();
162
163 // Hash current time + thread ID for entropy
164 let now = std::time::SystemTime::now()
165 .duration_since(std::time::UNIX_EPOCH)
166 .unwrap()
167 .as_nanos();
168
169 hasher.write_u128(now);
170
171 // Also mix in thread ID if available
172 #[cfg(not(target_arch = "wasm32"))]
173 {
174 use std::hash::Hash;
175 let thread_id = std::thread::current().id();
176 thread_id.hash(&mut hasher);
177 }
178
179 hasher.finish() as u32
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use crate::{ErrorCode, ImpactScore, OperationCategory};
186
187 #[test]
188 fn obfuscation_stays_within_namespace() {
189 let base = ErrorCode::const_new(
190 &crate::codes::namespaces::CFG,
191 150,
192 OperationCategory::Configuration,
193 ImpactScore::new(100),
194 );
195
196 for salt in 0..8 {
197 init_session_salt(salt);
198 let obfuscated = obfuscate_code(&base);
199
200 // Should stay in 100-199 range
201 assert!(obfuscated.code() >= 100, "Code {} below namespace", obfuscated.code());
202 assert!(obfuscated.code() <= 199, "Code {} above namespace", obfuscated.code());
203
204 // Namespace and category unchanged
205 assert_eq!(obfuscated.namespace().as_str(), "CFG");
206 assert_eq!(obfuscated.category(), OperationCategory::Configuration);
207 }
208 }
209
210 #[test]
211 fn different_salts_produce_different_codes() {
212 let base = ErrorCode::const_new(
213 &crate::codes::namespaces::CFG,
214 100,
215 OperationCategory::Configuration,
216 ImpactScore::new(100),
217 );
218
219 init_session_salt(0);
220 let code1 = obfuscate_code(&base);
221
222 init_session_salt(5);
223 let code2 = obfuscate_code(&base);
224
225 assert_ne!(code1.code(), code2.code());
226 }
227
228 #[test]
229 fn same_salt_produces_same_code() {
230 let base = ErrorCode::const_new(
231 &crate::codes::namespaces::CFG,
232 100,
233 OperationCategory::Configuration,
234 ImpactScore::new(100),
235 );
236
237 init_session_salt(3);
238 let code1 = obfuscate_code(&base);
239 let code2 = obfuscate_code(&base);
240
241 assert_eq!(code1.code(), code2.code());
242 }
243
244 #[test]
245 fn obfuscation_at_namespace_boundary() {
246 // Test edge cases at namespace boundaries
247 let base_low = ErrorCode::const_new(
248 &crate::codes::namespaces::CFG,
249 100,
250 OperationCategory::Configuration,
251 ImpactScore::new(100),
252 );
253 let base_high = ErrorCode::const_new(
254 &crate::codes::namespaces::CFG,
255 199,
256 OperationCategory::Configuration,
257 ImpactScore::new(100),
258 );
259
260 init_session_salt(7);
261 let obf_low = obfuscate_code(&base_low);
262 let obf_high = obfuscate_code(&base_high);
263
264 assert!(obf_low.code() >= 100 && obf_low.code() <= 199);
265 assert!(obf_high.code() >= 100 && obf_high.code() <= 199);
266 }
267
268 #[test]
269 fn wrapping_behavior() {
270 // Code at 195 + salt 7 = should wrap to 102
271 let base = ErrorCode::const_new(
272 &crate::codes::namespaces::CFG,
273 195,
274 OperationCategory::Configuration,
275 ImpactScore::new(100),
276 );
277 init_session_salt(7);
278 let obfuscated = obfuscate_code(&base);
279
280 // 195 % 100 = 95, (95 + 7) % 100 = 2, 100 + 2 = 102
281 assert_eq!(obfuscated.code(), 102);
282 }
283
284 #[test]
285 fn clear_salt_resets_to_zero() {
286 init_session_salt(5);
287 assert_eq!(get_session_salt(), 5);
288
289 clear_session_salt();
290 assert_eq!(get_session_salt(), 0);
291 }
292
293 #[test]
294 fn random_salt_generation() {
295 let salt1 = generate_random_salt();
296 let salt2 = generate_random_salt();
297
298 // Should be different (extremely high probability)
299 assert_ne!(salt1, salt2);
300
301 // Should be valid when used
302 init_session_salt(salt1);
303 assert_eq!(get_session_salt(), salt1 & 0b111);
304 }
305
306 #[test]
307 fn obfuscation_formatting() {
308 let base = ErrorCode::const_new(
309 &crate::codes::namespaces::CFG,
310 100,
311 OperationCategory::Configuration,
312 ImpactScore::new(100),
313 );
314 init_session_salt(3);
315 let obfuscated = obfuscate_code(&base);
316
317 assert_eq!(obfuscated.to_string(), "E-CFG-103");
318 }
319
320 #[test]
321 fn multiple_namespaces() {
322 init_session_salt(4);
323
324 let cfg = ErrorCode::const_new(
325 &crate::codes::namespaces::CFG,
326 100,
327 OperationCategory::Configuration,
328 ImpactScore::new(100),
329 );
330 let io = ErrorCode::const_new(
331 &crate::codes::namespaces::IO,
332 800,
333 OperationCategory::IO,
334 ImpactScore::new(100),
335 );
336
337 let cfg_obf = obfuscate_code(&cfg);
338 let io_obf = obfuscate_code(&io);
339
340 // Each stays in its namespace
341 assert_eq!(cfg_obf.code(), 104); // 100 + 4
342 assert_eq!(io_obf.code(), 804); // 800 + 4
343 }
344}