Add the audit chain verifier

Implemented proto::ChainVerifier as a pure line-holding state machine:
each line is judged only once the next has arrived, so a Recovery record
can mark the line before it not-a-record. Adds ChainFailure, TornTail,
ChainReport, Location and ChainVerifier, wired through lib.rs and the
matching re-export in audit.rs. 13 chain tests pass; make gate prints
gate: ok.

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-19 02:19:45 -07:00
parent e8568edf7e
commit d01b2ef2d9
63 changed files with 1110 additions and 0 deletions
+2
View File
@@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize};
use crate::{CallId, DataClass, DenyReason, Hash32, SessionId, Timestamp};
pub use crate::chain::{ChainFailure, ChainReport, ChainVerifier, Location, TornTail};
// JSON: {"outcome":"denied","reason":"no_grant"} ; the tag sits beside the fields
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
+411
View File
@@ -0,0 +1,411 @@
//! 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(&current));
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,
}
}
}
+2
View File
@@ -1,6 +1,7 @@
//! Shared data types and the frame codec for Boxmaker. No policy and no I/O beyond frames.
pub mod audit;
pub mod chain;
pub mod class;
pub mod frame;
pub mod grant;
@@ -10,6 +11,7 @@ pub mod log;
pub mod wire;
pub use audit::{ApprovalAnswer, AuditEvent, AuditRecord, DecisionRecord, ResultStatus};
pub use chain::{ChainFailure, ChainReport, ChainVerifier, Location, TornTail};
pub use class::DataClass;
pub use frame::{FrameError, MAX_FRAME, read_frame, write_frame};
pub use grant::{Constraints, Grant, Mode};