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(¬e.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(¬e.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, ¬e)?; |
|
| 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 |