sandbox: the S7 audit trail can outlive the process that wrote it #309
3 changed files with 261 additions and 0 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)")]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue