OXYGENHUB

Coverage / crates/oxy-core/src/repo/transparency.rs 4.0%

9 of 226 lines run, 2 of 36 functions · run #322 on main · run missed

1
//! Checking a host's transparency log on fetch (spec §14.8, oxygenhub#238).
2
//!
3
//! A host that keeps a log sends its latest checkpoint with the refs, and
4
//! for each bookmark's attestation its entry and an inclusion proof. This
5
//! user remembers, per hub, the log's key and the last checkpoint seen
6
//! (`<oxy home>/tlog/<hub>`): the key on first sight, then never another.
7
//! Each new checkpoint must extend the last one, which oxy proves from the
8
//! hub's public tiles (C2SP tlog-tiles); one that does not means the hub
9
//! showed two histories, and the fetch fails. An attestation that is not
10
//! in the log yet, or whose proof does not verify, is only reported while
11
//! the log is new (oxygenhub docs/transparency.md, decision 3).
12
13
use super::*;
14
use crate::credentials::hub_of;
15
use crate::protocol::http_get;
16
use crate::tlog::{self, Checkpoint, Entry, Hash, HashReader, Note, Tile, TlogError};
17
use std::cell::RefCell;
18
19
/// Tiles fetched, by level and number, with the width fetched.
20
type TileCache = RefCell<HashMap<(u32, u64), (Tile, Vec<u8>)>>;
21
22
/// A hub's log as its tiles serve it, for a tree of `size` entries.
23
struct Tiles<'a> {
24
    hub: &'a str,
25
    size: u64,
26
    options: &'a RemoteOptions,
27
    cache: TileCache,
28
}
29
30
impl HashReader for Tiles<'_> {
310×
    fn read_hashes(&self, indexes: &[u64]) -> std::result::Result<Vec<Hash>, TlogError> {
320×
        let mut out = Vec::with_capacity(indexes.len());
330×
        for &i in indexes {
340×
            let (t, ..) = Tile::for_index(i);
350×
            let key = (t.level, t.n);
360×
            if !self.cache.borrow().contains_key(&key) {
370×
                let held =
380×
                    (self.size >> (tlog::TILE_HEIGHT * t.level)) - (t.n << tlog::TILE_HEIGHT);
390×
                let tile = Tile {
400×
                    width: held.min(tlog::TILE_WIDTH) as u32,
410×
                    ..t
420×
                };
430×
                let data = http_get(&format!("{}/log/{}", self.hub, tile.path()), self.options)
440×
                    .map_err(|e| TlogError::Read(e.to_string()))?;
450×
                self.cache.borrow_mut().insert(key, (tile, data));
460×
            }
470×
            let cache = self.cache.borrow();
480×
            let (tile, data) = &cache[&key];
490×
            out.push(tile.hash_at(data, i)?);
50
        }
510×
        Ok(out)
520×
    }
53
}
54
55
/// What this user last saw of a hub's log.
56
struct Seen {
57
    identity: Identity,
58
    checkpoint: Checkpoint,
59
}
60
610×
fn file_name(hub: &str) -> String {
620×
    hub.chars()
630×
        .map(|c| {
640×
            if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
650×
                c
66
            } else {
670×
                '_'
68
            }
690×
        })
700×
        .collect()
710×
}
72
73
impl Repo {
740×
    fn tlog_file(&self, hub: &str) -> PathBuf {
750×
        self.user_config
760×
            .parent()
770×
            .map(Path::to_path_buf)
780×
            .unwrap_or_else(default_home)
790×
            .join("tlog")
800×
            .join(file_name(hub))
810×
    }
82
830×
    fn seen_log(&self, hub: &str) -> Option<Seen> {
840×
        let text = fs::read_to_string(self.tlog_file(hub)).ok()?;
850×
        let (key, note) = text.split_once('\n')?;
860×
        let identity = Identity::decode(&crate::dsse::base64_decode(key)?).ok()?;
870×
        let note = Note::parse(note).ok()?;
88
        Some(Seen {
890×
            checkpoint: Checkpoint::parse(&note.text).ok()?,
900×
            identity,
91
        })
920×
    }
93
940×
    fn remember_log(&self, hub: &str, identity: &Identity, note: &Note) -> Result<()> {
950×
        let path = self.tlog_file(hub);
960×
        if let Some(dir) = path.parent() {
970×
            fs::create_dir_all(dir)?;
980×
        }
990×
        let text = format!(
100
            "{}\n{}",
1010×
            crate::dsse::base64(&identity.encode()),
1020×
            note.encode()
103
        );
1040×
        crate::durable::write_atomic(&path, text.as_bytes())?;
1050×
        Ok(())
1060×
    }
107
108
    /// Take a checkpoint note `text` from `hub` as the newest seen, once it
109
    /// verifies with the hub's pinned log key and extends the last one
110
    /// seen. `Err` when the hub showed two histories; `Ok(Err(why))` when
111
    /// it could not be checked (the last one seen is kept).
1120×
    pub(crate) fn accept_checkpoint(
1130×
        &self,
1140×
        hub: &str,
1150×
        text: &str,
1160×
        options: &RemoteOptions,
1170×
    ) -> Result<std::result::Result<Checkpoint, String>> {
1180×
        let unchecked = |why: String| Ok(Err(why));
1190×
        let note = match Note::parse(text) {
1200×
            Ok(n) => n,
1210×
            Err(e) => return unchecked(e.to_string()),
122
        };
1230×
        let cp = match Checkpoint::parse(&note.text) {
1240×
            Ok(c) => c,
1250×
            Err(e) => return unchecked(e.to_string()),
126
        };
1270×
        let seen = self.seen_log(hub);
1280×
        let identity = match &seen {
1290×
            Some(s) => s.identity.clone(),
1300×
            None => match http_get(&format!("{hub}/.well-known/oxyhub-key"), options)
1310×
                .and_then(|b| Ok(Identity::decode(&b)?))
132
            {
1330×
                Ok(i) => i,
1340×
                Err(e) => return unchecked(format!("its key could not be fetched ({e})")),
135
            },
136
        };
1370×
        let two = |why: String| {
1380×
            fail(format!(
139
                "{hub} showed two histories: {why}. Do not trust what it serves; tell its operator. \
140
                 (What this user saw of its log is in {}.)",
1410×
                self.tlog_file(hub).display()
142
            ))
1430×
        };
1440×
        if let Err(e) = note.verify(&cp.origin, &identity) {
1450×
            return match &seen {
1460×
                Some(_) => two(format!(
1470×
                    "its checkpoint is not signed by the log key it showed before ({e})"
1480×
                )),
1490×
                None => unchecked(e.to_string()),
150
            };
1510×
        }
1520×
        if let Some(s) = &seen {
1530×
            let old = &s.checkpoint;
1540×
            if old.origin != cp.origin {
1550×
                return two(format!("its log is now {}, not {}", cp.origin, old.origin));
1560×
            }
1570×
            if cp.size < old.size {
1580×
                return two(format!(
1590×
                    "its log has {} entries, fewer than the {} it showed before",
1600×
                    cp.size, old.size
1610×
                ));
1620×
            }
1630×
            if cp.size == old.size && cp.root != old.root {
1640×
                return two(format!("two different logs of {} entries", cp.size));
1650×
            }
1660×
            if cp.size > old.size && old.size > 0 {
1670×
                let tiles = Tiles {
1680×
                    hub,
1690×
                    size: cp.size,
1700×
                    options,
1710×
                    cache: RefCell::new(HashMap::new()),
1720×
                };
1730×
                match tlog::prove_consistency(cp.size, old.size, &tiles).and_then(|p| {
1740×
                    tlog::check_consistency(&p, cp.size, &cp.root, old.size, &old.root)
1750×
                }) {
1760×
                    Ok(()) => {}
177
                    Err(TlogError::ProofFailed) | Err(TlogError::BadIndex(_)) => {
1780×
                        return two(format!(
1790×
                            "its log of {} entries does not extend the one of {} it showed before",
1800×
                            cp.size, old.size
1810×
                        ))
182
                    }
183
                    // Keep the older checkpoint: the next fetch tries again.
1840×
                    Err(e) => return unchecked(format!("its tiles could not be read ({e})")),
185
                }
1860×
            }
1870×
        }
1880×
        self.remember_log(hub, &identity, &note)?;
1890×
        Ok(Ok(cp))
1900×
    }
191
192
    /// Check what a refs answer from `url` says about its host's log.
193
    /// Fails only when the host showed two histories (or a log key other
194
    /// than the one it showed before); everything else is a note.
195353×
    pub(crate) fn check_log(
196353×
        &self,
197353×
        url: &str,
198353×
        ca: Option<&Path>,
199353×
        resp: &RefsResponse,
200353×
    ) -> Result<Vec<String>> {
201353×
        let (Some(text), Some(hub)) = (&resp.log, hub_of(url)) else {
202353×
            return Ok(vec![]);
203
        };
2040×
        let options = RemoteOptions {
2050×
            ca: ca.map(Path::to_path_buf),
2060×
            ..RemoteOptions::default()
2070×
        };
2080×
        let cp = match self.accept_checkpoint(&hub, text, &options)? {
2090×
            Ok(cp) => cp,
2100×
            Err(why) => {
2110×
                return Ok(vec![format!(
2120×
                    "{hub}'s transparency log was not checked: {why}"
2130×
                )])
214
            }
215
        };
2160×
        let mut notes = Vec::new();
2170×
        let mut missing = 0;
2180×
        for r in &resp.refs {
2190×
            let Some(a) = &r.attestation else { continue };
2200×
            let Some(l) = &r.logged else {
2210×
                missing += 1;
2220×
                continue;
223
            };
2240×
            let fits = Entry::decode(&l.entry).is_ok_and(|e| {
2250×
                e.kind == "ref" && e.object == a.attest.hash() && e.repo == a.attest.repo
2260×
            });
2270×
            let included = fits
2280×
                && tlog::check_inclusion(
2290×
                    &l.proof,
2300×
                    cp.size,
2310×
                    &cp.root,
2320×
                    l.index,
2330×
                    &tlog::leaf_hash(&l.entry),
2340×
                )
2350×
                .is_ok();
2360×
            if !included {
2370×
                notes.push(format!(
2380×
                    "{hub}'s log entry {} for {} does not prove its attestation is in the log",
2390×
                    l.index, r.name
2400×
                ));
2410×
            }
242
        }
2430×
        if missing > 0 {
2440×
            notes.push(format!(
245
                "{} of {hub}'s attestations {} not in its transparency log yet (logged within seconds of a push; checked on a later fetch)",
246
                missing,
2470×
                if missing == 1 { "is" } else { "are" }
248
            ));
2490×
        }
2500×
        Ok(notes)
251353×
    }
252
}
253
254
/// A hub's whole log, checked (`oxy tlog verify`).
255
#[derive(Clone, Debug)]
256
pub struct LogAudit {
257
    pub hub: String,
258
    pub checkpoint: Checkpoint,
259
    /// Every entry, numbered from 0.
260
    pub entries: Vec<Entry>,
261
}
262
263
impl Repo {
264
    /// Fetch the log of `url`'s hub whole: its checkpoint, accepted as a
265
    /// fetch accepts one, and every entry bundle, whose entries must make
266
    /// the root the checkpoint signs.
2670×
    pub fn audit_log(&self, url: &str, ca: Option<&Path>) -> Result<LogAudit> {
2680×
        let hub =
2690×
            hub_of(url).ok_or_else(|| RepoError::Msg(format!("{url} is not a hub's address")))?;
2700×
        let options = RemoteOptions {
2710×
            ca: ca.map(Path::to_path_buf),
2720×
            ..RemoteOptions::default()
2730×
        };
2740×
        let tl = |e: TlogError| RepoError::Msg(e.to_string());
2750×
        let text = String::from_utf8(http_get(&format!("{hub}/log/checkpoint"), &options)?)
2760×
            .map_err(|_| RepoError::Msg(format!("{hub}'s checkpoint is not text")))?;
2770×
        let cp = match self.accept_checkpoint(&hub, &text, &options)? {
2780×
            Ok(c) => c,
2790×
            Err(why) => return fail(format!("{hub}'s log could not be checked: {why}")),
280
        };
2810×
        let mut entries = Vec::with_capacity(cp.size as usize);
2820×
        let mut stored: Vec<Hash> = Vec::new();
2830×
        let mut n = 0u64;
2840×
        while n < cp.size {
2850×
            let t = Tile {
2860×
                level: 0,
2870×
                n: n >> tlog::TILE_HEIGHT,
2880×
                width: (cp.size - n).min(tlog::TILE_WIDTH) as u32,
2890×
            };
2900×
            let bytes = http_get(&format!("{hub}/log/{}", t.entries_path()), &options)?;
2910×
            let bundle = tlog::decode_bundle(&bytes).map_err(tl)?;
2920×
            if bundle.len() != t.width as usize {
2930×
                return fail(format!(
294
                    "{hub}'s entry bundle {} holds {} entries, not {}",
2950×
                    t.entries_path(),
2960×
                    bundle.len(),
297
                    t.width
298
                ));
2990×
            }
3000×
            for b in bundle {
3010×
                let add =
3020×
                    tlog::stored_hashes_for_leaf(n, tlog::leaf_hash(&b), &stored).map_err(tl)?;
3030×
                stored.extend(add);
3040×
                entries.push(Entry::decode(&b).map_err(tl)?);
3050×
                n += 1;
306
            }
307
        }
3080×
        if tlog::tree_hash(cp.size, &stored).map_err(tl)? != cp.root {
3090×
            return fail(format!(
310
                "{hub} serves entries that do not make the root it signed for {} entries: its log is not the one it attests",
311
                cp.size
312
            ));
3130×
        }
3140×
        Ok(LogAudit {
3150×
            hub,
3160×
            checkpoint: cp,
3170×
            entries,
3180×
        })
3190×
    }
320
}