pipa_core/logging/
verify.rs

1//! Ledger sealing + verification
2//!
3//! - Sealing: decrypt ledger, append hashes for unsealed logs, re-encrypt.
4//! - Verification: decrypt ledger, recompute hashes, compare with stored.
5//!
6//! This ensures logs are tamper-evident: once sealed, any modification
7//! or deletion will be detected by verification.
8use chrono::{NaiveDate, Utc};
9use std::path::PathBuf;
10use std::collections::HashMap;
11use std::fs;
12
13use crate::logging::ledger::{compute_sha256, read_ledger_plaintext};
14
15/// Result of verifying a single log file
16pub struct FileVerification {
17    pub filename: String,
18    pub status: FileStatus,
19    pub stored_hash: Option<String>,   // hash recorded in ledger
20    pub computed_hash: Option<String>, // hash recomputed from file
21}
22
23/// Possible verification outcomes for a file
24pub enum FileStatus {
25    Verified,   // file exists and hash matches ledger
26    Mismatched, // file exists but hash differs from ledger
27    Missing,    // file referenced in ledger but missing on disk
28    Malformed,  // ledger entry malformed (not enough fields)
29    Unsealed,   // file exists but not present in ledger
30}
31
32/// Aggregated summary across all files checked
33pub struct VerificationSummary {
34    pub verified: usize,
35    pub mismatched: usize,
36    pub missing: usize,
37    pub malformed: usize,
38    pub unsealed: usize,
39    pub files: Vec<FileVerification>, // per-file results
40}
41
42impl VerificationSummary {
43    fn new() -> Self {
44        VerificationSummary {
45            verified: 0,
46            mismatched: 0,
47            missing: 0,
48            malformed: 0,
49            unsealed: 0,
50            files: Vec::new(),
51        }
52    }
53}
54
55/// Verify all sealed logs in the encrypted ledger
56pub fn verify_all() -> VerificationSummary {
57    let logs_dir = PathBuf::from("logs");
58    let mut summary = VerificationSummary::new();
59    
60    // 1. Read all sealed files from the ledger into a HashMap for quick lookups.
61    // The map will store: filename -> stored_hash
62    let mut sealed_files: HashMap<String, String> = HashMap::new();
63    let ledger_plaintext = read_ledger_plaintext();
64    if !ledger_plaintext.is_empty() {
65        let ledger_str = String::from_utf8_lossy(&ledger_plaintext);
66        for line in ledger_str.lines() {
67            let parts: Vec<&str> = line.split_whitespace().collect();
68            if parts.len() < 3 {
69                summary.malformed += 1;
70                summary.files.push(FileVerification {
71                    filename: line.to_string(),
72                    status: FileStatus::Malformed,
73                    stored_hash: None,
74                    computed_hash: None,
75                });
76            } else {
77                let filename = parts[1].to_string();
78                let stored_hash = parts[2].to_string();
79                sealed_files.insert(filename, stored_hash);
80            }
81        }
82    }
83
84    // 2. If the logs directory doesn't exist, we can't find any files.
85    // Any files in the ledger at this point must be missing.
86    if !logs_dir.exists() {
87        for (filename, stored_hash) in sealed_files {
88            summary.missing += 1;
89            summary.files.push(FileVerification {
90                filename,
91                status: FileStatus::Missing,
92                stored_hash: Some(stored_hash),
93                computed_hash: None,
94            });
95        }
96        return summary;
97    }
98
99    // 3. Iterate through all log files on disk.
100    for entry in fs::read_dir(logs_dir).expect("cannot read logs dir") {
101        let path = entry.expect("bad dir entry").path();
102        if path.is_file() {
103            let filename = path.file_name().unwrap().to_string_lossy().to_string();
104            
105            // Check if this file was in our ledger map.
106            if let Some(stored_hash) = sealed_files.get(&filename) {
107                // The file is sealed. Now, verify the hash.
108                let computed_hash = compute_sha256(&path);
109                if *stored_hash == computed_hash {
110                    summary.verified += 1;
111                    summary.files.push(FileVerification {
112                        filename: filename.clone(),
113                        status: FileStatus::Verified,
114                        stored_hash: Some(stored_hash.clone()),
115                        computed_hash: Some(computed_hash),
116                    });
117                } else {
118                    summary.mismatched += 1;
119                    summary.files.push(FileVerification {
120                        filename: filename.clone(),
121                        status: FileStatus::Mismatched,
122                        stored_hash: Some(stored_hash.clone()),
123                        computed_hash: Some(computed_hash),
124                    });
125                }
126                // Remove the file from the map since we've processed it.
127                sealed_files.remove(&filename);
128            } else {
129                // The file exists on disk but was not in the ledger. It's unsealed.
130                summary.unsealed += 1;
131                summary.files.push(FileVerification {
132                    filename,
133                    status: FileStatus::Unsealed,
134                    stored_hash: None,
135                    computed_hash: Some(compute_sha256(&path)), // Still useful to compute hash
136                });
137            }
138        }
139    }
140
141    // 4. Any files left in our map were in the ledger but not found on disk.
142    // These are missing files.
143    for (filename, stored_hash) in sealed_files {
144        summary.missing += 1;
145        summary.files.push(FileVerification {
146            filename,
147            status: FileStatus::Missing,
148            stored_hash: Some(stored_hash),
149            computed_hash: None,
150        });
151    }
152
153    summary
154}
155/// Verify logs for a specific date (YYYY-MM-DD). Defaults to yesterday if None.
156pub fn verify_date(date: Option<&str>) -> VerificationSummary {
157    let logs_dir = PathBuf::from("logs");
158    let mut summary = VerificationSummary::new();
159
160    if !logs_dir.exists() {
161        return summary;
162    }
163
164    // 1. Pick target date
165    let target_date = match date {
166        Some(d) => match NaiveDate::parse_from_str(d, "%Y-%m-%d") {
167            Ok(date) => date.format("%Y-%m-%d").to_string(),
168            Err(_) => return summary,
169        },
170        None => {
171            let yesterday = Utc::now().date_naive() - chrono::Duration::days(1);
172            yesterday.format("%Y-%m-%d").to_string()
173        }
174    };
175
176    let log_filename = format!("audit-{}.jsonl", target_date);
177    let log_path = logs_dir.join(&log_filename);
178
179    // 2. Decrypt ledger
180    let ledger_plaintext = read_ledger_plaintext();
181    if ledger_plaintext.is_empty() {
182        return summary;
183    }
184    let ledger_str = String::from_utf8_lossy(&ledger_plaintext);
185
186    // 3. File missing entirely
187    if !log_path.exists() {
188        summary.missing += 1;
189        summary.files.push(FileVerification {
190            filename: log_filename,
191            status: FileStatus::Missing,
192            stored_hash: None,
193            computed_hash: None,
194        });
195        return summary;
196    }
197
198    // 4. File exists, check if sealed in ledger
199    if let Some(line) = ledger_str.lines().find(|l| l.contains(&log_filename)) {
200        let parts: Vec<&str> = line.split_whitespace().collect();
201        if parts.len() < 3 {
202            summary.malformed += 1;
203            summary.files.push(FileVerification {
204                filename: log_filename,
205                status: FileStatus::Malformed,
206                stored_hash: None,
207                computed_hash: None,
208            });
209            return summary;
210        }
211
212        let stored_hash = parts[2].to_string();
213        let computed_hash = compute_sha256(&log_path);
214
215        if stored_hash == computed_hash {
216            summary.verified += 1;
217            summary.files.push(FileVerification {
218                filename: log_filename,
219                status: FileStatus::Verified,
220                stored_hash: Some(stored_hash),
221                computed_hash: Some(computed_hash),
222            });
223        } else {
224            summary.mismatched += 1;
225            summary.files.push(FileVerification {
226                filename: log_filename,
227                status: FileStatus::Mismatched,
228                stored_hash: Some(stored_hash),
229                computed_hash: Some(computed_hash),
230            });
231        }
232    } else {
233        // File exists but not sealed in ledger
234        summary.unsealed += 1;
235        summary.files.push(FileVerification {
236            filename: log_filename,
237            status: FileStatus::Unsealed,
238            stored_hash: None,
239            computed_hash: None,
240        });
241    }
242
243    summary
244}