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();