//! 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(()) } /// Take a checkpoint note `text` from `hub` as the newest seen, once it /// verifies with the hub's pinned log key and extends the last one /// seen. `Err` when the hub showed two histories; `Ok(Err(why))` when /// it could not be checked (the last one seen is kept). pub(crate) fn accept_checkpoint( &self, hub: &str, text: &str, options: &RemoteOptions, ) -> Result), String>> { let unchecked = |why: String| Ok(Err(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 fetch_key = || { http_get(&format!("{hub}/.well-known/oxyhub-key"), options) .and_then(|b| Ok(Identity::decode(&b)?)) }; let mut notes = Vec::new(); let mut seen = self.seen_log(hub); let mut identity = match &seen { Some(s) => s.identity.clone(), None => match fetch_key() { Ok(i) => i, Err(e) => return unchecked(format!("its key could not be fetched ({e})")), }, }; if let Err(e) = note.verify(&cp.origin, &identity) { // Signed by another key than the one pinned: a hub given a new // key, or one starting another log. Not proof of a fork, so a // loud note while logs are new (decision 3), and the new key is // pinned; a fork under one key is what fails. let Some(old) = &seen else { return unchecked(e.to_string()); }; match fetch_key() { Ok(new) if note.verify(&cp.origin, &new).is_ok() => { notes.push(format!( "{hub}'s log is signed by another key than before ({} now, {} then): if its operator did not announce a new key, it may be showing others a different log", new.hash().short(12), old.identity.hash().short(12) )); identity = new; seen = None; } _ => return unchecked(format!("its checkpoint does not verify ({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 Some(s) = &seen { let old = &s.checkpoint; if old.origin != cp.origin { // A hub moved to another name keeps its key: a new log as // far as this user can tell, said so. notes.push(format!( "{hub}'s log is now {}, not {}: checking it afresh", cp.origin, old.origin )); } else if cp.size < old.size { return two(format!( "its log has {} entries, fewer than the {} it showed before", cp.size, old.size )); } else if cp.size == old.size && cp.root != old.root { return two(format!("two different logs of {} entries", cp.size)); } else if cp.size > old.size && old.size > 0 { let tiles = Tiles { hub, size: cp.size, 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)?; Ok(Ok((cp, notes))) } /// Check what a refs answer from `url` says about its host's log. /// Fails only when the host showed two histories under one key; /// 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 options = RemoteOptions { ca: ca.map(Path::to_path_buf), ..RemoteOptions::default() }; let (cp, mut notes) = match self.accept_checkpoint(&hub, text, &options)? { Ok(c) => c, Err(why) => { return Ok(vec![format!( "{hub}'s transparency log was not checked: {why}" )]) } }; 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) } } /// A hub's whole log, checked (`oxy tlog verify`). #[derive(Clone, Debug)] pub struct LogAudit { pub hub: String, pub checkpoint: Checkpoint, /// Every entry, numbered from 0. pub entries: Vec, /// What accepting the checkpoint noted (a new log key, a new name). pub notes: Vec, } impl Repo { /// Fetch the log of `url`'s hub whole: its checkpoint, accepted as a /// fetch accepts one, and every entry bundle, whose entries must make /// the root the checkpoint signs. pub fn audit_log(&self, url: &str, ca: Option<&Path>) -> Result { let hub = hub_of(url).ok_or_else(|| RepoError::Msg(format!("{url} is not a hub's address")))?; let options = RemoteOptions { ca: ca.map(Path::to_path_buf), ..RemoteOptions::default() }; let tl = |e: TlogError| RepoError::Msg(e.to_string()); let text = String::from_utf8(http_get(&format!("{hub}/log/checkpoint"), &options)?) .map_err(|_| RepoError::Msg(format!("{hub}'s checkpoint is not text")))?; let (cp, notes) = match self.accept_checkpoint(&hub, &text, &options)? { Ok(c) => c, Err(why) => return fail(format!("{hub}'s log could not be checked: {why}")), }; let mut entries = Vec::with_capacity(cp.size as usize); let mut stored: Vec = Vec::new(); let mut n = 0u64; while n < cp.size { let t = Tile { level: 0, n: n >> tlog::TILE_HEIGHT, width: (cp.size - n).min(tlog::TILE_WIDTH) as u32, }; let bytes = http_get(&format!("{hub}/log/{}", t.entries_path()), &options)?; let bundle = tlog::decode_bundle(&bytes).map_err(tl)?; if bundle.len() != t.width as usize { return fail(format!( "{hub}'s entry bundle {} holds {} entries, not {}", t.entries_path(), bundle.len(), t.width )); } for b in bundle { let add = tlog::stored_hashes_for_leaf(n, tlog::leaf_hash(&b), &stored).map_err(tl)?; stored.extend(add); entries.push(Entry::decode(&b).map_err(tl)?); n += 1; } } if tlog::tree_hash(cp.size, &stored).map_err(tl)? != cp.root { return fail(format!( "{hub} serves entries that do not make the root it signed for {} entries: its log is not the one it attests", cp.size )); } Ok(LogAudit { hub, checkpoint: cp, entries, notes, }) } }