OXYGENHUB

A hub's new log key is a note, not a fork b333a19976e4

A checkpoint signed by another key than the one pinned failed the fetch
as "two histories". A hub given a new key, a hub at a reused address
(the hub's CI hit this: tests reuse ports), or a hub renamed is not
proof of a fork, so while logs are new (decision 3) it is a loud note
and the new key is pinned. A smaller log, another root at the same
size, or a failed consistency proof under the same key still fail.
oxy tlog verify prints the notes.

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 30d74d6d9d88
When
· 2026-10-18 00:10 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
b333a19976e41c7d94a2869e7ae5a4306fd4196c76f10bc1ca9b27557f3869f0
Depends on
4b9d06778581 e59512f2f075
Depended on by
028545dfa7a4
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
15 atoms, 0 tree operations, 2 files

Browse the tree at this change

modified crates/oxy-core/src/repo/transparency.rs +48 −28
…
113 unchanged lines
114 114
        hub: &str,
115 115
        text: &str,
116 116
        options: &RemoteOptions,
117 -
    ) -> Result<std::result::Result<Checkpoint, String>> {
117 +
    ) -> Result<std::result::Result<(Checkpoint, Vec<String>), String>> {
118 118
        let unchecked = |why: String| Ok(Err(why));
119 119
        let note = match Note::parse(text) {
120 120
            Ok(n) => n,
…
3 unchanged lines
124 124
            Ok(c) => c,
125 125
            Err(e) => return unchecked(e.to_string()),
126 126
        };
127 -
        let seen = self.seen_log(hub);
128 -
        let identity = match &seen {
129 -
            Some(s) => s.identity.clone(),
130 -
            None => match http_get(&format!("{hub}/.well-known/oxyhub-key"), options)
127 +
        let fetch_key = || {
128 +
            http_get(&format!("{hub}/.well-known/oxyhub-key"), options)
131 129
                .and_then(|b| Ok(Identity::decode(&b)?))
132 -
            {
130 +
        };
131 +
        let mut notes = Vec::new();
132 +
        let mut seen = self.seen_log(hub);
133 +
        let mut identity = match &seen {
134 +
            Some(s) => s.identity.clone(),
135 +
            None => match fetch_key() {
133 136
                Ok(i) => i,
134 137
                Err(e) => return unchecked(format!("its key could not be fetched ({e})")),
135 138
            },
136 139
        };
140 +
        if let Err(e) = note.verify(&cp.origin, &identity) {
141 +
            // Signed by another key than the one pinned: a hub given a new
142 +
            // key, or one starting another log. Not proof of a fork, so a
143 +
            // loud note while logs are new (decision 3), and the new key is
144 +
            // pinned; a fork under one key is what fails.
145 +
            let Some(old) = &seen else {
146 +
                return unchecked(e.to_string());
147 +
            };
148 +
            match fetch_key() {
149 +
                Ok(new) if note.verify(&cp.origin, &new).is_ok() => {
150 +
                    notes.push(format!(
151 +
                        "{hub}'s log is signed by another key than before ({} now, {} then): if its operator did not announce a new key, it may be showing others a different log",
152 +
                        new.hash().short(12),
153 +
                        old.identity.hash().short(12)
154 +
                    ));
155 +
                    identity = new;
156 +
                    seen = None;
157 +
                }
158 +
                _ => return unchecked(format!("its checkpoint does not verify ({e})")),
159 +
            }
160 +
        }
137 161
        let two = |why: String| {
138 162
            fail(format!(
139 163
                "{hub} showed two histories: {why}. Do not trust what it serves; tell its operator. \
…
1 unchanged lines
141 165
                self.tlog_file(hub).display()
142 166
            ))
143 167
        };
144 -
        if let Err(e) = note.verify(&cp.origin, &identity) {
145 -
            return match &seen {
146 -
                Some(_) => two(format!(
147 -
                    "its checkpoint is not signed by the log key it showed before ({e})"
148 -
                )),
149 -
                None => unchecked(e.to_string()),
150 -
            };
151 -
        }
152 168
        if let Some(s) = &seen {
153 169
            let old = &s.checkpoint;
154 170
            if old.origin != cp.origin {
155 -
                return two(format!("its log is now {}, not {}", cp.origin, old.origin));
156 -
            }
157 -
            if cp.size < old.size {
171 +
                // A hub moved to another name keeps its key: a new log as
172 +
                // far as this user can tell, said so.
173 +
                notes.push(format!(
174 +
                    "{hub}'s log is now {}, not {}: checking it afresh",
175 +
                    cp.origin, old.origin
176 +
                ));
177 +
            } else if cp.size < old.size {
158 178
                return two(format!(
159 179
                    "its log has {} entries, fewer than the {} it showed before",
160 180
                    cp.size, old.size
161 181
                ));
162 -
            }
163 -
            if cp.size == old.size && cp.root != old.root {
182 +
            } else if cp.size == old.size && cp.root != old.root {
164 183
                return two(format!("two different logs of {} entries", cp.size));
165 -
            }
166 -
            if cp.size > old.size && old.size > 0 {
184 +
            } else if cp.size > old.size && old.size > 0 {
167 185
                let tiles = Tiles {
168 186
                    hub,
169 187
                    size: cp.size,
…
16 unchanged lines
186 204
            }
187 205
        }
188 206
        self.remember_log(hub, &identity, &note)?;
189 -
        Ok(Ok(cp))
207 +
        Ok(Ok((cp, notes)))
190 208
    }
191 209
192 210
    /// 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.
211 +
    /// Fails only when the host showed two histories under one key;
212 +
    /// everything else is a note.
195 213
    pub(crate) fn check_log(
196 214
        &self,
197 215
        url: &str,
…
7 unchanged lines
205 223
            ca: ca.map(Path::to_path_buf),
206 224
            ..RemoteOptions::default()
207 225
        };
208 -
        let cp = match self.accept_checkpoint(&hub, text, &options)? {
209 -
            Ok(cp) => cp,
226 +
        let (cp, mut notes) = match self.accept_checkpoint(&hub, text, &options)? {
227 +
            Ok(c) => c,
210 228
            Err(why) => {
211 229
                return Ok(vec![format!(
212 230
                    "{hub}'s transparency log was not checked: {why}"
213 231
                )])
214 232
            }
215 233
        };
216 -
        let mut notes = Vec::new();
217 234
        let mut missing = 0;
218 235
        for r in &resp.refs {
219 236
            let Some(a) = &r.attestation else { continue };
…
38 unchanged lines
258 275
    pub checkpoint: Checkpoint,
259 276
    /// Every entry, numbered from 0.
260 277
    pub entries: Vec<Entry>,
278 +
    /// What accepting the checkpoint noted (a new log key, a new name).
279 +
    pub notes: Vec<String>,
261 280
}
262 281
263 282
impl Repo {
…
10 unchanged lines
274 293
        let tl = |e: TlogError| RepoError::Msg(e.to_string());
275 294
        let text = String::from_utf8(http_get(&format!("{hub}/log/checkpoint"), &options)?)
276 295
            .map_err(|_| RepoError::Msg(format!("{hub}'s checkpoint is not text")))?;
277 -
        let cp = match self.accept_checkpoint(&hub, &text, &options)? {
296 +
        let (cp, notes) = match self.accept_checkpoint(&hub, &text, &options)? {
278 297
            Ok(c) => c,
279 298
            Err(why) => return fail(format!("{hub}'s log could not be checked: {why}")),
280 299
        };
…
34 unchanged lines
315 334
            hub,
316 335
            checkpoint: cp,
317 336
            entries,
337 +
            notes,
318 338
        })
319 339
    }
320 340
}
modified src/cli/tlog.rs +3 −0
…
86 unchanged lines
87 87
        );
88 88
        return Ok(0);
89 89
    }
90 +
    for n in &audit.notes {
91 +
        eprintln!("{} {n}", ui::caution());
92 +
    }
90 93
    println!(
91 94
        "{} {}: {} entries make the root its log key signed, and extend what you saw before",
92 95
        ui::done("Verified"),
…
28 unchanged lines