//! Checking a host's transparency log on fetch (spec ยง14.8, oxygenhub#238). //! //! A host that keeps a log sends its latest checkpoint with the refs, and //! for each bookmark's attestation its entry and an inclusion proof. This //! user remembers, per hub, the log's key and the last checkpoint seen //! (`/tlog/`): the key on first sight, then never another. //! Each new checkpoint must extend the last one, which oxy proves from the //! hub's public tiles (C2SP tlog-tiles); one that does not means the hub //! showed two histories, and the fetch fails. An attestation that is not //! in the log yet, or whose proof does not verify, is only reported while //! the log is new (oxygenhub docs/transparency.md, decision 3). use super::*; use crate::credentials::hub_of; use crate::protocol::http_get; use crate::tlog::{self, Checkpoint, Entry, Hash, HashReader, Note, Tile, TlogError}; use std::cell::RefCell; /// Tiles fetched, by level and number, with the width fetched. type TileCache = RefCell)>>; /// A hub's log as its tiles serve it, for a tree of `size` entries. struct Tiles<'a> { hub: &'a str, size: u64, options: &'a RemoteOptions, cache: TileCache, } impl HashReader for Tiles<'_> { fn read_hashes(&self, indexes: &[u64]) -> std::result::Result, TlogError> { let mut out = Vec::with_capacity(indexes.len()); for &i in indexes { let (t, ..) = Tile::for_index(i); let key = (t.level, t.n); if !self.cache.borrow().contains_key(&key) { let held = (self.size >> (tlog::TILE_HEIGHT * t.level)) - (t.n << tlog::TILE_HEIGHT); let tile = Tile { width: held.min(tlog::TILE_WIDTH) as u32, ..t }; let data = http_get(&format!("{}/log/{}", self.hub, tile.path()), self.options) .map_err(|e| TlogError::Read(e.to_string()))?; self.cache.borrow_mut().insert(key, (tile, data)); } let cache = self.cache.borrow(); let (tile, data) = &cache[&key]; out.push(tile.hash_at(data, i)?); } Ok(out) } } /// What this user last saw of a hub's log. struct Seen { identity: Identity, checkpoint: Checkpoint, } fn file_name(hub: &str) -> String { hub.chars() .map(|c| { if c.is_ascii_alphanumeric() || c == '.' || c == '-' { c } else { '_' } }) .collect() } impl Repo { fn tlog_file(&self, hub: &str) -> PathBuf { self.user_config .parent() .map(Path::to_path_buf) .unwrap_or_else(default_home) .join("tlog") .join(file_name(hub)) } fn seen_log(&self, hub: &str) -> Option { let text = fs::read_to_string(self.tlog_file(hub)).ok()?; let (key, note) = text.split_once('\n')?; let identity = Identity::decode(&crate::dsse::base64_decode(key)?).ok()?; let note = Note::parse(note).ok()?; Some(Seen { checkpoint: Checkpoint::parse(¬e.text).ok()?, identity, }) } fn remember_log(&self, hub: &str, identity: &Identity, note: &Note) -> Result<()> { let path = self.tlog_file(hub); if let Some(dir) = path.parent() { fs::create_dir_all(dir)?; } let text = format!( "{}\n{}", crate::dsse::base64(&identity.encode()), note.encode() ); crate::durable::write_atomic(&path, text.as_bytes())?; Ok(()) } /// Check what a refs answer from `url` says about its host's log. /// Fails only when the host showed two histories (or a log key other /// than the one it showed before); everything else is a note. pub(crate) fn check_log( &self, url: &str, ca: Option<&Path>, resp: &RefsResponse, ) -> Result> { let (Some(text), Some(hub)) = (&resp.log, hub_of(url)) else { return Ok(vec![]); }; let mut notes = Vec::new(); let unchecked = |why: String| { Ok(vec![format!( "{hub}'s transparency log was not checked: {why}" )]) }; let note = match Note::parse(text) { Ok(n) => n, Err(e) => return unchecked(e.to_string()), }; let cp = match Checkpoint::parse(¬e.text) { Ok(c) => c, Err(e) => return unchecked(e.to_string()), }; let options = RemoteOptions { ca: ca.map(Path::to_path_buf), ..RemoteOptions::default() }; let seen = self.seen_log(&hub); let identity = match &seen { Some(s) => s.identity.clone(), None => match http_get(&format!("{hub}/.well-known/oxyhub-key"), &options) .and_then(|b| Ok(Identity::decode(&b)?)) { Ok(i) => i, Err(e) => return unchecked(format!("its key could not be fetched ({e})")), }, }; let two = |why: String| { fail(format!( "{hub} showed two histories: {why}. Do not trust what it serves; tell its operator. \ (What this user saw of its log is in {}.)", self.tlog_file(&hub).display() )) }; if let Err(e) = note.verify(&cp.origin, &identity) { return match &seen { Some(_) => two(format!( "its checkpoint is not signed by the log key it showed before ({e})" )), None => unchecked(e.to_string()), }; } if let Some(s) = &seen { let old = &s.checkpoint; if old.origin != cp.origin { return two(format!("its log is now {}, not {}", cp.origin, old.origin)); } if cp.size < old.size { return two(format!( "its log has {} entries, fewer than the {} it showed before", cp.size, old.size )); } if cp.size == old.size && cp.root != old.root { return two(format!("two different logs of {} entries", cp.size)); } if cp.size > old.size && old.size > 0 { let tiles = Tiles { hub: &hub, size: cp.size, options: &options, cache: RefCell::new(HashMap::new()), }; match tlog::prove_consistency(cp.size, old.size, &tiles).and_then(|p| { tlog::check_consistency(&p, cp.size, &cp.root, old.size, &old.root) }) { Ok(()) => {} Err(TlogError::ProofFailed) | Err(TlogError::BadIndex(_)) => { return two(format!( "its log of {} entries does not extend the one of {} it showed before", cp.size, old.size )) } // Keep the older checkpoint: the next fetch tries again. Err(e) => return unchecked(format!("its tiles could not be read ({e})")), } } } self.remember_log(&hub, &identity, ¬e)?; let mut missing = 0; for r in &resp.refs { let Some(a) = &r.attestation else { continue }; let Some(l) = &r.logged else { missing += 1; continue; }; let fits = Entry::decode(&l.entry).is_ok_and(|e| { e.kind == "ref" && e.object == a.attest.hash() && e.repo == a.attest.repo }); let included = fits && tlog::check_inclusion( &l.proof, cp.size, &cp.root, l.index, &tlog::leaf_hash(&l.entry), ) .is_ok(); if !included { notes.push(format!( "{hub}'s log entry {} for {} does not prove its attestation is in the log", l.index, r.name )); } } if missing > 0 { notes.push(format!( "{} of {hub}'s attestations {} not in its transparency log yet (logged within seconds of a push; checked on a later fetch)", missing, if missing == 1 { "is" } else { "are" } )); } Ok(notes) } }