OXYGENHUB

Fetch checks the host's transparency log (spec §14.8) 4b9d06778581

A refs answer may carry the host's latest checkpoint (key 6) and, per
bookmark, its attestation's entry and inclusion proof (ref key 6); older
peers ignore both, and hosts add them through a new Backend::add_log hook
in oxy-server. oxy fetch now:
- pins the hub's log key on first sight (from /.well-known/oxyhub-key)
  in <oxy home>/tlog/<hub>, with the last checkpoint seen;
- requires every new checkpoint to be signed by that key and to extend
  the last one: older, same size with another root, or a failed
  consistency proof (computed from the hub's public tiles) fails the
  fetch, saying the hub showed two histories;
- checks each attestation's entry and inclusion proof, and only notes
  one not logged yet or not proved (decision 3: warn while the log is
  new); FetchReport.log_notes, printed by oxy fetch/pull and in --json.

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 5506938b5bcd
When
· 2026-10-17 23:46 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
4b9d06778581f6d3350902c6840c37353035026d0f14e1c843385082b84830ff
Depends on
132b884b9a8d 14edbaaf5291 170bb436e1d5 5b9c6ad45f6e 750251b21a96 75167672ebcd 76740263089c 83e0677b07fe 982864068a59 c6aa5bd9d1c8 f53cbe7a204e f8c695764c95 fce44ae9ad29
Depended on by
2f0b4b0651ea b63fc7a77fc6 ee130dd9eef2 1ec6fdbc6bcb 67a4fe1aa2d5 d36ba2f79f95 028545dfa7a4 b333a19976e4 e59512f2f075
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
28 atoms, 1 tree operation, 6 files

Browse the tree at this change

added crates/oxy-core/src/repo/transparency.rs +235 −0
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 +
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(
112 +
        &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 +
        };
126 +
        let note = match Note::parse(text) {
127 +
            Ok(n) => n,
128 +
            Err(e) => return unchecked(e.to_string()),
129 +
        };
130 +
        let cp = match Checkpoint::parse(&note.text) {
131 +
            Ok(c) => c,
132 +
            Err(e) => return unchecked(e.to_string()),
133 +
        };
134 +
        let options = RemoteOptions {
135 +
            ca: ca.map(Path::to_path_buf),
136 +
            ..RemoteOptions::default()
137 +
        };
138 +
        let seen = self.seen_log(&hub);
139 +
        let identity = match &seen {
140 +
            Some(s) => s.identity.clone(),
141 +
            None => match http_get(&format!("{hub}/.well-known/oxyhub-key"), &options)
142 +
                .and_then(|b| Ok(Identity::decode(&b)?))
143 +
            {
144 +
                Ok(i) => i,
145 +
                Err(e) => return unchecked(format!("its key could not be fetched ({e})")),
146 +
            },
147 +
        };
148 +
        let two = |why: String| {
149 +
            fail(format!(
150 +
                "{hub} showed two histories: {why}. Do not trust what it serves; tell its operator. \
151 +
                 (What this user saw of its log is in {}.)",
152 +
                self.tlog_file(&hub).display()
153 +
            ))
154 +
        };
155 +
        if let Err(e) = note.verify(&cp.origin, &identity) {
156 +
            return match &seen {
157 +
                Some(_) => two(format!(
158 +
                    "its checkpoint is not signed by the log key it showed before ({e})"
159 +
                )),
160 +
                None => unchecked(e.to_string()),
161 +
            };
162 +
        }
163 +
        if let Some(s) = &seen {
164 +
            let old = &s.checkpoint;
165 +
            if old.origin != cp.origin {
166 +
                return two(format!("its log is now {}, not {}", cp.origin, old.origin));
167 +
            }
168 +
            if cp.size < old.size {
169 +
                return two(format!(
170 +
                    "its log has {} entries, fewer than the {} it showed before",
171 +
                    cp.size, old.size
172 +
                ));
173 +
            }
174 +
            if cp.size == old.size && cp.root != old.root {
175 +
                return two(format!("two different logs of {} entries", cp.size));
176 +
            }
177 +
            if cp.size > old.size && old.size > 0 {
178 +
                let tiles = Tiles {
179 +
                    hub: &hub,
180 +
                    size: cp.size,
181 +
                    options: &options,
182 +
                    cache: RefCell::new(HashMap::new()),
183 +
                };
184 +
                match tlog::prove_consistency(cp.size, old.size, &tiles).and_then(|p| {
185 +
                    tlog::check_consistency(&p, cp.size, &cp.root, old.size, &old.root)
186 +
                }) {
187 +
                    Ok(()) => {}
188 +
                    Err(TlogError::ProofFailed) | Err(TlogError::BadIndex(_)) => {
189 +
                        return two(format!(
190 +
                            "its log of {} entries does not extend the one of {} it showed before",
191 +
                            cp.size, old.size
192 +
                        ))
193 +
                    }
194 +
                    // Keep the older checkpoint: the next fetch tries again.
195 +
                    Err(e) => return unchecked(format!("its tiles could not be read ({e})")),
196 +
                }
197 +
            }
198 +
        }
199 +
        self.remember_log(&hub, &identity, &note)?;
200 +
        let mut missing = 0;
201 +
        for r in &resp.refs {
202 +
            let Some(a) = &r.attestation else { continue };
203 +
            let Some(l) = &r.logged else {
204 +
                missing += 1;
205 +
                continue;
206 +
            };
207 +
            let fits = Entry::decode(&l.entry).is_ok_and(|e| {
208 +
                e.kind == "ref" && e.object == a.attest.hash() && e.repo == a.attest.repo
209 +
            });
210 +
            let included = fits
211 +
                && tlog::check_inclusion(
212 +
                    &l.proof,
213 +
                    cp.size,
214 +
                    &cp.root,
215 +
                    l.index,
216 +
                    &tlog::leaf_hash(&l.entry),
217 +
                )
218 +
                .is_ok();
219 +
            if !included {
220 +
                notes.push(format!(
221 +
                    "{hub}'s log entry {} for {} does not prove its attestation is in the log",
222 +
                    l.index, r.name
223 +
                ));
224 +
            }
225 +
        }
226 +
        if missing > 0 {
227 +
            notes.push(format!(
228 +
                "{} of {hub}'s attestations {} not in its transparency log yet (logged within seconds of a push; checked on a later fetch)",
229 +
                missing,
230 +
                if missing == 1 { "is" } else { "are" }
231 +
            ));
232 +
        }
233 +
        Ok(notes)
234 +
    }
235 +
}
modified crates/oxy-core/src/protocol.rs +79 −5
…
55 unchanged lines
56 56
    pub history: Vec<SignedPolicy>,
57 57
    /// The bookmark a clone checks out (spec §16.1), when the remote names one.
58 58
    pub default_bookmark: Option<String>,
59 +
    /// The host's latest transparency-log checkpoint, a signed note (spec
60 +
    /// §14.8), against which `RemoteRef::logged` proofs are made (key 6,
61 +
    /// only when the host keeps a log).
62 +
    pub log: Option<String>,
59 63
}
60 64
65 +
/// Where a reference attestation sits in the host's transparency log
66 +
/// (spec §14.8): its entry number, the entry, and the inclusion proof
67 +
/// against the checkpoint served beside it.
68 +
#[derive(Clone, Debug, PartialEq, Eq)]
69 +
pub struct Logged {
70 +
    pub index: u64,
71 +
    pub entry: Vec<u8>,
72 +
    pub proof: Vec<[u8; 32]>,
73 +
}
74 +
61 75
/// A tag as a remote reports it (spec §10.1).
62 76
#[derive(Clone, Debug, PartialEq, Eq)]
63 77
pub struct TagRef {
…
18 unchanged lines
82 96
    /// The host's acceptance of `attestation` when a short-lived key signed
83 97
    /// it (spec §14.7): the time its certificate is checked at.
84 98
    pub accepted: Option<SignedAttest>,
99 +
    /// The attestation's place in the host's transparency log (key 6, only
100 +
    /// when it has one).
101 +
    pub logged: Option<Logged>,
85 102
}
86 103
87 104
/// Everything needed to add a set of changes to a repository.
…
825 unchanged lines
913 930
    }
914 931
915 932
    pub fn encode_refs(id: [u8; 16], resp: &RefsResponse) -> Vec<u8> {
916 -
        cbor::encode(&Value::map([
933 +
        let mut v = Value::map([
917 934
            (
918 935
                0,
919 936
                Value::Array(
…
8 unchanged lines
928 945
                                (4, r.last_serial.map(Value::Uint).unwrap_or(Value::Null)),
929 946
                            ]);
930 947
                            // Only when there is one; older peers ignore it.
931 -
                            if r.accepted.is_some() {
932 -
                                if let Value::Map(m) = &mut v {
948 +
                            if let Value::Map(m) = &mut v {
949 +
                                if r.accepted.is_some() {
933 950
                                    m.insert(5, opt_attest_value(&r.accepted));
951 +
                                }
952 +
                                if let Some(l) = &r.logged {
953 +
                                    m.insert(
954 +
                                        6,
955 +
                                        Value::Array(vec![
956 +
                                            Value::Uint(l.index),
957 +
                                            Value::Bytes(l.entry.clone()),
958 +
                                            Value::Array(
959 +
                                                l.proof
960 +
                                                    .iter()
961 +
                                                    .map(|h| Value::Bytes(h.to_vec()))
962 +
                                                    .collect(),
963 +
                                            ),
964 +
                                        ]),
965 +
                                    );
934 966
                                }
935 967
                            }
936 968
                            v
…
15 unchanged lines
952 984
                    .map(Value::Text)
953 985
                    .unwrap_or(Value::Null),
954 986
            ),
955 -
        ]))
987 +
        ]);
988 +
        if let (Some(log), Value::Map(m)) = (&resp.log, &mut v) {
989 +
            m.insert(6, Value::Text(log.clone()));
990 +
        }
991 +
        cbor::encode(&v)
956 992
    }
957 993
    pub fn decode_refs(bytes: &[u8]) -> Result<([u8; 16], RefsResponse)> {
958 994
        let v = cbor::decode(bytes).map_err(ObjectError::from)?;
…
17 unchanged lines
976 1012
                },
977 1013
                accepted: match rm.get(&5) {
978 1014
                    Some(a) => opt_attest_from(a)?,
1015 +
                    None => None,
1016 +
                },
1017 +
                logged: match rm.get(&6) {
1018 +
                    Some(l) => {
1019 +
                        let a = o::array(l, "logged")?;
1020 +
                        if a.len() != 3 {
1021 +
                            return Err(schema("logged: expected [index, entry, proof]"));
1022 +
                        }
1023 +
                        let mut proof = Vec::new();
1024 +
                        for h in o::array(&a[2], "proof")? {
1025 +
                            proof.push(
1026 +
                                o::bytes(h, "proof hash")?
1027 +
                                    .try_into()
1028 +
                                    .map_err(|_| schema("proof hash: expected 32 bytes"))?,
1029 +
                            );
1030 +
                        }
1031 +
                        Some(Logged {
1032 +
                            index: o::uint(&a[0], "index")?,
1033 +
                            entry: o::bytes(&a[1], "entry")?.to_vec(),
1034 +
                            proof,
1035 +
                        })
1036 +
                    }
979 1037
                    None => None,
980 1038
                },
981 1039
            });
…
13 unchanged lines
995 1053
            }
996 1054
        }
997 1055
        let default_bookmark = match m.get(&5) {
1056 +
            Some(Value::Text(t)) => Some(t.clone()),
1057 +
            _ => None,
1058 +
        };
1059 +
        let log = match m.get(&6) {
998 1060
            Some(Value::Text(t)) => Some(t.clone()),
999 1061
            _ => None,
1000 1062
        };
…
5 unchanged lines
1006 1068
                tags,
1007 1069
                history,
1008 1070
                default_bookmark,
1071 +
                log,
1009 1072
            },
1010 1073
        ))
1011 1074
    }
…
247 unchanged lines
1259 1322
                }),
1260 1323
                last_serial: Some(4),
1261 1324
                accepted: None,
1325 +
                logged: None,
1262 1326
            },
1263 1327
            RemoteRef {
1264 1328
                name: "dev".into(),
…
2 unchanged lines
1267 1331
                attestation: None,
1268 1332
                last_serial: None,
1269 1333
                accepted: None,
1334 +
                logged: None,
1270 1335
            },
1271 1336
        ];
1272 1337
        let tag = TagRef {
…
2 unchanged lines
1275 1340
            heads: vec![c.hash()],
1276 1341
            attestation: None,
1277 1342
        };
1278 -
        let resp = RefsResponse {
1343 +
        let mut resp = RefsResponse {
1279 1344
            refs,
1280 1345
            policy: Some(SignedPolicy {
1281 1346
                policy: policy.clone(),
…
5 unchanged lines
1287 1352
                sigs: vec![],
1288 1353
            }],
1289 1354
            default_bookmark: Some("develop".into()),
1355 +
            log: Some("hub.example/log\n2\nroot\n\n- sig\n".into()),
1290 1356
        };
1357 +
        resp.refs[1].logged = Some(Logged {
1358 +
            index: 41,
1359 +
            entry: vec![1, 2, 3],
1360 +
            proof: vec![[9; 32], [8; 32]],
1361 +
        });
1291 1362
        let (id_back, back) = wire::decode_refs(&wire::encode_refs([3; 16], &resp)).unwrap();
1363 +
        assert_eq!(back.log, resp.log);
1364 +
        assert_eq!(back.refs[1].logged, resp.refs[1].logged);
1365 +
        assert_eq!(back.refs[0].logged, None);
1292 1366
        assert_eq!(id_back, [3; 16]);
1293 1367
        assert_eq!(back.refs.len(), 2);
1294 1368
        assert_eq!(back.refs[0].attestation.as_ref().unwrap().attest, att);
…
75 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +1 −0
…
128 unchanged lines
129 129
#[cfg(test)]
130 130
mod tests;
131 131
mod touched;
132 +
mod transparency;
132 133
mod walk;
133 134
mod workspace;
134 135
…
2168 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +13 −0
…
29 unchanged lines
30 30
    /// The remote has never presented a policy, so nothing was verified
31 31
    /// against one (spec §14.5: warn loudly).
32 32
    pub unverified: bool,
33 +
    /// What checking the host's transparency log found short of a fork
34 +
    /// (spec §14.8): attestations not logged yet, a log that could not be
35 +
    /// read. A fork fails the fetch instead.
36 +
    pub log_notes: Vec<String>,
33 37
}
34 38
35 39
#[derive(Debug, Clone, PartialEq, Eq)]
…
60 unchanged lines
96 100
                attestation,
97 101
                last_serial,
98 102
                accepted,
103 +
                logged: None,
99 104
            });
100 105
        }
101 106
        let policy = self.current_policy()?.map(|(_, sp)| sp);
…
12 unchanged lines
114 119
            tags: self.tag_refs()?,
115 120
            history,
116 121
            default_bookmark: Some(self.config.bookmark.clone()),
122 +
            log: None,
117 123
        })
118 124
    }
119 125
…
1261 unchanged lines
1381 1387
        let remote = self.open_named_remote(remote_name)?;
1382 1388
        self.check_remote_id(remote.as_ref(), remote_name, false)?;
1383 1389
        let resp = remote.refs()?;
1390 +
        // The host's transparency log (spec §14.8): a fork fails here.
1391 +
        let log_notes = self.check_log(
1392 +
            &self.remote_url(remote_name)?,
1393 +
            self.config.remote_cas.get(remote_name).map(Path::new),
1394 +
            &resp,
1395 +
        )?;
1384 1396
        // Trust policy (spec §14.5). Adopt the remote's policy when it is newer
1385 1397
        // and signed by roots we trust, or on first contact. A remote that
1386 1398
        // once presented a policy may never present none or an older one:
…
174 unchanged lines
1561 1573
            refs: report,
1562 1574
            unverified,
1563 1575
            new_tags,
1576 +
            log_notes,
1564 1577
        })
1565 1578
    }
1566 1579
…
649 unchanged lines
modified crates/oxy-server/src/lib.rs +6 −0
…
63 unchanged lines
64 64
    /// between, and the changes stored, for logs and cache invalidation.
65 65
    fn after_push(&self, _name: &str, _report: &oxy_core::repo::PushReport, _headers: &HeaderMap) {}
66 66
67 +
    /// Add the host's transparency log to a refs answer (spec §14.8): its
68 +
    /// checkpoint, and each attestation's entry and inclusion proof. A host
69 +
    /// without a log leaves it as it is.
70 +
    fn add_log(&self, _name: &str, _refs: &mut oxy_core::protocol::RefsResponse) {}
71 +
67 72
    /// The bookmark of `name` that pushes may never delete (its default),
68 73
    /// when the host keeps one apart from the repository's own config.
69 74
    fn default_bookmark(&self, _name: &str) -> Option<String> {
…
181 unchanged lines
251 256
                if let Some(d) = self.backend.default_bookmark(name) {
252 257
                    refs.default_bookmark = Some(d);
253 258
                }
259 +
                self.backend.add_log(name, &mut refs);
254 260
                ok(wire::encode_refs(r.id()?, &refs))
255 261
            }
256 262
            Endpoint::Negotiate => {
…
185 unchanged lines
modified src/cli/sync.rs +7 −0
…
219 unchanged lines
220 220
                ),
221 221
                ("new_tags", json::n(report.new_tags)),
222 222
                ("unverified", json::b(report.unverified)),
223 +
                (
224 +
                    "log_notes",
225 +
                    json::arr(report.log_notes.iter().map(|n| json::s(n))),
226 +
                ),
223 227
            ])
224 228
        );
225 229
        return Ok(0);
…
55 unchanged lines
281 285
            "{} {remote} has no trust policy; nothing fetched from it was verified",
282 286
            ui::caution()
283 287
        );
288 +
    }
289 +
    for n in &report.log_notes {
290 +
        eprintln!("{} {n}", ui::caution());
284 291
    }
285 292
    Ok(0)
286 293
}
…
631 unchanged lines