sandbox: the S7 audit trail can outlive the process that wrote it #309

Merged
dcharlot merged 1 commit from fix/sandbox-s7-audit-trail-survives-restart into main 2026-10-18 07:47:45 -04:00
3 changed files with 261 additions and 0 deletions

View file

@ -5,10 +5,31 @@
//! cannot be reordered or have entries removed without breaking the chain. An
//! S7 attestation carries the head **before** its own append (`audit_prev`),
//! linking it to the prior run; appending the attestation advances the head.
//!
//! ## Durability
//!
//! "Immutable" is a claim about a log that outlives the process. A log held
//! only in memory resets to the genesis head on restart, and the short chain
//! that follows verifies perfectly — so the runs before the restart leave no
//! trace and nothing breaks. [`AuditLog::load_jsonl`] and
//! [`AuditLog::append_jsonl`] give the log a file, following
//! `sandbox-build::FileBuildLedger`.
//!
//! The file stores **only the entry list**; the head is always recomputed by
//! replaying it, so the file cannot assert a head it did not earn. That also
//! bounds what the file alone proves: a log is not self-authenticating, and a
//! truncation that drops entries *and* leaves a consistent list still
//! verifies. What detects it is the `audit_prev` an S7 attestation carries —
//! the attestations are the evidence, the log is the index into them.
use std::io::Write;
use std::path::Path;
use sandbox_schema::ContentId;
use serde::{Deserialize, Serialize};
use crate::AttestError;
/// An append-only, hash-chained audit log of run attestations.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditLog {
@ -57,6 +78,65 @@ impl AuditLog {
self.entries.is_empty()
}
/// Load a log from the JSONL file [`Self::append_jsonl`] writes: one
/// entry's content address per line.
///
/// The head is **replayed** from the entries rather than read from the
/// file, so a loaded log verifies by construction and no stored head can
/// disagree with the list it summarises. A missing file is an empty log —
/// the host's first run.
///
/// # Errors
/// [`AttestError::Audit`] if the file cannot be read or a line is not a
/// content address. A file that does not parse is an error, not a shorter
/// history: the runs whose lines are unreadable are exactly the ones the
/// trail is supposed to hold.
pub fn load_jsonl(path: impl AsRef<Path>) -> Result<Self, AttestError> {
let path = path.as_ref();
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::new()),
Err(e) => return Err(AttestError::Audit(format!("{}: {e}", path.display()))),
};
let mut log = Self::new();
for (i, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let id: ContentId = serde_json::from_str(line)
.map_err(|e| AttestError::Audit(format!("{}: line {}: {e}", path.display(), i + 1)))?;
log.append(&id);
}
Ok(log)
}
/// Durably append one entry's content address to the JSONL file.
///
/// Called **before** the in-memory head advances, so a crash between the
/// two leaves the file ahead of memory and the next
/// [`Self::load_jsonl`] replays to the head the attestation already
/// committed to. The other order would seal an attestation against a head
/// no file records.
///
/// # Errors
/// [`AttestError::Audit`] if the line cannot be written or flushed.
pub fn append_jsonl(path: impl AsRef<Path>, entry: &ContentId) -> Result<(), AttestError> {
let path = path.as_ref();
let line = serde_json::to_string(entry)
.map_err(|e| AttestError::Audit(format!("encoding an audit entry: {e}")))?;
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| AttestError::Audit(format!("{}: {e}", path.display())))?;
writeln!(f, "{line}").map_err(|e| AttestError::Audit(format!("{}: {e}", path.display())))?;
f.flush()
.map_err(|e| AttestError::Audit(format!("{}: {e}", path.display())))?;
f.sync_all()
.map_err(|e| AttestError::Audit(format!("{}: {e}", path.display())))?;
Ok(())
}
/// Recompute the head from the recorded entries and confirm it matches —
/// detects reordering or removal (the chain is tamper-evident).
pub fn verify(&self) -> bool {
@ -75,6 +155,61 @@ impl AuditLog {
mod tests {
use super::*;
fn tempdir(tag: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"sandbox-{tag}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&p).expect("mkdir");
p
}
/// The trail reloads to the same head it reached, because the head is
/// replayed from the entries rather than stored.
#[test]
fn a_durable_trail_replays_to_the_same_head() {
let dir = tempdir("audit");
let path = dir.join("audit.jsonl");
let mut in_memory = AuditLog::new();
for b in [b"a7".as_slice(), b"b7", b"c7"] {
let id = ContentId::of_bytes(b);
AuditLog::append_jsonl(&path, &id).expect("durable append");
in_memory.append(&id);
}
let loaded = AuditLog::load_jsonl(&path).expect("load");
assert_eq!(loaded.len(), 3, "every entry came back");
assert_eq!(loaded.head_id(), in_memory.head_id(), "and the head with them");
assert!(loaded.verify(), "a replayed head verifies by construction");
assert_ne!(loaded.head_id(), AuditLog::new().head_id(), "not the genesis head");
}
/// No file is the host's first run, not an error.
#[test]
fn a_missing_trail_is_the_first_run() {
let dir = tempdir("audit-missing");
let log = AuditLog::load_jsonl(dir.join("nothing-here.jsonl")).expect("empty");
assert!(log.is_empty());
assert_eq!(log.head_id(), AuditLog::new().head_id());
}
/// A trail that does not parse is an error, not a shorter history: the
/// runs whose lines are unreadable are the ones it exists to hold.
#[test]
fn a_trail_that_does_not_parse_is_an_error() {
let dir = tempdir("audit-bad");
let path = dir.join("audit.jsonl");
AuditLog::append_jsonl(&path, &ContentId::of_bytes(b"a7")).expect("append");
std::fs::write(&path, "not json\n").expect("clobber");
assert!(AuditLog::load_jsonl(&path).is_err());
}
#[test]
fn append_advances_and_chains() {
let mut log = AuditLog::new();

View file

@ -51,6 +51,11 @@ pub enum AttestError {
TeeEvidenceUnexpected,
#[error("tier S7 requires a Locality (SPEC §2.2 S7)")]
LocalityRequired,
/// The S7 audit trail could not be read or extended. Fatal by design: a
/// sovereign run whose append is not durable would be sealed against a
/// head no file records.
#[error("S7 audit trail: {0}")]
Audit(String),
#[error("tier S7 requires an audit_prev link (SPEC §2.2 S7)")]
AuditPrevRequired,
#[error("tier S0 is unconfined, so it cannot carry an enforcement mechanism (SPEC §7.9)")]

View file

@ -259,6 +259,10 @@ pub struct Host {
tee: Option<TeeConfig>,
locality: Option<Locality>,
audit: Mutex<AuditLog>,
/// Where the S7 audit trail is kept. `None` means the trail lives only in
/// this process, so it resets to the genesis head on restart — see
/// [`Host::with_durable_audit`].
audit_path: Option<std::path::PathBuf>,
}
impl Host {
@ -276,9 +280,43 @@ impl Host {
tee: None,
locality: None,
audit: Mutex::new(AuditLog::new()),
audit_path: None,
}
}
/// Keep the S7 audit trail in a file, so it survives a restart.
///
/// SPEC §2.2 grades S7 as "S6 + geographic pinning + **immutable audit
/// trail**", and §7.7 says the chain is tamper-evident "so the sequence of
/// sovereign runs is itself attestable". Neither holds for a trail that
/// lives only in memory: [`Host::new`] installs a fresh [`AuditLog`], so
/// after a restart the next sovereign run is sealed with `audit_prev` =
/// the genesis head — byte-identical to a host's very first run — and the
/// short chain that follows verifies perfectly while the runs before the
/// restart leave no trace.
///
/// Opening loads and replays the existing chain. Appends are written to
/// the file before the in-memory head advances.
///
/// # Errors
/// [`AttestError::Audit`] if the existing trail cannot be read or parsed.
/// A trail that does not load is an error, not an empty one.
pub fn with_durable_audit(
mut self,
path: impl Into<std::path::PathBuf>,
) -> Result<Self, AttestError> {
let path = path.into();
let log = AuditLog::load_jsonl(&path)?;
self.audit = Mutex::new(log);
self.audit_path = Some(path);
Ok(self)
}
/// How many sovereign runs the audit trail holds.
pub fn audit_len(&self) -> usize {
self.audit.lock().expect("audit lock").len()
}
/// Configure the confidential-tier (S6+) attestation path.
pub fn with_tee(mut self, tee: TeeConfig) -> Self {
self.tee = Some(tee);
@ -427,6 +465,13 @@ impl Host {
// S7: append this attestation to the audit chain, advancing the head.
if tier >= Tier::S7 {
let id = attestation.content_id()?;
// Durable first, then advance: a crash between the two leaves the
// file ahead of memory, which replays to the head this
// attestation already committed to. The other order would seal a
// run against a head no file records.
if let Some(path) = &self.audit_path {
AuditLog::append_jsonl(path, &id)?;
}
self.audit.lock().expect("audit lock").append(&id);
}
@ -850,6 +895,82 @@ mod tests {
assert!(matches!(err, Err(RunError::TeeProviderMissing(_))));
}
fn tempdir(tag: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"sandbox-{tag}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&p).expect("mkdir");
p
}
/// SPEC §2.2 grades S7 as "S6 + geographic pinning + **immutable audit
/// trail**" and §7.7 sells "the sequence of sovereign runs is itself
/// attestable". Neither survives a restart unless the trail has a file.
#[test]
fn an_s7_audit_trail_survives_a_restart() {
let dir = tempdir("s7-audit");
let path = dir.join("audit.jsonl");
let first = confidential_host().with_durable_audit(&path).expect("open");
let genesis = first.audit_head();
for (seq, unit) in [(1u64, b"u7a".as_slice()), (2, b"u7b")] {
first
.seal_run(
ContentId::of_bytes(unit),
ContentId::of_bytes(b"i7"),
&Grants::default(),
Tier::S7,
cpu_report(),
seq,
[0; 8],
0,
1,
)
.expect("seal");
}
let head_after = first.audit_head();
assert_eq!(first.audit_len(), 2);
assert_ne!(head_after, genesis, "two sovereign runs advanced the head");
drop(first);
// The process restarts.
let resumed = confidential_host().with_durable_audit(&path).expect("reopen");
assert_eq!(resumed.audit_len(), 2, "both sovereign runs are still on the trail");
assert_eq!(resumed.audit_head(), head_after, "and the head did not reset");
// The next sovereign run links to the real head, not to genesis.
let sealed = resumed
.seal_run(
ContentId::of_bytes(b"u7c"),
ContentId::of_bytes(b"i7"),
&Grants::default(),
Tier::S7,
cpu_report(),
3,
[0; 8],
0,
1,
)
.expect("seal");
assert_eq!(sealed.attestation.audit_prev.as_ref().unwrap(), &head_after);
// The discriminating half: with no file the trail resets to the
// genesis head, and the short chain that follows verifies perfectly.
let in_memory = confidential_host();
assert!(in_memory.audit_len() == 0);
assert_eq!(
in_memory.audit_head(),
genesis,
"a host without a durable trail starts over, indistinguishably from its first run"
);
}
#[test]
fn s7_run_binds_locality_and_advances_audit_chain() {
let host = confidential_host();