Done the way the owner asked and the spike mapped: the crate and the manifests renamed, then what the build reported fixed. Layers: 138 "proto unresolved" errors across 169 files; cargo fmt reordering imports; six fixture paths built as strings in brokerd's and bxctl's tests, which the compiler cannot see, and comments naming crates/proto/; the crate-dependency check and its self-test. One more the spike did not reach: a `\nproto =` key inside a printf format in the self-test, which a word-boundary replacement skips because `n` and `p` are both word characters. make gate: gate: ok, 944 tests; no new entry in /tmp. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
412 lines
13 KiB
Rust
412 lines
13 KiB
Rust
//! 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<Hash32>,
|
|
pub next_seq: u64,
|
|
pub failure: Option<ChainFailure>,
|
|
pub recoveries: Vec<Location>,
|
|
pub accepted_breaks: Vec<Location>,
|
|
/// `seq` of `ask` decisions with no `Approval` after them.
|
|
pub abandoned: Vec<u64>,
|
|
/// `seq` of decisions that were allowed, at once or by approval, with no `Result` after them.
|
|
pub unfinished: Vec<u64>,
|
|
pub clock_warnings: Vec<Location>,
|
|
pub torn_tail: Option<TornTail>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Held {
|
|
at: Location,
|
|
hash: Hash32,
|
|
bytes: u64,
|
|
has_newline: bool,
|
|
record: Option<AuditRecord>,
|
|
}
|
|
|
|
#[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<String>,
|
|
file: String,
|
|
line_no: u64,
|
|
next_seq: u64,
|
|
prev: Hash32,
|
|
head: Option<Hash32>,
|
|
/// Hash of the line before the held one, whether or not it verified.
|
|
before_held: Hash32,
|
|
held: Option<Held>,
|
|
/// The held line is a `Recovery` record that described the line before it.
|
|
held_recovers: bool,
|
|
broken: Option<Broken>,
|
|
last_time: Option<Timestamp>,
|
|
records: u64,
|
|
open_asks: BTreeSet<u64>,
|
|
open_runs: BTreeSet<u64>,
|
|
recoveries: Vec<Location>,
|
|
accepted_breaks: Vec<Location>,
|
|
clock_warnings: Vec<Location>,
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
}
|