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;
10
11use crate::logging::ledger::{compute_sha256, read_ledger_plaintext};
12
13/// Result of verifying a single log file
14pub struct FileVerification {
15    pub filename: String,
16    pub status: FileStatus,
17    pub stored_hash: Option<String>,   // hash recorded in ledger
18    pub computed_hash: Option<String>, // hash recomputed from file
19}
20
21/// Possible verification outcomes for a file
22pub enum FileStatus {
23    Verified,   // file exists and hash matches ledger
24    Mismatched, // file exists but hash differs from ledger
25    Missing,    // file referenced in ledger but missing on disk
26    Malformed,  // ledger entry malformed (not enough fields)
27    Unsealed,   // file exists but not present in ledger
28}
29
30/// Aggregated summary across all files checked
31pub struct VerificationSummary {
32    pub verified: usize,
33    pub mismatched: usize,
34    pub missing: usize,
35    pub malformed: usize,
36    pub unsealed: usize,
37    pub files: Vec<FileVerification>, // per-file results
38}
39
40impl VerificationSummary {
41    fn new() -> Self {
42        VerificationSummary {
43            verified: 0,
44            mismatched: 0,
45            missing: 0,
46            malformed: 0,
47            unsealed: 0,
48            files: Vec::new(),
49        }
50    }
51}
52
53/// Verify all sealed logs in the encrypted ledger
54pub fn verify_all() -> VerificationSummary {
55    let logs_dir = PathBuf::from("logs");
56    let mut summary = VerificationSummary::new();
57
58    if !logs_dir.exists() {
59        return summary;
60    }
61
62    // 1. Decrypt ledger
63    let ledger_plaintext = read_ledger_plaintext();
64    if ledger_plaintext.is_empty() {
65        return summary;
66    }
67    let ledger_str = String::from_utf8_lossy(&ledger_plaintext);
68
69    // 2. Parse each ledger entry
70    for line in ledger_str.lines() {
71        let parts: Vec<&str> = line.split_whitespace().collect();
72        if parts.len() < 3 {
73            summary.malformed += 1;
74            summary.files.push(FileVerification {
75                filename: line.to_string(),
76                status: FileStatus::Malformed,
77                stored_hash: None,
78                computed_hash: None,
79            });
80            continue;
81        }
82
83        let filename = parts[1].to_string();
84        let stored_hash = parts[2].to_string();
85        let log_path = logs_dir.join(&filename);
86
87        // 3. Check file existence + recompute hash
88        if !log_path.exists() {
89            summary.missing += 1;
90            summary.files.push(FileVerification {
91                filename,
92                status: FileStatus::Missing,
93                stored_hash: Some(stored_hash),
94                computed_hash: None,
95            });
96            continue;
97        }
98
99        let computed_hash = compute_sha256(&log_path);
100        if stored_hash == computed_hash {
101            summary.verified += 1;
102            summary.files.push(FileVerification {
103                filename,
104                status: FileStatus::Verified,
105                stored_hash: Some(stored_hash),
106                computed_hash: Some(computed_hash),
107            });
108        } else {
109            summary.mismatched += 1;
110            summary.files.push(FileVerification {
111                filename,
112                status: FileStatus::Mismatched,
113                stored_hash: Some(stored_hash),
114                computed_hash: Some(computed_hash),
115            });
116        }
117    }
118
119    summary
120}
121
122/// Verify logs for a specific date (YYYY-MM-DD). Defaults to yesterday if None.
123pub fn verify_date(date: Option<&str>) -> VerificationSummary {
124    let logs_dir = PathBuf::from("logs");
125    let mut summary = VerificationSummary::new();
126
127    if !logs_dir.exists() {
128        return summary;
129    }
130
131    // 1. Pick target date
132    let target_date = match date {
133        Some(d) => match NaiveDate::parse_from_str(d, "%Y-%m-%d") {
134            Ok(date) => date.format("%Y-%m-%d").to_string(),
135            Err(_) => return summary,
136        },
137        None => {
138            let yesterday = Utc::now().date_naive() - chrono::Duration::days(1);
139            yesterday.format("%Y-%m-%d").to_string()
140        }
141    };
142
143    let log_filename = format!("audit-{}.jsonl", target_date);
144    let log_path = logs_dir.join(&log_filename);
145
146    // 2. Decrypt ledger
147    let ledger_plaintext = read_ledger_plaintext();
148    if ledger_plaintext.is_empty() {
149        return summary;
150    }
151    let ledger_str = String::from_utf8_lossy(&ledger_plaintext);
152
153    // 3. File missing entirely
154    if !log_path.exists() {
155        summary.missing += 1;
156        summary.files.push(FileVerification {
157            filename: log_filename,
158            status: FileStatus::Missing,
159            stored_hash: None,
160            computed_hash: None,
161        });
162        return summary;
163    }
164
165    // 4. File exists, check if sealed in ledger
166    if let Some(line) = ledger_str.lines().find(|l| l.contains(&log_filename)) {
167        let parts: Vec<&str> = line.split_whitespace().collect();
168        if parts.len() < 3 {
169            summary.malformed += 1;
170            summary.files.push(FileVerification {
171                filename: log_filename,
172                status: FileStatus::Malformed,
173                stored_hash: None,
174                computed_hash: None,
175            });
176            return summary;
177        }
178
179        let stored_hash = parts[2].to_string();
180        let computed_hash = compute_sha256(&log_path);
181
182        if stored_hash == computed_hash {
183            summary.verified += 1;
184            summary.files.push(FileVerification {
185                filename: log_filename,
186                status: FileStatus::Verified,
187                stored_hash: Some(stored_hash),
188                computed_hash: Some(computed_hash),
189            });
190        } else {
191            summary.mismatched += 1;
192            summary.files.push(FileVerification {
193                filename: log_filename,
194                status: FileStatus::Mismatched,
195                stored_hash: Some(stored_hash),
196                computed_hash: Some(computed_hash),
197            });
198        }
199    } else {
200        // File exists but not sealed in ledger
201        summary.unsealed += 1;
202        summary.files.push(FileVerification {
203            filename: log_filename,
204            status: FileStatus::Unsealed,
205            stored_hash: None,
206            computed_hash: None,
207        });
208    }
209
210    summary
211}