From 82d2ef9a0d6e9587774c1131d38dea0272521466 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 18 Oct 2026 00:47:20 -0400 Subject: [PATCH] sandbox: the S7 audit trail can outlive the process that wrote it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC §2.2 grades tier S7 as "S6 + geographic pinning + **immutable audit trail**", and §7.7 states: "`audit_prev` — the head of the host's append-only, hash-chained audit log **before** this run ... The chain is tamper-evident — reordering or removing any entry breaks it — so the sequence of sovereign runs is itself attestable." `sandbox-attest`'s audit module repeats it, and SPEC.md:352 grades the log Engineering with "the hash-chained, tamper-evident audit log are implemented and signed into the attestation; vectored" — disclosing nothing about durability. The reference state was a `Mutex` that `Host::new` always built fresh, with no constructor taking an existing log and `audit_head()` the only accessor. `AuditLog` derives Serialize and Deserialize, and nothing in the tree ever wrote or read one. So the trail did not survive a restart, and nothing showed it had not. `AuditLog::verify` recomputes the head from the entries it holds, so a freshly reset log verifies perfectly: after a restart the next sovereign run is sealed with `audit_prev` = BLAKE3("sandbox.audit/1"), the genesis head — byte-identical to what a host's very first sovereign run would carry. A data-residency auditor given the post-restart host sees a short chain that verifies from genesis, while the runs before the restart have left the "immutable audit trail" without breaking anything. The one property §7.7 sells is the one that did not survive. The pattern was already in the repo and unused here: `sandbox-build::FileBuildLedger` is a file-backed, verify-on-open JSONL ledger. This follows it. - `AuditLog::load_jsonl` / `append_jsonl` give the trail a file. - The file stores **only the entry list**; the head is replayed from it, never stored. So the file cannot assert a head it did not earn, and a loaded log verifies by construction. - `Host::with_durable_audit(path)` opens and replays the existing chain. - The append writes the line **before** the in-memory head advances. A crash between the two leaves the file ahead of memory and the next load replays to the head the attestation already committed to; the other order would seal a sovereign run against a head no file records. - A missing file is the first run. 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 exists to hold. `AttestError::Audit` is fatal by design. What this does not claim: a log is not self-authenticating. A truncation that drops entries *and* leaves a consistent list still verifies on its own. What detects that is the `audit_prev` each S7 attestation carries — the attestations are the evidence and the log is the index into them, which is exactly why the index has to survive. Said plainly in the module doc rather than left to be assumed. `Host::new` and every existing call site are unchanged: a host that does not ask for a durable trail behaves as before, and the limit is now documented on `with_durable_audit` and pinned by a test. Co-Authored-By: Claude Opus 5 --- .../crates/sandbox-attest/src/audit.rs | 135 ++++++++++++++++++ .../crates/sandbox-attest/src/lib.rs | 5 + .../crates/sandbox-runtime/src/lib.rs | 121 ++++++++++++++++ 3 files changed, 261 insertions(+) diff --git a/sandbox/sandbox-rs/crates/sandbox-attest/src/audit.rs b/sandbox/sandbox-rs/crates/sandbox-attest/src/audit.rs index 5eb3ec700..5cf833192 100644 --- a/sandbox/sandbox-rs/crates/sandbox-attest/src/audit.rs +++ b/sandbox/sandbox-rs/crates/sandbox-attest/src/audit.rs @@ -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) -> Result { + 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, 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(); diff --git a/sandbox/sandbox-rs/crates/sandbox-attest/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-attest/src/lib.rs index de1dfda67..d282f9c43 100644 --- a/sandbox/sandbox-rs/crates/sandbox-attest/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-attest/src/lib.rs @@ -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)")] diff --git a/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs index 4681d73b6..31608c29a 100644 --- a/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs @@ -259,6 +259,10 @@ pub struct Host { tee: Option, locality: Option, audit: Mutex, + /// 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, } 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, + ) -> Result { + 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(); -- 2.54.0 (Apple Git-157)