OXYGENHUB

oxy tlog verify: audit a hub's whole transparency log e59512f2f075

`oxy tlog verify [HUB]` (spec §14.8, oxygenhub#238) accepts the hub's
checkpoint as a fetch does (its pinned log key, extending the last one
seen, a fork failing), downloads every entry bundle and requires the
entries to make the root the checkpoint signs, then lists this
repository's entries, a key's (--key) or all (--all); --json too. The
checkpoint rules are now shared with fetch (accept_checkpoint).

Checked live: oxygenhub.io's 76 entries make its signed root; oxygen's
15 entries listed.

Refs oxygenhub#238
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 7a65d02804e1
When
· 2026-10-18 00:05 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
e59512f2f0756fc294add7dd36d39c558cfd18d5ec5d56c7904d640d62c81f70
Depends on
0ed150d62994 132b884b9a8d 135212de5370 4b9d06778581 4dee277c327a 61e3bce7ea5b 6caacfff1d4f 982864068a59 d42369abf180 ee06a6c557b7 fc1a466a1e66 fce44ae9ad29 ff0af1ee299d ff1f62fee654
Depended on by
284fe47d98b9 642da2636de2 028545dfa7a4 51d4862a16d8 b333a19976e4
In bookmarks
carried-not-removed doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
18 atoms, 1 tree operation, 6 files

Browse the tree at this change

added src/cli/tlog.rs +120 −0
1 +
//! `oxy tlog verify` (spec §14.8, oxygenhub#238): audit a hub's whole
2 +
//! transparency log and list the entries that concern you.
3 +
4 +
use super::json;
5 +
use super::open;
6 +
use super::ui;
7 +
use anyhow::{anyhow, Result};
8 +
use oxy_core::hash::KeyId;
9 +
use std::path::Path;
10 +
11 +
pub fn verify(hub: Option<&str>, key: Option<&str>, all: bool, as_json: bool) -> Result<i32> {
12 +
    let repo = open()?;
13 +
    let remote = repo.default_remote();
14 +
    let url = match hub {
15 +
        Some(h) if h.contains("://") => h.to_string(),
16 +
        Some(h) => format!("https://{h}"),
17 +
        None => repo.config.remotes.get(&remote).cloned().ok_or_else(|| {
18 +
            anyhow!("name the hub to check (this checkout has no remote {remote})")
19 +
        })?,
20 +
    };
21 +
    let ca = repo
22 +
        .config
23 +
        .remote_cas
24 +
        .get(&remote)
25 +
        .map(Path::new)
26 +
        .filter(|_| hub.is_none());
27 +
    let audit = repo.audit_log(&url, ca).map_err(|e| anyhow!("{e}"))?;
28 +
    let key = match key {
29 +
        Some(k) => Some(
30 +
            KeyId::from_hex(k)
31 +
                .ok_or_else(|| anyhow!("--key takes a full KeyId (64 hex digits)"))?,
32 +
        ),
33 +
        None => None,
34 +
    };
35 +
    let rid = repo.id().ok();
36 +
    let listed: Vec<(usize, &oxy_core::tlog::Entry)> = audit
37 +
        .entries
38 +
        .iter()
39 +
        .enumerate()
40 +
        .filter(|(_, e)| match (&key, all) {
41 +
            (_, true) => true,
42 +
            (Some(k), _) => e.signers.contains(k),
43 +
            (None, _) => Some(e.repo) == rid,
44 +
        })
45 +
        .collect();
46 +
    let what = match (&key, all) {
47 +
        (_, true) => "every entry".to_string(),
48 +
        (Some(k), _) => format!("entries signed by {}", k.short(12)),
49 +
        (None, _) => "this repository's entries".to_string(),
50 +
    };
51 +
    if as_json {
52 +
        println!(
53 +
            "{}",
54 +
            json::obj([
55 +
                ("hub", json::s(&audit.hub)),
56 +
                ("origin", json::s(&audit.checkpoint.origin)),
57 +
                ("size", json::n(audit.checkpoint.size)),
58 +
                (
59 +
                    "root",
60 +
                    json::s(&oxy_core::hash::ObjectRef(audit.checkpoint.root).to_hex())
61 +
                ),
62 +
                (
63 +
                    "entries",
64 +
                    json::arr(listed.iter().map(|(i, e)| {
65 +
                        json::obj([
66 +
                            ("index", json::n(*i)),
67 +
                            ("kind", json::s(&e.kind)),
68 +
                            (
69 +
                                "repo",
70 +
                                json::s(
71 +
                                    &e.repo
72 +
                                        .iter()
73 +
                                        .map(|b| format!("{b:02x}"))
74 +
                                        .collect::<String>(),
75 +
                                ),
76 +
                            ),
77 +
                            ("object", json::s(&e.object.to_hex())),
78 +
                            (
79 +
                                "signers",
80 +
                                json::arr(e.signers.iter().map(|k| json::s(&k.to_hex()))),
81 +
                            ),
82 +
                            ("received_ms", json::n(e.received)),
83 +
                        ])
84 +
                    })),
85 +
                ),
86 +
            ])
87 +
        );
88 +
        return Ok(0);
89 +
    }
90 +
    println!(
91 +
        "{} {}: {} entries make the root its log key signed, and extend what you saw before",
92 +
        ui::done("Verified"),
93 +
        ui::path(&audit.checkpoint.origin),
94 +
        audit.checkpoint.size
95 +
    );
96 +
    if listed.is_empty() {
97 +
        println!("  {} {}", ui::note(), ui::dim(format!("no {what} in it")));
98 +
        return Ok(0);
99 +
    }
100 +
    println!(
101 +
        "  {} {}",
102 +
        ui::note(),
103 +
        ui::dim(format!("{} of {what}:", listed.len()))
104 +
    );
105 +
    for (i, e) in listed {
106 +
        let signers: Vec<String> = e.signers.iter().map(|k| k.short(12)).collect();
107 +
        println!(
108 +
            "  #{i:<6} {:<11} {} {} {}",
109 +
            e.kind,
110 +
            ui::hash(e.object.short(12)),
111 +
            ui::dim(if signers.is_empty() {
112 +
                "unsigned".to_string()
113 +
            } else {
114 +
                format!("by {}", signers.join(", "))
115 +
            }),
116 +
            ui::dim(ui::ago(e.received))
117 +
        );
118 +
    }
119 +
    Ok(0)
120 +
}
modified crates/oxy-core/src/repo/mod.rs +1 −0
…
158 unchanged lines
159 159
pub use subrepos::{SubrepoInfo, SubrepoSync};
160 160
pub use sync::{FetchReport, PullReport, PushReport, RefUpdated, OBJECTS_RESULT};
161 161
pub use tags::TagInfo;
162 +
pub use transparency::LogAudit;
162 163
pub use workspace::{FileLock, SnapshotLock, WorkingCopyLock, WorkspaceInfo};
163 164
164 165
/// The file name under which per-bookmark or per-tag data is kept: `/`
…
2526 unchanged lines
modified crates/oxy-core/src/repo/transparency.rs +112 −27
…
104 unchanged lines
105 105
        Ok(())
106 106
    }
107 107
108 -
    /// Check what a refs answer from `url` says about its host's log.
109 -
    /// Fails only when the host showed two histories (or a log key other
110 -
    /// than the one it showed before); everything else is a note.
111 -
    pub(crate) fn check_log(
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(
112 113
        &self,
113 -
        url: &str,
114 -
        ca: Option<&Path>,
115 -
        resp: &RefsResponse,
116 -
    ) -> Result<Vec<String>> {
117 -
        let (Some(text), Some(hub)) = (&resp.log, hub_of(url)) else {
118 -
            return Ok(vec![]);
119 -
        };
120 -
        let mut notes = Vec::new();
121 -
        let unchecked = |why: String| {
122 -
            Ok(vec![format!(
123 -
                "{hub}'s transparency log was not checked: {why}"
124 -
            )])
125 -
        };
114 +
        hub: &str,
115 +
        text: &str,
116 +
        options: &RemoteOptions,
117 +
    ) -> Result<std::result::Result<Checkpoint, String>> {
118 +
        let unchecked = |why: String| Ok(Err(why));
126 119
        let note = match Note::parse(text) {
127 120
            Ok(n) => n,
128 121
            Err(e) => return unchecked(e.to_string()),
…
2 unchanged lines
131 124
            Ok(c) => c,
132 125
            Err(e) => return unchecked(e.to_string()),
133 126
        };
134 -
        let options = RemoteOptions {
135 -
            ca: ca.map(Path::to_path_buf),
136 -
            ..RemoteOptions::default()
137 -
        };
138 -
        let seen = self.seen_log(&hub);
127 +
        let seen = self.seen_log(hub);
139 128
        let identity = match &seen {
140 129
            Some(s) => s.identity.clone(),
141 -
            None => match http_get(&format!("{hub}/.well-known/oxyhub-key"), &options)
130 +
            None => match http_get(&format!("{hub}/.well-known/oxyhub-key"), options)
142 131
                .and_then(|b| Ok(Identity::decode(&b)?))
143 132
            {
144 133
                Ok(i) => i,
…
4 unchanged lines
149 138
            fail(format!(
150 139
                "{hub} showed two histories: {why}. Do not trust what it serves; tell its operator. \
151 140
                 (What this user saw of its log is in {}.)",
152 -
                self.tlog_file(&hub).display()
141 +
                self.tlog_file(hub).display()
153 142
            ))
154 143
        };
155 144
        if let Err(e) = note.verify(&cp.origin, &identity) {
…
20 unchanged lines
176 165
            }
177 166
            if cp.size > old.size && old.size > 0 {
178 167
                let tiles = Tiles {
179 -
                    hub: &hub,
168 +
                    hub,
180 169
                    size: cp.size,
181 -
                    options: &options,
170 +
                    options,
182 171
                    cache: RefCell::new(HashMap::new()),
183 172
                };
184 173
                match tlog::prove_consistency(cp.size, old.size, &tiles).and_then(|p| {
…
11 unchanged lines
196 185
                }
197 186
            }
198 187
        }
199 -
        self.remember_log(&hub, &identity, &note)?;
188 +
        self.remember_log(hub, &identity, &note)?;
189 +
        Ok(Ok(cp))
190 +
    }
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.
195 +
    pub(crate) fn check_log(
196 +
        &self,
197 +
        url: &str,
198 +
        ca: Option<&Path>,
199 +
        resp: &RefsResponse,
200 +
    ) -> Result<Vec<String>> {
201 +
        let (Some(text), Some(hub)) = (&resp.log, hub_of(url)) else {
202 +
            return Ok(vec![]);
203 +
        };
204 +
        let options = RemoteOptions {
205 +
            ca: ca.map(Path::to_path_buf),
206 +
            ..RemoteOptions::default()
207 +
        };
208 +
        let cp = match self.accept_checkpoint(&hub, text, &options)? {
209 +
            Ok(cp) => cp,
210 +
            Err(why) => {
211 +
                return Ok(vec![format!(
212 +
                    "{hub}'s transparency log was not checked: {why}"
213 +
                )])
214 +
            }
215 +
        };
216 +
        let mut notes = Vec::new();
200 217
        let mut missing = 0;
201 218
        for r in &resp.refs {
202 219
            let Some(a) = &r.attestation else { continue };
…
28 unchanged lines
231 248
            ));
232 249
        }
233 250
        Ok(notes)
251 +
    }
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.
267 +
    pub fn audit_log(&self, url: &str, ca: Option<&Path>) -> Result<LogAudit> {
268 +
        let hub =
269 +
            hub_of(url).ok_or_else(|| RepoError::Msg(format!("{url} is not a hub's address")))?;
270 +
        let options = RemoteOptions {
271 +
            ca: ca.map(Path::to_path_buf),
272 +
            ..RemoteOptions::default()
273 +
        };
274 +
        let tl = |e: TlogError| RepoError::Msg(e.to_string());
275 +
        let text = String::from_utf8(http_get(&format!("{hub}/log/checkpoint"), &options)?)
276 +
            .map_err(|_| RepoError::Msg(format!("{hub}'s checkpoint is not text")))?;
277 +
        let cp = match self.accept_checkpoint(&hub, &text, &options)? {
278 +
            Ok(c) => c,
279 +
            Err(why) => return fail(format!("{hub}'s log could not be checked: {why}")),
280 +
        };
281 +
        let mut entries = Vec::with_capacity(cp.size as usize);
282 +
        let mut stored: Vec<Hash> = Vec::new();
283 +
        let mut n = 0u64;
284 +
        while n < cp.size {
285 +
            let t = Tile {
286 +
                level: 0,
287 +
                n: n >> tlog::TILE_HEIGHT,
288 +
                width: (cp.size - n).min(tlog::TILE_WIDTH) as u32,
289 +
            };
290 +
            let bytes = http_get(&format!("{hub}/log/{}", t.entries_path()), &options)?;
291 +
            let bundle = tlog::decode_bundle(&bytes).map_err(tl)?;
292 +
            if bundle.len() != t.width as usize {
293 +
                return fail(format!(
294 +
                    "{hub}'s entry bundle {} holds {} entries, not {}",
295 +
                    t.entries_path(),
296 +
                    bundle.len(),
297 +
                    t.width
298 +
                ));
299 +
            }
300 +
            for b in bundle {
301 +
                let add =
302 +
                    tlog::stored_hashes_for_leaf(n, tlog::leaf_hash(&b), &stored).map_err(tl)?;
303 +
                stored.extend(add);
304 +
                entries.push(Entry::decode(&b).map_err(tl)?);
305 +
                n += 1;
306 +
            }
307 +
        }
308 +
        if tlog::tree_hash(cp.size, &stored).map_err(tl)? != cp.root {
309 +
            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 +
            ));
313 +
        }
314 +
        Ok(LogAudit {
315 +
            hub,
316 +
            checkpoint: cp,
317 +
            entries,
318 +
        })
234 319
    }
235 320
}
modified docs/guide.md +10 −0
…
695 unchanged lines
696 696
```
697 697
698 698
The oh-my-bash plugin in `contrib/oh-my-bash` loads the bash completions itself.
699 +
700 +
## Transparency logs
701 +
702 +
A hub that keeps a transparency log (OxygenHub does, at `/log`) logs every signed object it accepts. `oxy fetch` and `oxy pull` check it as they go. They remember the log's key and its last checkpoint, and fail if the hub ever shows a log that does not extend what you saw before. That is the sign of a hub showing different histories to different people. To audit a hub's whole log and see what it holds for this repository:
703 +
704 +
```
705 +
oxy tlog verify                         # this checkout's hub
706 +
oxy tlog verify https://oxygenhub.io --all
707 +
oxy tlog verify --key <KeyId>           # what a key signed
708 +
```
modified docs/spec.md +2 −0
…
787 unchanged lines
788 788
- requires every later checkpoint to verify with that key, keep its origin, and extend the last one seen: a smaller tree, the same size with another root, or a consistency proof that fails (computed from the host's tiles) means the host showed two histories, and the fetch fails;
789 789
- checks each bookmark's attestation against its entry (kind `ref`, the attestation's ObjectRef and repository id) and inclusion proof, reporting one that is missing or does not verify. While logs are new this is a warning, not a refusal.
790 790
791 +
**`oxy tlog verify [HUB]`** audits a whole log: it accepts the checkpoint as a fetch does, downloads every entry bundle, and requires the entries to make the root the checkpoint signs. It then lists this repository's entries (`--key KEYID` for a key's, `--all` for every one, `--json` for machines).
792 +
791 793
## 15. Transport
792 794
793 795
Every transport carries the same wire protocol (§16). The transport provides the channel; integrity and authenticity of history come from the object model regardless of channel.
…
343 unchanged lines
modified src/cli/mod.rs +35 −0
…
7 unchanged lines
8 8
mod proposal;
9 9
mod receipt;
10 10
mod sync;
11 +
mod tlog;
11 12
pub mod ui;
12 13
mod workspace;
13 14
…
358 unchanged lines
372 373
        #[command(subcommand)]
373 374
        cmd: SubmoduleCmd,
374 375
    },
376 +
    /// Hubs' transparency logs: check that a hub shows everyone the same
377 +
    /// history (spec §14.8)
378 +
    Tlog {
379 +
        #[command(subcommand)]
380 +
        cmd: TlogCmd,
381 +
    },
375 382
    /// Execution receipts: signed records of what a runner ran on a state
376 383
    Receipt {
377 384
        #[command(subcommand)]
…
281 unchanged lines
659 666
        /// versions of the files they touch
660 667
        #[arg(long, conflicts_with = "discard")]
661 668
        carry: bool,
669 +
    },
670 +
}
671 +
672 +
#[derive(Subcommand)]
673 +
enum TlogCmd {
674 +
    /// Check a hub's whole log: its checkpoint against the log key and the
675 +
    /// last checkpoint seen, and every entry against the root it signed;
676 +
    /// then list this repository's entries (or a key's, or all)
677 +
    Verify {
678 +
        /// The hub, as https://oxygenhub.io (default: this checkout's remote)
679 +
        hub: Option<String>,
680 +
        /// List the entries a key signed (a KeyId) instead
681 +
        #[arg(long, conflicts_with = "all")]
682 +
        key: Option<String>,
683 +
        /// List every entry
684 +
        #[arg(long)]
685 +
        all: bool,
686 +
        /// Machine-readable output
687 +
        #[arg(long)]
688 +
        json: bool,
662 689
    },
663 690
}
664 691
…
634 unchanged lines
1299 1326
            SubmoduleCmd::Update { init, paths } => sync::submodule_update(init, &paths),
1300 1327
            SubmoduleCmd::Status { json } => sync::submodule_status(json),
1301 1328
            SubmoduleCmd::Remove { path } => sync::submodule_remove(&path),
1329 +
        },
1330 +
        Cmd::Tlog { cmd } => match cmd {
1331 +
            TlogCmd::Verify {
1332 +
                hub,
1333 +
                key,
1334 +
                all,
1335 +
                json,
1336 +
            } => tlog::verify(hub.as_deref(), key.as_deref(), all, json),
1302 1337
        },
1303 1338
        Cmd::Receipt { cmd } => match cmd {
1304 1339
            ReceiptCmd::List { rev, json } => receipt::list(rev.as_deref(), json),
…
206 unchanged lines