OXYGENHUB
◆ merge-remote-bookmark oxygen / crates / oxy-core / src / repo / transparency.rs
12.9 KiB · Rust · last changed A hub's new log key is a note, not a fork 18 days ago source blame history raw
4b9d0677 Andy McCall ↶ 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<'_> {
31
    fn read_hashes(&self, indexes: &[u64]) -> std::result::Result<Vec<Hash>, TlogError> {
32
        let mut out = Vec::with_capacity(indexes.len());
33
        for &i in indexes {
34
            let (t, ..) = Tile::for_index(i);
35
            let key = (t.level, t.n);
36
            if !self.cache.borrow().contains_key(&key) {
37
                let held =
38
                    (self.size >> (tlog::TILE_HEIGHT * t.level)) - (t.n << tlog::TILE_HEIGHT);
39
                let tile = Tile {
40
                    width: held.min(tlog::TILE_WIDTH) as u32,
41
                    ..t
42
                };
43
                let data = http_get(&format!("{}/log/{}", self.hub, tile.path()), self.options)
44
                    .map_err(|e| TlogError::Read(e.to_string()))?;
45
                self.cache.borrow_mut().insert(key, (tile, data));
46
            }
47
            let cache = self.cache.borrow();
48
            let (tile, data) = &cache[&key];
49
            out.push(tile.hash_at(data, i)?);
50
        }
51
        Ok(out)
52
    }
53
}
54
55
/// What this user last saw of a hub's log.
56
struct Seen {
57
    identity: Identity,
58
    checkpoint: Checkpoint,
59
}
60
61
fn file_name(hub: &str) -> String {
62
    hub.chars()
63
        .map(|c| {
64
            if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
65
                c
66
            } else {
67
                '_'
68
            }
69
        })
70
        .collect()
71
}
72
73
impl Repo {
74
    fn tlog_file(&self, hub: &str) -> PathBuf {
75
        self.user_config
76
            .parent()
77
            .map(Path::to_path_buf)
78
            .unwrap_or_else(default_home)
79
            .join("tlog")
80
            .join(file_name(hub))
81
    }
82
83
    fn seen_log(&self, hub: &str) -> Option<Seen> {
84
        let text = fs::read_to_string(self.tlog_file(hub)).ok()?;
85
        let (key, note) = text.split_once('\n')?;
86
        let identity = Identity::decode(&crate::dsse::base64_decode(key)?).ok()?;
87
        let note = Note::parse(note).ok()?;
88
        Some(Seen {
89
            checkpoint: Checkpoint::parse(&note.text).ok()?,
90
            identity,
91
        })
92
    }
93
94
    fn remember_log(&self, hub: &str, identity: &Identity, note: &Note) -> Result<()> {
95
        let path = self.tlog_file(hub);
96
        if let Some(dir) = path.parent() {
97
            fs::create_dir_all(dir)?;
98
        }
99
        let text = format!(
100
            "{}\n{}",
101
            crate::dsse::base64(&identity.encode()),
102
            note.encode()
103
        );
104
        crate::durable::write_atomic(&path, text.as_bytes())?;
105
        Ok(())
106
    }
107
e59512f2 Andy McCall ↶ 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).
112
    pub(crate) fn accept_checkpoint(
4b9d0677 Andy McCall ↶ 113
        &self,
e59512f2 Andy McCall ↶ 114
        hub: &str,
115
        text: &str,
116
        options: &RemoteOptions,
b333a199 Andy McCall ↶ 117
    ) -> Result<std::result::Result<(Checkpoint, Vec<String>), String>> {
e59512f2 Andy McCall ↶ 118
        let unchecked = |why: String| Ok(Err(why));
4b9d0677 Andy McCall ↶ 119
        let note = match Note::parse(text) {
120
            Ok(n) => n,
121
            Err(e) => return unchecked(e.to_string()),
122
        };
123
        let cp = match Checkpoint::parse(&note.text) {
124
            Ok(c) => c,
125
            Err(e) => return unchecked(e.to_string()),
126
        };
b333a199 Andy McCall ↶ 127
        let fetch_key = || {
128
            http_get(&format!("{hub}/.well-known/oxyhub-key"), options)
4b9d0677 Andy McCall ↶ 129
                .and_then(|b| Ok(Identity::decode(&b)?))
b333a199 Andy McCall ↶ 130
        };
131
        let mut notes = Vec::new();
132
        let mut seen = self.seen_log(hub);
133
        let mut identity = match &seen {
134
            Some(s) => s.identity.clone(),
135
            None => match fetch_key() {
4b9d0677 Andy McCall ↶ 136
                Ok(i) => i,
137
                Err(e) => return unchecked(format!("its key could not be fetched ({e})")),
138
            },
139
        };
b333a199 Andy McCall ↶ 140
        if let Err(e) = note.verify(&cp.origin, &identity) {
141
            // Signed by another key than the one pinned: a hub given a new
142
            // key, or one starting another log. Not proof of a fork, so a
143
            // loud note while logs are new (decision 3), and the new key is
144
            // pinned; a fork under one key is what fails.
145
            let Some(old) = &seen else {
146
                return unchecked(e.to_string());
147
            };
148
            match fetch_key() {
149
                Ok(new) if note.verify(&cp.origin, &new).is_ok() => {
150
                    notes.push(format!(
151
                        "{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",
152
                        new.hash().short(12),
153
                        old.identity.hash().short(12)
154
                    ));
155
                    identity = new;
156
                    seen = None;
157
                }
158
                _ => return unchecked(format!("its checkpoint does not verify ({e})")),
159
            }
160
        }
4b9d0677 Andy McCall ↶ 161
        let two = |why: String| {
162
            fail(format!(
163
                "{hub} showed two histories: {why}. Do not trust what it serves; tell its operator. \
164
                 (What this user saw of its log is in {}.)",
e59512f2 Andy McCall ↶ 165
                self.tlog_file(hub).display()
4b9d0677 Andy McCall ↶ 166
            ))
167
        };
168
        if let Some(s) = &seen {
169
            let old = &s.checkpoint;
170
            if old.origin != cp.origin {
b333a199 Andy McCall ↶ 171
                // A hub moved to another name keeps its key: a new log as
172
                // far as this user can tell, said so.
173
                notes.push(format!(
174
                    "{hub}'s log is now {}, not {}: checking it afresh",
175
                    cp.origin, old.origin
176
                ));
177
            } else if cp.size < old.size {
4b9d0677 Andy McCall ↶ 178
                return two(format!(
179
                    "its log has {} entries, fewer than the {} it showed before",
180
                    cp.size, old.size
181
                ));
b333a199 Andy McCall ↶ 182
            } else if cp.size == old.size && cp.root != old.root {
4b9d0677 Andy McCall ↶ 183
                return two(format!("two different logs of {} entries", cp.size));
b333a199 Andy McCall ↶ 184
            } else if cp.size > old.size && old.size > 0 {
4b9d0677 Andy McCall ↶ 185
                let tiles = Tiles {
e59512f2 Andy McCall ↶ 186
                    hub,
4b9d0677 Andy McCall ↶ 187
                    size: cp.size,
e59512f2 Andy McCall ↶ 188
                    options,
4b9d0677 Andy McCall ↶ 189
                    cache: RefCell::new(HashMap::new()),
190
                };
191
                match tlog::prove_consistency(cp.size, old.size, &tiles).and_then(|p| {
192
                    tlog::check_consistency(&p, cp.size, &cp.root, old.size, &old.root)
193
                }) {
194
                    Ok(()) => {}
195
                    Err(TlogError::ProofFailed) | Err(TlogError::BadIndex(_)) => {
196
                        return two(format!(
197
                            "its log of {} entries does not extend the one of {} it showed before",
198
                            cp.size, old.size
199
                        ))
200
                    }
201
                    // Keep the older checkpoint: the next fetch tries again.
202
                    Err(e) => return unchecked(format!("its tiles could not be read ({e})")),
203
                }
204
            }
205
        }
e59512f2 Andy McCall ↶ 206
        self.remember_log(hub, &identity, &note)?;
b333a199 Andy McCall ↶ 207
        Ok(Ok((cp, notes)))
e59512f2 Andy McCall ↶ 208
    }
209
210
    /// Check what a refs answer from `url` says about its host's log.
b333a199 Andy McCall ↶ 211
    /// Fails only when the host showed two histories under one key;
212
    /// everything else is a note.
e59512f2 Andy McCall ↶ 213
    pub(crate) fn check_log(
214
        &self,
215
        url: &str,
216
        ca: Option<&Path>,
217
        resp: &RefsResponse,
218
    ) -> Result<Vec<String>> {
219
        let (Some(text), Some(hub)) = (&resp.log, hub_of(url)) else {
220
            return Ok(vec![]);
221
        };
222
        let options = RemoteOptions {
223
            ca: ca.map(Path::to_path_buf),
224
            ..RemoteOptions::default()
225
        };
b333a199 Andy McCall ↶ 226
        let (cp, mut notes) = match self.accept_checkpoint(&hub, text, &options)? {
227
            Ok(c) => c,
e59512f2 Andy McCall ↶ 228
            Err(why) => {
229
                return Ok(vec![format!(
230
                    "{hub}'s transparency log was not checked: {why}"
231
                )])
232
            }
233
        };
4b9d0677 Andy McCall ↶ 234
        let mut missing = 0;
235
        for r in &resp.refs {
236
            let Some(a) = &r.attestation else { continue };
237
            let Some(l) = &r.logged else {
238
                missing += 1;
239
                continue;
240
            };
241
            let fits = Entry::decode(&l.entry).is_ok_and(|e| {
242
                e.kind == "ref" && e.object == a.attest.hash() && e.repo == a.attest.repo
243
            });
244
            let included = fits
245
                && tlog::check_inclusion(
246
                    &l.proof,
247
                    cp.size,
248
                    &cp.root,
249
                    l.index,
250
                    &tlog::leaf_hash(&l.entry),
251
                )
252
                .is_ok();
253
            if !included {
254
                notes.push(format!(
255
                    "{hub}'s log entry {} for {} does not prove its attestation is in the log",
256
                    l.index, r.name
257
                ));
258
            }
259
        }
260
        if missing > 0 {
261
            notes.push(format!(
262
                "{} of {hub}'s attestations {} not in its transparency log yet (logged within seconds of a push; checked on a later fetch)",
263
                missing,
264
                if missing == 1 { "is" } else { "are" }
265
            ));
266
        }
267
        Ok(notes)
e59512f2 Andy McCall ↶ 268
    }
269
}
270
271
/// A hub's whole log, checked (`oxy tlog verify`).
272
#[derive(Clone, Debug)]
273
pub struct LogAudit {
274
    pub hub: String,
275
    pub checkpoint: Checkpoint,
276
    /// Every entry, numbered from 0.
277
    pub entries: Vec<Entry>,
b333a199 Andy McCall ↶ 278
    /// What accepting the checkpoint noted (a new log key, a new name).
279
    pub notes: Vec<String>,
e59512f2 Andy McCall ↶ 280
}
281
282
impl Repo {
283
    /// Fetch the log of `url`'s hub whole: its checkpoint, accepted as a
284
    /// fetch accepts one, and every entry bundle, whose entries must make
285
    /// the root the checkpoint signs.
286
    pub fn audit_log(&self, url: &str, ca: Option<&Path>) -> Result<LogAudit> {
287
        let hub =
288
            hub_of(url).ok_or_else(|| RepoError::Msg(format!("{url} is not a hub's address")))?;
289
        let options = RemoteOptions {
290
            ca: ca.map(Path::to_path_buf),
291
            ..RemoteOptions::default()
292
        };
293
        let tl = |e: TlogError| RepoError::Msg(e.to_string());
294
        let text = String::from_utf8(http_get(&format!("{hub}/log/checkpoint"), &options)?)
295
            .map_err(|_| RepoError::Msg(format!("{hub}'s checkpoint is not text")))?;
b333a199 Andy McCall ↶ 296
        let (cp, notes) = match self.accept_checkpoint(&hub, &text, &options)? {
e59512f2 Andy McCall ↶ 297
            Ok(c) => c,
298
            Err(why) => return fail(format!("{hub}'s log could not be checked: {why}")),
299
        };
300
        let mut entries = Vec::with_capacity(cp.size as usize);
301
        let mut stored: Vec<Hash> = Vec::new();
302
        let mut n = 0u64;
303
        while n < cp.size {
304
            let t = Tile {
305
                level: 0,
306
                n: n >> tlog::TILE_HEIGHT,
307
                width: (cp.size - n).min(tlog::TILE_WIDTH) as u32,
308
            };
309
            let bytes = http_get(&format!("{hub}/log/{}", t.entries_path()), &options)?;
310
            let bundle = tlog::decode_bundle(&bytes).map_err(tl)?;
311
            if bundle.len() != t.width as usize {
312
                return fail(format!(
313
                    "{hub}'s entry bundle {} holds {} entries, not {}",
314
                    t.entries_path(),
315
                    bundle.len(),
316
                    t.width
317
                ));
318
            }
319
            for b in bundle {
320
                let add =
321
                    tlog::stored_hashes_for_leaf(n, tlog::leaf_hash(&b), &stored).map_err(tl)?;
322
                stored.extend(add);
323
                entries.push(Entry::decode(&b).map_err(tl)?);
324
                n += 1;
325
            }
326
        }
327
        if tlog::tree_hash(cp.size, &stored).map_err(tl)? != cp.root {
328
            return fail(format!(
329
                "{hub} serves entries that do not make the root it signed for {} entries: its log is not the one it attests",
330
                cp.size
331
            ));
332
        }
333
        Ok(LogAudit {
334
            hub,
335
            checkpoint: cp,
336
            entries,
b333a199 Andy McCall ↶ 337
            notes,
e59512f2 Andy McCall ↶ 338
        })
4b9d0677 Andy McCall ↶ 339
    }
340
}