//! 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). //! //! The witnesses that cosign a hub's checkpoints are pinned the same way //! (`/tlog/.witnesses`), and give each bookmark's //! attestation an *integrated time* (oxygenhub#240): the earliest time a //! trusted witness cosigned a checkpoint holding it. A fetch checks //! delegations and certificates at that time when it has one. use super::*; use crate::credentials::hub_of; use crate::protocol::http_get; use crate::tlog::{self, Checkpoint, Entry, Hash, HashReader, Integrated, 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, }) } pub(crate) 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> { 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(Accepted { checkpoint: cp, identity, note, notes, })) } fn witnesses_file(&self, hub: &str) -> PathBuf { let mut p = self.tlog_file(hub); p.set_file_name(format!("{}.witnesses", file_name(hub))); p } /// The witnesses this user pinned for `hub`: each one's name in /// cosignatures and its identity. pub(crate) fn pinned_witnesses(&self, hub: &str) -> Vec<(String, Identity)> { let Ok(text) = fs::read_to_string(self.witnesses_file(hub)) else { return vec![]; }; text.lines() .filter_map(|l| { let (name, key) = l.split_once(' ')?; let id = Identity::decode(&crate::dsse::base64_decode(key)?).ok()?; Some((name.to_string(), id)) }) .collect() } /// Pin the witnesses `hub` lists (name and identity bytes), as its log /// key is pinned: on first sight silently, later with a note for a /// witness that is new or has another key than before. A witness the /// hub no longer lists stays pinned. Returns every pinned witness. pub(crate) fn pin_witnesses( &self, hub: &str, listed: &[(String, Vec)], notes: &mut Vec, ) -> Result> { let path = self.witnesses_file(hub); let first = !path.exists(); let mut pinned = self.pinned_witnesses(hub); let mut changed = false; for (name, bytes) in listed { if name.is_empty() || name.contains(char::is_whitespace) { notes.push(format!( "{hub} lists a witness named {name:?}, which cannot name one" )); continue; } let Ok(id) = Identity::decode(bytes) else { notes.push(format!( "{hub} lists witness {name} with a key that does not decode" )); continue; }; match pinned.iter_mut().find(|(n, _)| n == name) { Some((_, old)) if old.hash() == id.hash() => {} Some((_, old)) => { notes.push(format!( "{hub}'s witness {name} has another key than before ({} now, {} then): if its operator did not announce one, {hub} may be naming a witness of its own", id.hash().short(12), old.hash().short(12) )); *old = id; changed = true; } None => { if !first { notes.push(format!( "{hub} is now cosigned by witness {name} (key {}), trusted from now on", id.hash().short(12) )); } pinned.push((name.clone(), id)); changed = true; } } } if changed { if let Some(dir) = path.parent() { fs::create_dir_all(dir)?; } let text: String = pinned .iter() .map(|(n, id)| format!("{n} {}\n", crate::dsse::base64(&id.encode()))) .collect(); crate::durable::write_atomic(&path, text.as_bytes())?; } Ok(pinned) } /// Check what a refs answer from `url` says about its host's log, and /// read each bookmark attestation's integrated time. 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 mut out = LogCheck::default(); let (Some(text), Some(hub)) = (&resp.log, hub_of(url)) else { return Ok(out); }; out.kept = true; let options = RemoteOptions { ca: ca.map(Path::to_path_buf), ..RemoteOptions::default() }; let accepted = match self.accept_checkpoint(&hub, text, &options)? { Ok(c) => c, Err(why) => { out.notes .push(format!("{hub}'s transparency log was not checked: {why}")); out.unchecked = Some(why); return Ok(out); } }; let cp = &accepted.checkpoint; out.notes.extend(accepted.notes.iter().cloned()); let witnesses = self.pin_witnesses(&hub, &resp.witnesses, &mut out.notes)?; let mut missing = 0; for r in &resp.refs { let Some(a) = &r.attestation else { continue }; let Some(l) = &r.logged else { missing += 1; out.times .insert(r.name.clone(), Err("it is not in the log yet".into())); 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 { out.notes.push(format!( "{hub}'s log entry {} for {} does not prove its attestation is in the log", l.index, r.name )); } let time = if !fits { Err("its log entry is not its attestation's".to_string()) } else if witnesses.is_empty() { Err(format!("{hub} names no witness")) } else { let cosigned: Vec<(&str, &[Hash])> = r .integration .iter() .map(|i| (i.note.as_str(), i.proof.as_slice())) .collect(); tlog::integrated_time( &l.entry, l.index, &cosigned, &cp.origin, &accepted.identity, &witnesses, ) }; out.times.insert(r.name.clone(), time); } if missing > 0 { out.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(out) } } /// A checkpoint `accept_checkpoint` took: the checkpoint, the log key it /// verified with, its note as served, and what accepting it noted. pub(crate) struct Accepted { pub checkpoint: Checkpoint, pub identity: Identity, pub note: Note, pub notes: Vec, } /// What a refs answer said about its host's log. #[derive(Debug, Default)] pub(crate) struct LogCheck { /// Whether the host keeps a log (it sent a checkpoint). pub kept: bool, /// Why the log could not be checked, when it could not. pub unchecked: Option, pub notes: Vec, /// Each attested bookmark's integrated time, or why it has none. pub times: HashMap>, } impl LogCheck { /// Bookmark `name`'s integrated time, or why there is none. pub fn time_of(&self, name: &str) -> std::result::Result<&Integrated, String> { if let Some(why) = &self.unchecked { return Err(format!("its host's log was not checked ({why})")); } if !self.kept { return Err("its host keeps no transparency log".into()); } match self.times.get(name) { Some(Ok(i)) => Ok(i), Some(Err(why)) => Err(why.clone()), None => Err("it has no log entry".into()), } } } /// How a check that depends on when an attestation was signed came out /// (spec §14.8, *Integrated time*). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Timed { /// Passed at the integrated time. Integrated, /// No integrated time: passed by the earlier rule (the issuer's /// acceptance, else the attestation's own time). Fallback, /// Failed at the integrated time but passed by the earlier rule: /// accepted with a note while logs are new. Disagrees, /// Failed. Refused, } /// Judge a check (`at`, made at a time in milliseconds) at the integrated /// time when there is one, else by the earlier rule (`before`). pub(crate) fn judge( integrated: Option, at: impl Fn(u64) -> bool, before: impl FnOnce() -> bool, ) -> Timed { match integrated { Some(t) if at(t) => Timed::Integrated, Some(_) => { if before() { Timed::Disagrees } else { Timed::Refused } } None => { if before() { Timed::Fallback } else { Timed::Refused } } } } /// A note when an attestation's claimed time (`claimed`, its own clock) /// is more than `tlog::TIME_TOLERANCE_MS` from its integrated time. pub(crate) fn gap_note(claimed: u64, integrated: &Integrated) -> Option { let t = integrated.ms(); let minutes = |d: u64| d.div_ceil(60_000); if claimed.saturating_add(tlog::TIME_TOLERANCE_MS) < t { Some(format!( "claims to be from {} minutes before witness {} first saw it: backdated, held back before it was logged, or signed on a slow clock", minutes(t - claimed), integrated.witness )) } else if claimed > t.saturating_add(tlog::TIME_TOLERANCE_MS) { Some(format!( "claims to be from {} minutes after witness {} saw it: signed on a fast clock", minutes(claimed - t), integrated.witness )) } else { None } } /// 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, /// The trusted witnesses that cosigned the checkpoint, with their /// times in seconds, earliest first. pub witnessed: Vec<(String, u64)>, } 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 accepted = 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 notes = accepted.notes; // The witnesses it names (one ` ` line // each), pinned as a fetch pins them; a hub that does not list // them leaves those pinned before. let listed: Vec<(String, Vec)> = http_get(&format!("{hub}/log/witnesses"), &options) .ok() .and_then(|b| String::from_utf8(b).ok()) .map(|t| { t.lines() .filter_map(|l| { let (n, k) = l.split_once(' ')?; Some((n.to_string(), crate::dsse::base64_decode(k.trim())?)) }) .collect() }) .unwrap_or_default(); let witnesses = self.pin_witnesses(&hub, &listed, &mut notes)?; let mut witnessed = accepted.note.cosignatures(&witnesses); witnessed.sort_by_key(|(_, t)| *t); let cp = accepted.checkpoint; 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, witnessed, }) } }