//! Verifies the audit log's hash chain. A pure state machine: the caller reads the files and feeds //! it lines. Shared by `brokerd`'s startup check and `bxctl audit verify`. //! //! Each line is held back until the next one has been seen, because a `Recovery` record says //! that the line before it is not a record at all. use std::collections::BTreeSet; use crate::{AuditEvent, AuditRecord, DecisionRecord, Hash32, Timestamp, sha256}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Location { pub file: String, pub line: u64, } /// The first failure since the last accepted break, with what an `AcceptedBreak` record appended /// now would have to carry. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ChainFailure { pub file: String, pub line: u64, pub what: String, pub last_good: Hash32, pub break_prev: Hash32, pub break_seq: u64, /// The last line fed has no newline; it must be ended before a break record is appended. pub tail_torn: bool, } /// The last line of the last file when it needs recovery, with what its `Recovery` record /// would have to carry. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TornTail { pub at: Location, pub has_newline: bool, pub bytes: u64, pub sha256: Hash32, pub recovery_prev: Hash32, pub recovery_seq: u64, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ChainReport { pub records: u64, pub head: Option, pub next_seq: u64, pub failure: Option, pub recoveries: Vec, pub accepted_breaks: Vec, /// `seq` of `ask` decisions with no `Approval` after them. pub abandoned: Vec, /// `seq` of decisions that were allowed, at once or by approval, with no `Result` after them. pub unfinished: Vec, pub clock_warnings: Vec, pub torn_tail: Option, } #[derive(Debug)] struct Held { at: Location, hash: Hash32, bytes: u64, has_newline: bool, record: Option, } #[derive(Debug)] struct Broken { file: String, line: u64, what: String, last_good: Hash32, expected_seq: u64, /// Lines from the failing line up to the last one judged, the failing line included. lines: u64, } #[derive(Debug)] pub struct ChainVerifier { resumed: bool, first_file: Option, file: String, line_no: u64, next_seq: u64, prev: Hash32, head: Option, /// Hash of the line before the held one, whether or not it verified. before_held: Hash32, held: Option, /// The held line is a `Recovery` record that described the line before it. held_recovers: bool, broken: Option, last_time: Option, records: u64, open_asks: BTreeSet, open_runs: BTreeSet, recoveries: Vec, accepted_breaks: Vec, clock_warnings: Vec, } impl Default for ChainVerifier { fn default() -> Self { Self::new() } } impl ChainVerifier { /// Expects `seq` 0 and an all-zero `prev`. pub fn new() -> Self { Self::start(false, 0, Hash32::ZERO) } /// Continues from a known point: the `seq` after, and the hash of, the last line of the file /// before the first one fed. pub fn resume(next_seq: u64, prev: Hash32) -> Self { Self::start(true, next_seq, prev) } fn start(resumed: bool, next_seq: u64, prev: Hash32) -> Self { ChainVerifier { resumed, first_file: None, file: String::new(), line_no: 0, next_seq, prev, head: None, before_held: prev, held: None, held_recovers: false, broken: None, last_time: None, records: 0, open_asks: BTreeSet::new(), open_runs: BTreeSet::new(), recoveries: Vec::new(), accepted_breaks: Vec::new(), clock_warnings: Vec::new(), } } /// Starts the next file. Files are fed in name order. pub fn file(&mut self, name: &str) { if self.first_file.is_none() { self.first_file = Some(name.to_string()); } self.file = name.to_string(); self.line_no = 0; } /// Feeds a whole file: `file(name)`, then every line of `content`. pub fn feed(&mut self, name: &str, content: &[u8]) { self.file(name); let mut rest = content; while !rest.is_empty() { match rest.iter().position(|b| *b == b'\n') { Some(end) => { let (line, tail) = rest.split_at(end); self.line(line, true); rest = tail.get(1..).unwrap_or_default(); } None => { self.line(rest, false); rest = &[]; } } } } /// One line without its newline. `has_newline` is false only for a file's last line. pub fn line(&mut self, bytes: &[u8], has_newline: bool) { self.line_no = self.line_no.saturating_add(1); let at = Location { file: self.file.clone(), line: self.line_no, }; let Ok(hash) = sha256(bytes) else { // The hasher fails only on an input too long to count; such a line is no record. self.judge_held(None); self.fail(&at, "the line is too long to hash".to_string()); return; }; let current = Held { at, hash, bytes: u64::try_from(bytes.len()).unwrap_or(u64::MAX), has_newline, record: serde_json::from_slice(bytes).ok(), }; self.judge_held(Some(¤t)); self.held = Some(current); } /// Judges the held line now that the line after it (if any) is known. fn judge_held(&mut self, next: Option<&Held>) { let Some(held) = self.held.take() else { return; }; let recovers = std::mem::replace(&mut self.held_recovers, false); if self.broken.is_some() { self.judge_in_region(&held); } else if self.is_recovered_by(&held, next) { self.recoveries.push(held.at.clone()); self.held_recovers = true; } else { self.judge_record(&held, recovers); } self.before_held = held.hash; } /// True if `next` is a `Recovery` record that describes `held` and chains from the line /// before it. Whether `held` parses makes no difference. fn is_recovered_by(&self, held: &Held, next: Option<&Held>) -> bool { let Some(AuditRecord { seq, prev, event: AuditEvent::Recovery { torn_bytes, torn_sha256, }, .. }) = next.and_then(|n| n.record.as_ref()) else { return false; }; *torn_bytes == held.bytes && *torn_sha256 == held.hash && *prev == self.prev && *seq == self.next_seq } fn judge_record(&mut self, held: &Held, recovers: bool) { let Some(record) = &held.record else { self.fail(&held.at, "does not parse as an audit record".to_string()); return; }; if let AuditEvent::AcceptedBreak { file, .. } = &record.event { // Only a resumed verifier may meet a break with no failure before it: the failure // is in a file it was not given. The full verification judges the rest. if self.names_earlier_file(file) && record.prev == self.prev { self.accept_break(held, record); } else { self.fail( &held.at, "an accepted break with no failure before it".to_string(), ); } return; } if record.seq != self.next_seq { let what = format!("seq is {}, expected {}", record.seq, self.next_seq); self.fail(&held.at, what); return; } if record.prev != self.prev { let what = if held.at.line == 1 && (self.resumed || self.head.is_some()) { "does not chain from the last line of the file before" } else { "prev is not the hash of the line before" }; self.fail(&held.at, what.to_string()); return; } if matches!(record.event, AuditEvent::Recovery { .. }) && !recovers { self.fail( &held.at, "a recovery record that does not describe the line before it".to_string(), ); return; } self.advance(held, record); } /// A line after a failure. It is read only to see whether it is the break record. fn judge_in_region(&mut self, held: &Held) { let Some(broken) = &mut self.broken else { return; }; if let Some(record) = &held.record && let AuditEvent::AcceptedBreak { file, line, last_good, } = &record.event && record.prev == self.before_held { let names_this = *file == broken.file && *line == broken.line && *last_good == broken.last_good && broken.expected_seq.checked_add(broken.lines) == Some(record.seq); if names_this || (self.resumed && self .first_file .as_deref() .is_some_and(|f| file.as_str() < f)) { self.broken = None; self.accept_break(held, record); return; } } broken.lines = broken.lines.saturating_add(1); } fn names_earlier_file(&self, file: &str) -> bool { self.resumed && self.first_file.as_deref().is_some_and(|first| file < first) } fn accept_break(&mut self, held: &Held, record: &AuditRecord) { self.accepted_breaks.push(held.at.clone()); self.advance(held, record); } /// `held` is a verified record: it becomes the head. fn advance(&mut self, held: &Held, record: &AuditRecord) { self.records = self.records.saturating_add(1); self.head = Some(held.hash); self.prev = held.hash; self.next_seq = record.seq.saturating_add(1); if self.last_time.is_some_and(|last| record.time < last) { self.clock_warnings.push(held.at.clone()); } self.last_time = Some(record.time); match &record.event { AuditEvent::Decision { outcome, .. } => match outcome { DecisionRecord::Allowed {} => { self.open_runs.insert(record.seq); } DecisionRecord::Ask {} => { self.open_asks.insert(record.seq); } DecisionRecord::Denied { .. } => {} }, AuditEvent::Approval { decision, outcome, .. } => { self.open_asks.remove(decision); if matches!(outcome, DecisionRecord::Allowed {}) { self.open_runs.insert(*decision); } } AuditEvent::Result { decision, .. } => { self.open_runs.remove(decision); } AuditEvent::Recovery { .. } | AuditEvent::AcceptedBreak { .. } => {} } } fn fail(&mut self, at: &Location, what: String) { if self.broken.is_some() { return; } self.broken = Some(Broken { file: at.file.clone(), line: at.line, what, last_good: self.prev, expected_seq: self.next_seq, lines: 1, }); } pub fn finish(mut self) -> ChainReport { let mut torn_tail = None; let mut tail_torn = false; let mut last_line = self.before_held; if let Some(held) = self.held.take() { last_line = held.hash; tail_torn = !held.has_newline; let needs_recovery = !held.has_newline || held.record.is_none(); if self.broken.is_none() && needs_recovery { torn_tail = Some(TornTail { at: held.at.clone(), has_newline: held.has_newline, bytes: held.bytes, sha256: held.hash, recovery_prev: self.prev, recovery_seq: self.next_seq, }); } else { self.held = Some(held); self.judge_held(None); } } let failure = self.broken.map(|b| ChainFailure { file: b.file, line: b.line, what: b.what, last_good: b.last_good, break_prev: last_line, break_seq: b.expected_seq.saturating_add(b.lines), tail_torn, }); ChainReport { records: self.records, head: self.head, next_seq: self.next_seq, failure, recoveries: self.recoveries, accepted_breaks: self.accepted_breaks, abandoned: self.open_asks.into_iter().collect(), unfinished: self.open_runs.into_iter().collect(), clock_warnings: self.clock_warnings, torn_tail, } } }