OXYGENHUB
◆ push-notes oxygen / crates / oxy-core / src / repo / transparency.rs
23.7 KiB · Rust · last changed Keyless agents create release tags: receive and fetch authorize certified tag keys 15 days ago source blame history raw
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
//! The witnesses that cosign a hub's checkpoints are pinned the same way
14
//! (`<oxy home>/tlog/<hub>.witnesses`), and give each bookmark's
15
//! attestation an *integrated time* (oxygenhub#240): the earliest time a
16
//! trusted witness cosigned a checkpoint holding it. A fetch checks
17
//! delegations and certificates at that time when it has one.
18
19
use super::*;
20
use crate::credentials::hub_of;
21
use crate::protocol::http_get;
22
use crate::tlog::{self, Checkpoint, Entry, Hash, HashReader, Integrated, Note, Tile, TlogError};
23
use std::cell::RefCell;
24
25
/// Tiles fetched, by level and number, with the width fetched.
26
type TileCache = RefCell<HashMap<(u32, u64), (Tile, Vec<u8>)>>;
27
28
/// A hub's log as its tiles serve it, for a tree of `size` entries.
29
struct Tiles<'a> {
30
    hub: &'a str,
31
    size: u64,
32
    options: &'a RemoteOptions,
33
    cache: TileCache,
34
}
35
36
impl HashReader for Tiles<'_> {
37
    fn read_hashes(&self, indexes: &[u64]) -> std::result::Result<Vec<Hash>, TlogError> {
38
        let mut out = Vec::with_capacity(indexes.len());
39
        for &i in indexes {
40
            let (t, ..) = Tile::for_index(i);
41
            let key = (t.level, t.n);
42
            if !self.cache.borrow().contains_key(&key) {
43
                let held =
44
                    (self.size >> (tlog::TILE_HEIGHT * t.level)) - (t.n << tlog::TILE_HEIGHT);
45
                let tile = Tile {
46
                    width: held.min(tlog::TILE_WIDTH) as u32,
47
                    ..t
48
                };
49
                let data = http_get(&format!("{}/log/{}", self.hub, tile.path()), self.options)
50
                    .map_err(|e| TlogError::Read(e.to_string()))?;
51
                self.cache.borrow_mut().insert(key, (tile, data));
52
            }
53
            let cache = self.cache.borrow();
54
            let (tile, data) = &cache[&key];
55
            out.push(tile.hash_at(data, i)?);
56
        }
57
        Ok(out)
58
    }
59
}
60
61
/// What this user last saw of a hub's log.
62
struct Seen {
63
    identity: Identity,
64
    checkpoint: Checkpoint,
65
}
66
67
fn file_name(hub: &str) -> String {
68
    hub.chars()
69
        .map(|c| {
70
            if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
71
                c
72
            } else {
73
                '_'
74
            }
75
        })
76
        .collect()
77
}
78
79
impl Repo {
80
    fn tlog_file(&self, hub: &str) -> PathBuf {
81
        self.user_config
82
            .parent()
83
            .map(Path::to_path_buf)
84
            .unwrap_or_else(default_home)
85
            .join("tlog")
86
            .join(file_name(hub))
87
    }
88
89
    fn seen_log(&self, hub: &str) -> Option<Seen> {
90
        let text = fs::read_to_string(self.tlog_file(hub)).ok()?;
91
        let (key, note) = text.split_once('\n')?;
92
        let identity = Identity::decode(&crate::dsse::base64_decode(key)?).ok()?;
93
        let note = Note::parse(note).ok()?;
94
        Some(Seen {
95
            checkpoint: Checkpoint::parse(&note.text).ok()?,
96
            identity,
97
        })
98
    }
99
100
    pub(crate) fn remember_log(&self, hub: &str, identity: &Identity, note: &Note) -> Result<()> {
101
        let path = self.tlog_file(hub);
102
        if let Some(dir) = path.parent() {
103
            fs::create_dir_all(dir)?;
104
        }
105
        let text = format!(
106
            "{}\n{}",
107
            crate::dsse::base64(&identity.encode()),
108
            note.encode()
109
        );
110
        crate::durable::write_atomic(&path, text.as_bytes())?;
111
        Ok(())
112
    }
113
114
    /// Take a checkpoint note `text` from `hub` as the newest seen, once it
115
    /// verifies with the hub's pinned log key and extends the last one
116
    /// seen. `Err` when the hub showed two histories; `Ok(Err(why))` when
117
    /// it could not be checked (the last one seen is kept).
118
    pub(crate) fn accept_checkpoint(
119
        &self,
120
        hub: &str,
121
        text: &str,
122
        options: &RemoteOptions,
123
    ) -> Result<std::result::Result<Accepted, String>> {
124
        let unchecked = |why: String| Ok(Err(why));
125
        let note = match Note::parse(text) {
126
            Ok(n) => n,
127
            Err(e) => return unchecked(e.to_string()),
128
        };
129
        let cp = match Checkpoint::parse(&note.text) {
130
            Ok(c) => c,
131
            Err(e) => return unchecked(e.to_string()),
132
        };
133
        let fetch_key = || {
134
            http_get(&format!("{hub}/.well-known/oxyhub-key"), options)
135
                .and_then(|b| Ok(Identity::decode(&b)?))
136
        };
137
        let mut notes = Vec::new();
138
        let mut seen = self.seen_log(hub);
139
        let mut identity = match &seen {
140
            Some(s) => s.identity.clone(),
141
            None => match fetch_key() {
142
                Ok(i) => i,
143
                Err(e) => return unchecked(format!("its key could not be fetched ({e})")),
144
            },
145
        };
146
        if let Err(e) = note.verify(&cp.origin, &identity) {
147
            // Signed by another key than the one pinned: a hub given a new
148
            // key, or one starting another log. Not proof of a fork, so a
149
            // loud note while logs are new (decision 3), and the new key is
150
            // pinned; a fork under one key is what fails.
151
            let Some(old) = &seen else {
152
                return unchecked(e.to_string());
153
            };
154
            match fetch_key() {
155
                Ok(new) if note.verify(&cp.origin, &new).is_ok() => {
156
                    notes.push(format!(
157
                        "{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",
158
                        new.hash().short(12),
159
                        old.identity.hash().short(12)
160
                    ));
161
                    identity = new;
162
                    seen = None;
163
                }
164
                _ => return unchecked(format!("its checkpoint does not verify ({e})")),
165
            }
166
        }
167
        let two = |why: String| {
168
            fail(format!(
169
                "{hub} showed two histories: {why}. Do not trust what it serves; tell its operator. \
170
                 (What this user saw of its log is in {}.)",
171
                self.tlog_file(hub).display()
172
            ))
173
        };
174
        if let Some(s) = &seen {
175
            let old = &s.checkpoint;
176
            if old.origin != cp.origin {
177
                // A hub moved to another name keeps its key: a new log as
178
                // far as this user can tell, said so.
179
                notes.push(format!(
180
                    "{hub}'s log is now {}, not {}: checking it afresh",
181
                    cp.origin, old.origin
182
                ));
183
            } else if cp.size < old.size {
184
                return two(format!(
185
                    "its log has {} entries, fewer than the {} it showed before",
186
                    cp.size, old.size
187
                ));
188
            } else if cp.size == old.size && cp.root != old.root {
189
                return two(format!("two different logs of {} entries", cp.size));
190
            } else if cp.size > old.size && old.size > 0 {
191
                let tiles = Tiles {
192
                    hub,
193
                    size: cp.size,
194
                    options,
195
                    cache: RefCell::new(HashMap::new()),
196
                };
197
                match tlog::prove_consistency(cp.size, old.size, &tiles).and_then(|p| {
198
                    tlog::check_consistency(&p, cp.size, &cp.root, old.size, &old.root)
199
                }) {
200
                    Ok(()) => {}
201
                    Err(TlogError::ProofFailed) | Err(TlogError::BadIndex(_)) => {
202
                        return two(format!(
203
                            "its log of {} entries does not extend the one of {} it showed before",
204
                            cp.size, old.size
205
                        ))
206
                    }
207
                    // Keep the older checkpoint: the next fetch tries again.
208
                    Err(e) => return unchecked(format!("its tiles could not be read ({e})")),
209
                }
210
            }
211
        }
212
        self.remember_log(hub, &identity, &note)?;
213
        Ok(Ok(Accepted {
214
            checkpoint: cp,
215
            identity,
216
            note,
217
            notes,
218
        }))
219
    }
220
221
    fn witnesses_file(&self, hub: &str) -> PathBuf {
222
        let mut p = self.tlog_file(hub);
223
        p.set_file_name(format!("{}.witnesses", file_name(hub)));
224
        p
225
    }
226
227
    /// The witnesses this user pinned for `hub`: each one's name in
228
    /// cosignatures and its identity.
229
    pub(crate) fn pinned_witnesses(&self, hub: &str) -> Vec<(String, Identity)> {
230
        let Ok(text) = fs::read_to_string(self.witnesses_file(hub)) else {
231
            return vec![];
232
        };
233
        text.lines()
234
            .filter_map(|l| {
235
                let (name, key) = l.split_once(' ')?;
236
                let id = Identity::decode(&crate::dsse::base64_decode(key)?).ok()?;
237
                Some((name.to_string(), id))
238
            })
239
            .collect()
240
    }
241
242
    /// Pin the witnesses `hub` lists (name and identity bytes), as its log
243
    /// key is pinned: on first sight silently, later with a note for a
244
    /// witness that is new or has another key than before. A witness the
245
    /// hub no longer lists stays pinned. Returns every pinned witness.
246
    pub(crate) fn pin_witnesses(
247
        &self,
248
        hub: &str,
249
        listed: &[(String, Vec<u8>)],
250
        notes: &mut Vec<String>,
251
    ) -> Result<Vec<(String, Identity)>> {
252
        let path = self.witnesses_file(hub);
253
        let first = !path.exists();
254
        let mut pinned = self.pinned_witnesses(hub);
255
        let mut changed = false;
256
        for (name, bytes) in listed {
257
            if name.is_empty() || name.contains(char::is_whitespace) {
258
                notes.push(format!(
259
                    "{hub} lists a witness named {name:?}, which cannot name one"
260
                ));
261
                continue;
262
            }
263
            let Ok(id) = Identity::decode(bytes) else {
264
                notes.push(format!(
265
                    "{hub} lists witness {name} with a key that does not decode"
266
                ));
267
                continue;
268
            };
269
            match pinned.iter_mut().find(|(n, _)| n == name) {
270
                Some((_, old)) if old.hash() == id.hash() => {}
271
                Some((_, old)) => {
272
                    notes.push(format!(
273
                        "{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",
274
                        id.hash().short(12),
275
                        old.hash().short(12)
276
                    ));
277
                    *old = id;
278
                    changed = true;
279
                }
280
                None => {
281
                    if !first {
282
                        notes.push(format!(
283
                            "{hub} is now cosigned by witness {name} (key {}), trusted from now on",
284
                            id.hash().short(12)
285
                        ));
286
                    }
287
                    pinned.push((name.clone(), id));
288
                    changed = true;
289
                }
290
            }
291
        }
292
        if changed {
293
            if let Some(dir) = path.parent() {
294
                fs::create_dir_all(dir)?;
295
            }
296
            let text: String = pinned
297
                .iter()
298
                .map(|(n, id)| format!("{n} {}\n", crate::dsse::base64(&id.encode())))
299
                .collect();
300
            crate::durable::write_atomic(&path, text.as_bytes())?;
301
        }
302
        Ok(pinned)
303
    }
304
305
    /// Check what a refs answer from `url` says about its host's log, and
306
    /// read each bookmark attestation's integrated time. Fails only when
307
    /// the host showed two histories under one key; everything else is a
308
    /// note.
309
    pub(crate) fn check_log(
310
        &self,
311
        url: &str,
312
        ca: Option<&Path>,
313
        resp: &RefsResponse,
314
    ) -> Result<LogCheck> {
315
        let mut out = LogCheck::default();
316
        let (Some(text), Some(hub)) = (&resp.log, hub_of(url)) else {
317
            return Ok(out);
318
        };
319
        out.kept = true;
320
        let options = RemoteOptions {
321
            ca: ca.map(Path::to_path_buf),
322
            ..RemoteOptions::default()
323
        };
324
        let accepted = match self.accept_checkpoint(&hub, text, &options)? {
325
            Ok(c) => c,
326
            Err(why) => {
327
                out.notes
328
                    .push(format!("{hub}'s transparency log was not checked: {why}"));
329
                out.unchecked = Some(why);
330
                return Ok(out);
331
            }
332
        };
333
        let cp = &accepted.checkpoint;
334
        out.notes.extend(accepted.notes.iter().cloned());
335
        let witnesses = self.pin_witnesses(&hub, &resp.witnesses, &mut out.notes)?;
336
        let mut missing = 0;
337
        // Each attestation's place in the log, and its integrated time.
338
        let mut place = |name: &str,
339
                         kind: &str,
340
                         a: &SignedAttest,
341
                         logged: Option<&crate::protocol::Logged>,
342
                         integration: &[crate::protocol::Integration]|
343
         -> std::result::Result<Integrated, String> {
344
            let Some(l) = logged else {
345
                missing += 1;
346
                return Err("it is not in the log yet".into());
347
            };
348
            let fits = Entry::decode(&l.entry).is_ok_and(|e| {
349
                e.kind == kind && e.object == a.attest.hash() && e.repo == a.attest.repo
350
            });
351
            let included = fits
352
                && tlog::check_inclusion(
353
                    &l.proof,
354
                    cp.size,
355
                    &cp.root,
356
                    l.index,
357
                    &tlog::leaf_hash(&l.entry),
358
                )
359
                .is_ok();
360
            if !included {
361
                out.notes.push(format!(
362
                    "{hub}'s log entry {} for {name} does not prove its attestation is in the log",
363
                    l.index
364
                ));
365
            }
366
            if !fits {
367
                Err("its log entry is not its attestation's".to_string())
368
            } else if witnesses.is_empty() {
369
                Err(format!("{hub} names no witness"))
370
            } else {
371
                let cosigned: Vec<(&str, &[Hash])> = integration
372
                    .iter()
373
                    .map(|i| (i.note.as_str(), i.proof.as_slice()))
374
                    .collect();
375
                tlog::integrated_time(
376
                    &l.entry,
377
                    l.index,
378
                    &cosigned,
379
                    &cp.origin,
380
                    &accepted.identity,
381
                    &witnesses,
382
                )
383
            }
384
        };
385
        let mut times = HashMap::new();
386
        for r in &resp.refs {
387
            let Some(a) = &r.attestation else { continue };
388
            let time = place(&r.name, "ref", a, r.logged.as_ref(), &r.integration);
389
            times.insert(r.name.clone(), time);
390
        }
391
        // Tags too (oxygen#129): a short-lived key's tag is checked at its
392
        // integrated time on every later fetch.
393
        let mut tag_times = HashMap::new();
394
        for t in &resp.tags {
395
            let Some(a) = &t.attestation else { continue };
396
            let name = format!("tags/{}", t.name);
397
            let time = place(&name, "tag", a, t.logged.as_ref(), &t.integration);
398
            tag_times.insert(t.name.clone(), time);
399
        }
400
        out.times = times;
401
        out.tag_times = tag_times;
402
        if missing > 0 {
403
            out.notes.push(format!(
404
                "{} of {hub}'s attestations {} not in its transparency log yet (logged within seconds of a push; checked on a later fetch)",
405
                missing,
406
                if missing == 1 { "is" } else { "are" }
407
            ));
408
        }
409
        Ok(out)
410
    }
411
}
412
413
/// A checkpoint `accept_checkpoint` took: the checkpoint, the log key it
414
/// verified with, its note as served, and what accepting it noted.
415
pub(crate) struct Accepted {
416
    pub checkpoint: Checkpoint,
417
    pub identity: Identity,
418
    pub note: Note,
419
    pub notes: Vec<String>,
420
}
421
422
/// What a refs answer said about its host's log.
423
#[derive(Debug, Default)]
424
pub(crate) struct LogCheck {
425
    /// Whether the host keeps a log (it sent a checkpoint).
426
    pub kept: bool,
427
    /// Why the log could not be checked, when it could not.
428
    pub unchecked: Option<String>,
429
    pub notes: Vec<String>,
430
    /// Each attested bookmark's integrated time, or why it has none.
431
    pub times: HashMap<String, std::result::Result<Integrated, String>>,
432
    /// Each attested tag's, by tag name.
433
    pub tag_times: HashMap<String, std::result::Result<Integrated, String>>,
434
}
435
436
impl LogCheck {
437
    /// Bookmark `name`'s integrated time, or why there is none.
438
    pub fn time_of(&self, name: &str) -> std::result::Result<&Integrated, String> {
439
        self.lookup(&self.times, name)
440
    }
441
442
    /// Tag `name`'s integrated time, or why there is none.
443
    pub fn tag_time_of(&self, name: &str) -> std::result::Result<&Integrated, String> {
444
        self.lookup(&self.tag_times, name)
445
    }
446
447
    fn lookup<'a>(
448
        &self,
449
        times: &'a HashMap<String, std::result::Result<Integrated, String>>,
450
        name: &str,
451
    ) -> std::result::Result<&'a Integrated, String> {
452
        if let Some(why) = &self.unchecked {
453
            return Err(format!("its host's log was not checked ({why})"));
454
        }
455
        if !self.kept {
456
            return Err("its host keeps no transparency log".into());
457
        }
458
        match times.get(name) {
459
            Some(Ok(i)) => Ok(i),
460
            Some(Err(why)) => Err(why.clone()),
461
            None => Err("it has no log entry".into()),
462
        }
463
    }
464
}
465
466
/// How a check that depends on when an attestation was signed came out
467
/// (spec §14.8, *Integrated time*).
468
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
469
pub(crate) enum Timed {
470
    /// Passed at the integrated time.
471
    Integrated,
472
    /// No integrated time: passed by the earlier rule (the issuer's
473
    /// acceptance, else the attestation's own time).
474
    Fallback,
475
    /// Failed at the integrated time but passed by the earlier rule:
476
    /// accepted with a note while logs are new.
477
    Disagrees,
478
    /// Failed.
479
    Refused,
480
}
481
482
/// Judge a check (`at`, made at a time in milliseconds) at the integrated
483
/// time when there is one, else by the earlier rule (`before`).
484
pub(crate) fn judge(
485
    integrated: Option<u64>,
486
    at: impl Fn(u64) -> bool,
487
    before: impl FnOnce() -> bool,
488
) -> Timed {
489
    match integrated {
490
        Some(t) if at(t) => Timed::Integrated,
491
        Some(_) => {
492
            if before() {
493
                Timed::Disagrees
494
            } else {
495
                Timed::Refused
496
            }
497
        }
498
        None => {
499
            if before() {
500
                Timed::Fallback
501
            } else {
502
                Timed::Refused
503
            }
504
        }
505
    }
506
}
507
508
/// A note when an attestation's claimed time (`claimed`, its own clock)
509
/// is more than `tlog::TIME_TOLERANCE_MS` from its integrated time.
510
pub(crate) fn gap_note(claimed: u64, integrated: &Integrated) -> Option<String> {
511
    let t = integrated.ms();
512
    let minutes = |d: u64| d.div_ceil(60_000);
513
    if claimed.saturating_add(tlog::TIME_TOLERANCE_MS) < t {
514
        Some(format!(
515
            "claims to be from {} minutes before witness {} first saw it: backdated, held back before it was logged, or signed on a slow clock",
516
            minutes(t - claimed),
517
            integrated.witness
518
        ))
519
    } else if claimed > t.saturating_add(tlog::TIME_TOLERANCE_MS) {
520
        Some(format!(
521
            "claims to be from {} minutes after witness {} saw it: signed on a fast clock",
522
            minutes(claimed - t),
523
            integrated.witness
524
        ))
525
    } else {
526
        None
527
    }
528
}
529
530
/// A hub's whole log, checked (`oxy tlog verify`).
531
#[derive(Clone, Debug)]
532
pub struct LogAudit {
533
    pub hub: String,
534
    pub checkpoint: Checkpoint,
535
    /// Every entry, numbered from 0.
536
    pub entries: Vec<Entry>,
537
    /// What accepting the checkpoint noted (a new log key, a new name).
538
    pub notes: Vec<String>,
539
    /// The trusted witnesses that cosigned the checkpoint, with their
540
    /// times in seconds, earliest first.
541
    pub witnessed: Vec<(String, u64)>,
542
}
543
544
impl Repo {
545
    /// Fetch the log of `url`'s hub whole: its checkpoint, accepted as a
546
    /// fetch accepts one, and every entry bundle, whose entries must make
547
    /// the root the checkpoint signs.
548
    pub fn audit_log(&self, url: &str, ca: Option<&Path>) -> Result<LogAudit> {
549
        let hub =
550
            hub_of(url).ok_or_else(|| RepoError::Msg(format!("{url} is not a hub's address")))?;
551
        let options = RemoteOptions {
552
            ca: ca.map(Path::to_path_buf),
553
            ..RemoteOptions::default()
554
        };
555
        let tl = |e: TlogError| RepoError::Msg(e.to_string());
556
        let text = String::from_utf8(http_get(&format!("{hub}/log/checkpoint"), &options)?)
557
            .map_err(|_| RepoError::Msg(format!("{hub}'s checkpoint is not text")))?;
558
        let accepted = match self.accept_checkpoint(&hub, &text, &options)? {
559
            Ok(c) => c,
560
            Err(why) => return fail(format!("{hub}'s log could not be checked: {why}")),
561
        };
562
        let mut notes = accepted.notes;
563
        // The witnesses it names (one `<name> <base64 identity>` line
564
        // each), pinned as a fetch pins them; a hub that does not list
565
        // them leaves those pinned before.
566
        let listed: Vec<(String, Vec<u8>)> = http_get(&format!("{hub}/log/witnesses"), &options)
567
            .ok()
568
            .and_then(|b| String::from_utf8(b).ok())
569
            .map(|t| {
570
                t.lines()
571
                    .filter_map(|l| {
572
                        let (n, k) = l.split_once(' ')?;
573
                        Some((n.to_string(), crate::dsse::base64_decode(k.trim())?))
574
                    })
575
                    .collect()
576
            })
577
            .unwrap_or_default();
578
        let witnesses = self.pin_witnesses(&hub, &listed, &mut notes)?;
579
        let mut witnessed = accepted.note.cosignatures(&witnesses);
580
        witnessed.sort_by_key(|(_, t)| *t);
581
        let cp = accepted.checkpoint;
582
        let mut entries = Vec::with_capacity(cp.size as usize);
583
        let mut stored: Vec<Hash> = Vec::new();
584
        let mut n = 0u64;
585
        while n < cp.size {
586
            let t = Tile {
587
                level: 0,
588
                n: n >> tlog::TILE_HEIGHT,
589
                width: (cp.size - n).min(tlog::TILE_WIDTH) as u32,
590
            };
591
            let bytes = http_get(&format!("{hub}/log/{}", t.entries_path()), &options)?;
592
            let bundle = tlog::decode_bundle(&bytes).map_err(tl)?;
593
            if bundle.len() != t.width as usize {
594
                return fail(format!(
595
                    "{hub}'s entry bundle {} holds {} entries, not {}",
596
                    t.entries_path(),
597
                    bundle.len(),
598
                    t.width
599
                ));
600
            }
601
            for b in bundle {
602
                let add =
603
                    tlog::stored_hashes_for_leaf(n, tlog::leaf_hash(&b), &stored).map_err(tl)?;
604
                stored.extend(add);
605
                entries.push(Entry::decode(&b).map_err(tl)?);
606
                n += 1;
607
            }
608
        }
609
        if tlog::tree_hash(cp.size, &stored).map_err(tl)? != cp.root {
610
            return fail(format!(
611
                "{hub} serves entries that do not make the root it signed for {} entries: its log is not the one it attests",
612
                cp.size
613
            ));
614
        }
615
        Ok(LogAudit {
616
            hub,
617
            checkpoint: cp,
618
            entries,
619
            notes,
620
            witnessed,
621
        })
622
    }
623
}