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:
@@ -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)]
|
||||
|
||||
@@ -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(¤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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user