promocrypt_core/
audit.rs

1//! Audit trail for tracking .bin file history.
2//!
3//! Records creation, mastering, and generation events for compliance
4//! and debugging purposes.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// Record of a secret rotation event.
10#[derive(Clone, Debug, Serialize, Deserialize)]
11pub struct SecretRotation {
12    /// When the rotation occurred
13    pub timestamp: DateTime<Utc>,
14    /// Machine ID (hex) that performed the rotation
15    pub machine_id: String,
16    /// SHA256 hash of old secret (for verification)
17    pub old_secret_hash: String,
18    /// SHA256 hash of new secret
19    pub new_secret_hash: String,
20}
21
22/// Record of a machine mastering event.
23#[derive(Clone, Debug, Serialize, Deserialize)]
24pub struct MachineMastering {
25    /// When the mastering occurred
26    pub timestamp: DateTime<Utc>,
27    /// Machine ID (hex) that was previously bound
28    pub from_machine: String,
29    /// Machine ID (hex) that is now bound
30    pub to_machine: String,
31    /// Machine ID (hex) that performed the mastering
32    pub mastered_by: String,
33}
34
35/// Record of a configuration change.
36#[derive(Clone, Debug, Serialize, Deserialize)]
37pub struct ConfigChange {
38    /// When the change occurred
39    pub timestamp: DateTime<Utc>,
40    /// Machine ID (hex) that made the change
41    pub machine_id: String,
42    /// Field that was changed
43    pub field: String,
44    /// Old value (serialized)
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub old_value: Option<String>,
47    /// New value (serialized)
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub new_value: Option<String>,
50}
51
52/// Record of a code generation event.
53#[derive(Clone, Debug, Serialize, Deserialize)]
54pub struct GenerationLogEntry {
55    /// When the generation occurred
56    pub timestamp: DateTime<Utc>,
57    /// Machine ID (hex) that generated codes
58    pub machine_id: String,
59    /// Number of codes generated
60    pub count: u64,
61    /// Starting counter value
62    pub counter_start: u64,
63    /// Ending counter value (exclusive)
64    pub counter_end: u64,
65}
66
67/// History tracking for .bin files.
68#[derive(Clone, Debug, Default, Serialize, Deserialize)]
69pub struct History {
70    /// Secret rotation events
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub secret_rotations: Vec<SecretRotation>,
73    /// Machine mastering events
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub machine_masterings: Vec<MachineMastering>,
76    /// Configuration change events
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub config_changes: Vec<ConfigChange>,
79}
80
81impl History {
82    /// Create empty history.
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    /// Record a secret rotation.
88    pub fn record_secret_rotation(
89        &mut self,
90        machine_id: &[u8; 32],
91        old_secret_hash: &str,
92        new_secret_hash: &str,
93    ) {
94        self.secret_rotations.push(SecretRotation {
95            timestamp: Utc::now(),
96            machine_id: hex::encode(machine_id),
97            old_secret_hash: old_secret_hash.to_string(),
98            new_secret_hash: new_secret_hash.to_string(),
99        });
100    }
101
102    /// Record a machine mastering.
103    pub fn record_machine_mastering(
104        &mut self,
105        from_machine: &[u8; 32],
106        to_machine: &[u8; 32],
107        mastered_by: &[u8; 32],
108    ) {
109        self.machine_masterings.push(MachineMastering {
110            timestamp: Utc::now(),
111            from_machine: hex::encode(from_machine),
112            to_machine: hex::encode(to_machine),
113            mastered_by: hex::encode(mastered_by),
114        });
115    }
116
117    /// Record a configuration change.
118    pub fn record_config_change(
119        &mut self,
120        machine_id: &[u8; 32],
121        field: &str,
122        old_value: Option<&str>,
123        new_value: Option<&str>,
124    ) {
125        self.config_changes.push(ConfigChange {
126            timestamp: Utc::now(),
127            machine_id: hex::encode(machine_id),
128            field: field.to_string(),
129            old_value: old_value.map(String::from),
130            new_value: new_value.map(String::from),
131        });
132    }
133
134    /// Clear all history, optionally keeping the last N entries of each type.
135    pub fn clear(&mut self, keep_last: Option<usize>) {
136        if let Some(n) = keep_last {
137            let sr_len = self.secret_rotations.len();
138            if sr_len > n {
139                self.secret_rotations = self.secret_rotations.split_off(sr_len - n);
140            }
141
142            let mm_len = self.machine_masterings.len();
143            if mm_len > n {
144                self.machine_masterings = self.machine_masterings.split_off(mm_len - n);
145            }
146
147            let cc_len = self.config_changes.len();
148            if cc_len > n {
149                self.config_changes = self.config_changes.split_off(cc_len - n);
150            }
151        } else {
152            self.secret_rotations.clear();
153            self.machine_masterings.clear();
154            self.config_changes.clear();
155        }
156    }
157
158    /// Export history as JSON string.
159    pub fn to_json(&self) -> String {
160        serde_json::to_string_pretty(self).unwrap_or_default()
161    }
162}
163
164/// Audit trail information stored in .bin files.
165///
166/// Tracks the complete lifecycle of a promotional code configuration:
167/// - When and where it was created
168/// - When and to which machine it was mastered
169/// - How many codes have been generated
170#[derive(Clone, Debug, Serialize, Deserialize)]
171pub struct AuditInfo {
172    /// When the .bin file was created
173    pub created_at: DateTime<Utc>,
174
175    /// Machine ID (hex) where the file was created
176    pub created_machine: String,
177
178    /// When the file was last mastered (bound to a machine)
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub mastered_at: Option<DateTime<Utc>>,
181
182    /// Machine ID (hex) the file is currently bound to
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub mastered_for: Option<String>,
185
186    /// Total number of codes generated
187    pub generation_count: u64,
188
189    /// Timestamp of last code generation
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub last_generated_at: Option<DateTime<Utc>>,
192
193    /// History of changes
194    #[serde(default, skip_serializing_if = "History::is_empty")]
195    pub history: History,
196
197    /// Generation log entries
198    #[serde(default, skip_serializing_if = "Vec::is_empty")]
199    pub generation_log: Vec<GenerationLogEntry>,
200}
201
202impl History {
203    /// Check if history is empty.
204    pub fn is_empty(&self) -> bool {
205        self.secret_rotations.is_empty()
206            && self.machine_masterings.is_empty()
207            && self.config_changes.is_empty()
208    }
209}
210
211impl AuditInfo {
212    /// Create new audit info for a newly created .bin file.
213    ///
214    /// # Arguments
215    /// * `machine_id` - The 32-byte machine ID where creation occurs
216    pub fn new(machine_id: &[u8; 32]) -> Self {
217        Self {
218            created_at: Utc::now(),
219            created_machine: hex::encode(machine_id),
220            mastered_at: None,
221            mastered_for: None,
222            generation_count: 0,
223            last_generated_at: None,
224            history: History::new(),
225            generation_log: Vec::new(),
226        }
227    }
228
229    /// Create audit info with a specific creation timestamp.
230    ///
231    /// Useful for testing or restoring from backup.
232    pub fn with_timestamp(machine_id: &[u8; 32], created_at: DateTime<Utc>) -> Self {
233        Self {
234            created_at,
235            created_machine: hex::encode(machine_id),
236            mastered_at: None,
237            mastered_for: None,
238            generation_count: 0,
239            last_generated_at: None,
240            history: History::new(),
241            generation_log: Vec::new(),
242        }
243    }
244
245    /// Record a mastering event.
246    ///
247    /// Called when the .bin file is bound to a new machine.
248    ///
249    /// # Arguments
250    /// * `target_machine` - The 32-byte machine ID being mastered to
251    pub fn record_master(&mut self, target_machine: &[u8; 32]) {
252        self.mastered_at = Some(Utc::now());
253        self.mastered_for = Some(hex::encode(target_machine));
254    }
255
256    /// Record a mastering event with history tracking.
257    pub fn record_master_with_history(
258        &mut self,
259        from_machine: &[u8; 32],
260        target_machine: &[u8; 32],
261        mastered_by: &[u8; 32],
262    ) {
263        self.history
264            .record_machine_mastering(from_machine, target_machine, mastered_by);
265        self.mastered_at = Some(Utc::now());
266        self.mastered_for = Some(hex::encode(target_machine));
267    }
268
269    /// Record code generation.
270    ///
271    /// Called after successfully generating codes.
272    ///
273    /// # Arguments
274    /// * `count` - Number of codes generated
275    pub fn record_generation(&mut self, count: u64) {
276        self.generation_count = self.generation_count.saturating_add(count);
277        self.last_generated_at = Some(Utc::now());
278    }
279
280    /// Record code generation with log entry.
281    pub fn record_generation_with_log(
282        &mut self,
283        machine_id: &[u8; 32],
284        count: u64,
285        counter_start: u64,
286    ) {
287        let counter_end = counter_start.saturating_add(count);
288        self.generation_log.push(GenerationLogEntry {
289            timestamp: Utc::now(),
290            machine_id: hex::encode(machine_id),
291            count,
292            counter_start,
293            counter_end,
294        });
295        self.generation_count = self.generation_count.saturating_add(count);
296        self.last_generated_at = Some(Utc::now());
297    }
298
299    /// Check if the file has been mastered (bound to a machine).
300    pub fn is_mastered(&self) -> bool {
301        self.mastered_for.is_some()
302    }
303
304    /// Get the machine ID this file is bound to, if any.
305    pub fn bound_machine(&self) -> Option<&str> {
306        self.mastered_for.as_deref()
307    }
308
309    /// Get creation machine ID as bytes.
310    pub fn created_machine_bytes(&self) -> Option<[u8; 32]> {
311        Self::hex_to_machine_id(&self.created_machine)
312    }
313
314    /// Get mastered machine ID as bytes.
315    pub fn mastered_machine_bytes(&self) -> Option<[u8; 32]> {
316        self.mastered_for
317            .as_ref()
318            .and_then(|s| Self::hex_to_machine_id(s))
319    }
320
321    /// Convert hex string to machine ID bytes.
322    fn hex_to_machine_id(hex_str: &str) -> Option<[u8; 32]> {
323        let bytes = hex::decode(hex_str).ok()?;
324        if bytes.len() != 32 {
325            return None;
326        }
327        let mut arr = [0u8; 32];
328        arr.copy_from_slice(&bytes);
329        Some(arr)
330    }
331
332    /// Get total codes generated from generation log.
333    pub fn total_codes_from_log(&self) -> u64 {
334        self.generation_log.iter().map(|e| e.count).sum()
335    }
336
337    /// Get generation log.
338    pub fn get_generation_log(&self) -> &[GenerationLogEntry] {
339        &self.generation_log
340    }
341
342    /// Get history.
343    pub fn get_history(&self) -> &History {
344        &self.history
345    }
346
347    /// Clear generation log, optionally keeping the last N entries.
348    pub fn clear_generation_log(&mut self, keep_last: Option<usize>) {
349        if let Some(n) = keep_last {
350            let len = self.generation_log.len();
351            if len > n {
352                self.generation_log = self.generation_log.split_off(len - n);
353            }
354        } else {
355            self.generation_log.clear();
356        }
357    }
358
359    /// Clear history.
360    pub fn clear_history(&mut self, keep_last: Option<usize>) {
361        self.history.clear(keep_last);
362    }
363
364    /// Export generation log as JSON.
365    pub fn export_generation_log(&self) -> String {
366        serde_json::to_string_pretty(&self.generation_log).unwrap_or_default()
367    }
368
369    /// Export history as JSON.
370    pub fn export_history(&self) -> String {
371        self.history.to_json()
372    }
373
374    /// Create a human-readable summary of the audit info.
375    pub fn summary(&self) -> String {
376        let mut lines = Vec::new();
377
378        lines.push(format!(
379            "Created: {}",
380            self.created_at.format("%Y-%m-%d %H:%M:%S UTC")
381        ));
382        lines.push(format!("Created on: {}...", &self.created_machine[..16]));
383
384        if let Some(mastered_at) = &self.mastered_at {
385            lines.push(format!(
386                "Mastered: {}",
387                mastered_at.format("%Y-%m-%d %H:%M:%S UTC")
388            ));
389        }
390
391        if let Some(mastered_for) = &self.mastered_for {
392            lines.push(format!("Bound to: {}...", &mastered_for[..16]));
393        }
394
395        lines.push(format!("Codes generated: {}", self.generation_count));
396
397        if let Some(last) = &self.last_generated_at {
398            lines.push(format!(
399                "Last generated: {}",
400                last.format("%Y-%m-%d %H:%M:%S UTC")
401            ));
402        }
403
404        if !self.generation_log.is_empty() {
405            lines.push(format!(
406                "Generation log entries: {}",
407                self.generation_log.len()
408            ));
409        }
410
411        if !self.history.is_empty() {
412            lines.push(format!(
413                "History: {} rotations, {} masterings, {} config changes",
414                self.history.secret_rotations.len(),
415                self.history.machine_masterings.len(),
416                self.history.config_changes.len()
417            ));
418        }
419
420        lines.join("\n")
421    }
422}
423
424impl Default for AuditInfo {
425    fn default() -> Self {
426        Self {
427            created_at: Utc::now(),
428            created_machine: "0".repeat(64),
429            mastered_at: None,
430            mastered_for: None,
431            generation_count: 0,
432            last_generated_at: None,
433            history: History::new(),
434            generation_log: Vec::new(),
435        }
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn test_new_audit() {
445        let machine_id = [0xABu8; 32];
446        let audit = AuditInfo::new(&machine_id);
447
448        assert_eq!(audit.created_machine, "ab".repeat(32));
449        assert!(!audit.is_mastered());
450        assert_eq!(audit.generation_count, 0);
451        assert!(audit.last_generated_at.is_none());
452    }
453
454    #[test]
455    fn test_record_master() {
456        let machine_id = [0xABu8; 32];
457        let mut audit = AuditInfo::new(&machine_id);
458
459        let target_machine = [0xCDu8; 32];
460        audit.record_master(&target_machine);
461
462        assert!(audit.is_mastered());
463        assert!(audit.mastered_at.is_some());
464        assert_eq!(audit.mastered_for, Some("cd".repeat(32)));
465    }
466
467    #[test]
468    fn test_record_generation() {
469        let machine_id = [0xABu8; 32];
470        let mut audit = AuditInfo::new(&machine_id);
471
472        audit.record_generation(100);
473        assert_eq!(audit.generation_count, 100);
474        assert!(audit.last_generated_at.is_some());
475
476        audit.record_generation(50);
477        assert_eq!(audit.generation_count, 150);
478    }
479
480    #[test]
481    fn test_generation_count_overflow() {
482        let machine_id = [0xABu8; 32];
483        let mut audit = AuditInfo::new(&machine_id);
484
485        audit.generation_count = u64::MAX - 10;
486        audit.record_generation(100);
487
488        // Should saturate, not overflow
489        assert_eq!(audit.generation_count, u64::MAX);
490    }
491
492    #[test]
493    fn test_machine_id_conversion() {
494        let machine_id = [0xABu8; 32];
495        let audit = AuditInfo::new(&machine_id);
496
497        let recovered = audit.created_machine_bytes().unwrap();
498        assert_eq!(recovered, machine_id);
499    }
500
501    #[test]
502    fn test_serialization() {
503        let machine_id = [0xABu8; 32];
504        let mut audit = AuditInfo::new(&machine_id);
505        audit.record_generation(42);
506
507        let json = serde_json::to_string(&audit).unwrap();
508        let recovered: AuditInfo = serde_json::from_str(&json).unwrap();
509
510        assert_eq!(recovered.created_machine, audit.created_machine);
511        assert_eq!(recovered.generation_count, 42);
512    }
513
514    #[test]
515    fn test_summary() {
516        let machine_id = [0xABu8; 32];
517        let mut audit = AuditInfo::new(&machine_id);
518        audit.record_generation(100);
519
520        let summary = audit.summary();
521        assert!(summary.contains("Created:"));
522        assert!(summary.contains("Codes generated: 100"));
523    }
524}