OXYGENHUB

The transparency log as a library (tlog, spec §14.8) e5f8eb2a2006

oxy-core gains the transparency log's parts (oxygenhub#238, docs/
transparency.md, step 1 of its order of work):
- Entry, the fixed-shape record of one accepted object, and its leaf
  hash;
- RFC 6962's tree with BLAKE3, domain-separated for leaves and nodes,
  over hashes in stored order (append-only);
- inclusion and consistency proofs, generated from stored hashes and
  checked;
- C2SP tlog-tiles: tiles, their paths, entry bundles, which tiles a
  growing log writes;
- checkpoints as C2SP signed notes: an Ed25519 line note tools read,
  and a composite line (domain `checkpoint`) that keeps the log
  post-quantum; a reader requires both.

Tests check every root against RFC 6962's definition and every inclusion
and consistency proof up to 140 leaves; tampered proofs and forked
histories are refused; tiles give back every stored hash of a 70 000-leaf
log. The hub's log, oxy's checks and witnesses come next.

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 b3bf4a69bb3d
When
· 2026-10-17 22:56 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
e5f8eb2a2006f2d7fc782871edc27cfefc8c7f6902198507a5fa7e8b3e768316
Depends on
05059d434aca 0bdc3f91a39f 1983686fb7a7 2cd40ea1c04c 8d80356371c5 a2387d547373 c895bc52e4e0 f53cbe7a204e ff0af1ee299d
Depended on by
028545dfa7a4 b0621c2f8cc4 0ed150d62994
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
10 atoms, 1 tree operation, 6 files

Browse the tree at this change

added crates/oxy-core/src/tlog.rs +1127 −0
1 +
//! The transparency log (spec §14.8, oxygenhub#238, oxygenhub
2 +
//! docs/transparency.md): an append-only Merkle tree of what a hub
3 +
//! accepts, served as C2SP tlog-tiles with C2SP signed-note checkpoints,
4 +
//! so a hub cannot show one history to some and another to others.
5 +
//!
6 +
//! The tree is RFC 6962's, with BLAKE3 for its hashes, domain-separated
7 +
//! for leaves and nodes. Hashes are kept in *stored order*: every hash of
8 +
//! the tree, each written as soon as it is known, so the store is
9 +
//! append-only (the arithmetic is that of Go's `sumdb/tlog`). Tiles are
10 +
//! views of it: level L tile N holds the hashes of tree level 8·L for
11 +
//! leaves N·256 onwards.
12 +
//!
13 +
//! A checkpoint is a signed note: the origin, the tree size and the root,
14 +
//! signed with the hub's Ed25519 key (what note tools read) and with its
15 +
//! composite key in an extra line, so the log stays post-quantum.
16 +
17 +
use crate::cbor::{self, Value};
18 +
use crate::hash::{KeyId, ObjectRef};
19 +
use crate::identity::{verify, Domain, Identity, SecretKey, Signature};
20 +
use crate::objects::{array, bytes, check_version, field, map, oref, text, uint, ObjectError};
21 +
use sha2::{Digest, Sha256};
22 +
23 +
/// A hash in the tree.
24 +
pub type Hash = [u8; 32];
25 +
26 +
/// Tile height: a tile holds up to 2⁸ hashes (C2SP tlog-tiles).
27 +
pub const TILE_HEIGHT: u32 = 8;
28 +
/// Hashes in a full tile.
29 +
pub const TILE_WIDTH: u64 = 1 << TILE_HEIGHT;
30 +
31 +
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
32 +
pub enum TlogError {
33 +
    #[error("the proof does not verify")]
34 +
    ProofFailed,
35 +
    #[error("{0}")]
36 +
    BadIndex(String),
37 +
    #[error("reading the log's hashes: {0}")]
38 +
    Read(String),
39 +
    #[error("not a valid note: {0}")]
40 +
    BadNote(String),
41 +
    #[error("{0}")]
42 +
    BadSignature(String),
43 +
    #[error("{0}")]
44 +
    Schema(String),
45 +
}
46 +
47 +
impl From<ObjectError> for TlogError {
48 +
    fn from(e: ObjectError) -> Self {
49 +
        TlogError::Schema(e.to_string())
50 +
    }
51 +
}
52 +
53 +
type Result<T> = std::result::Result<T, TlogError>;
54 +
55 +
// ------------------------------------------------------------- hashing
56 +
57 +
/// A leaf's hash: `BLAKE3("oxygen-tlog-leaf\0" || entry bytes)`.
58 +
pub fn leaf_hash(entry: &[u8]) -> Hash {
59 +
    let mut h = blake3::Hasher::new();
60 +
    h.update(b"oxygen-tlog-leaf\0");
61 +
    h.update(entry);
62 +
    *h.finalize().as_bytes()
63 +
}
64 +
65 +
/// An interior node's hash: `BLAKE3("oxygen-tlog-node\0" || left || right)`.
66 +
pub fn node_hash(left: &Hash, right: &Hash) -> Hash {
67 +
    let mut h = blake3::Hasher::new();
68 +
    h.update(b"oxygen-tlog-node\0");
69 +
    h.update(left);
70 +
    h.update(right);
71 +
    *h.finalize().as_bytes()
72 +
}
73 +
74 +
/// The root of the empty tree: the hash of nothing, as RFC 6962 has it.
75 +
pub fn empty_root() -> Hash {
76 +
    *blake3::hash(b"").as_bytes()
77 +
}
78 +
79 +
// --------------------------------------------------------------- entries
80 +
81 +
/// One accepted object (spec §14.8): small and fixed-shape, never the
82 +
/// object itself, so a private repository's entries reveal only ids and
83 +
/// hashes.
84 +
#[derive(Clone, Debug, PartialEq, Eq)]
85 +
pub struct Entry {
86 +
    /// `ref`, `seal`, `tag`, `rotate`, `revoke`, `receipt`, `approval`,
87 +
    /// `policy`, `identity`, `certificate`, `accepted`.
88 +
    pub kind: String,
89 +
    /// The repository's id; zero for identities.
90 +
    pub repo: [u8; 16],
91 +
    /// The attestation, policy, identity or certificate.
92 +
    pub object: ObjectRef,
93 +
    /// The keys whose signatures were checked.
94 +
    pub signers: Vec<KeyId>,
95 +
    /// The hub's clock when it accepted it, in milliseconds (its claim).
96 +
    pub received: u64,
97 +
}
98 +
99 +
impl Entry {
100 +
    pub fn to_value(&self) -> Value {
101 +
        Value::map([
102 +
            (0, Value::Uint(1)),
103 +
            (1, Value::Text(self.kind.clone())),
104 +
            (2, Value::Bytes(self.repo.to_vec())),
105 +
            (3, Value::Bytes(self.object.0.to_vec())),
106 +
            (
107 +
                4,
108 +
                Value::Array(
109 +
                    self.signers
110 +
                        .iter()
111 +
                        .map(|k| Value::Bytes(k.0.to_vec()))
112 +
                        .collect(),
113 +
                ),
114 +
            ),
115 +
            (5, Value::Uint(self.received)),
116 +
        ])
117 +
    }
118 +
119 +
    pub fn encode(&self) -> Vec<u8> {
120 +
        cbor::encode(&self.to_value())
121 +
    }
122 +
123 +
    pub fn decode(b: &[u8]) -> Result<Entry> {
124 +
        let v = cbor::decode(b).map_err(ObjectError::from)?;
125 +
        let m = map(&v, "log entry")?;
126 +
        check_version(m)?;
127 +
        let repo = bytes(field(m, 2, "repo")?, "repo")?;
128 +
        let repo: [u8; 16] = repo
129 +
            .try_into()
130 +
            .map_err(|_| TlogError::Schema("log entry: repo is not 16 bytes".into()))?;
131 +
        let mut signers = Vec::new();
132 +
        for s in array(field(m, 4, "signers")?, "signers")? {
133 +
            signers.push(oref(s, "signer")?);
134 +
        }
135 +
        Ok(Entry {
136 +
            kind: text(field(m, 1, "kind")?, "kind")?.to_owned(),
137 +
            repo,
138 +
            object: oref(field(m, 3, "object")?, "object")?,
139 +
            signers,
140 +
            received: uint(field(m, 5, "received")?, "received")?,
141 +
        })
142 +
    }
143 +
144 +
    /// Its leaf hash in the tree.
145 +
    pub fn leaf_hash(&self) -> Hash {
146 +
        leaf_hash(&self.encode())
147 +
    }
148 +
}
149 +
150 +
/// An entry bundle (C2SP tlog-tiles): each entry as a big-endian u16
151 +
/// length and its bytes.
152 +
pub fn encode_bundle(entries: &[Vec<u8>]) -> Result<Vec<u8>> {
153 +
    let mut out = Vec::new();
154 +
    for e in entries {
155 +
        let n = u16::try_from(e.len())
156 +
            .map_err(|_| TlogError::Schema("a log entry over 65535 bytes".into()))?;
157 +
        out.extend_from_slice(&n.to_be_bytes());
158 +
        out.extend_from_slice(e);
159 +
    }
160 +
    Ok(out)
161 +
}
162 +
163 +
/// The entries of a bundle.
164 +
pub fn decode_bundle(mut b: &[u8]) -> Result<Vec<Vec<u8>>> {
165 +
    let mut out = Vec::new();
166 +
    while !b.is_empty() {
167 +
        if b.len() < 2 {
168 +
            return Err(TlogError::Schema("entry bundle: a truncated length".into()));
169 +
        }
170 +
        let n = u16::from_be_bytes([b[0], b[1]]) as usize;
171 +
        if b.len() < 2 + n {
172 +
            return Err(TlogError::Schema("entry bundle: a truncated entry".into()));
173 +
        }
174 +
        out.push(b[2..2 + n].to_vec());
175 +
        b = &b[2 + n..];
176 +
    }
177 +
    Ok(out)
178 +
}
179 +
180 +
// -------------------------------------------------- the tree's arithmetic
181 +
182 +
/// Reads stored hashes by their stored index.
183 +
pub trait HashReader {
184 +
    /// The hashes at `indexes`, in that order.
185 +
    fn read_hashes(&self, indexes: &[u64]) -> Result<Vec<Hash>>;
186 +
}
187 +
188 +
/// Every stored hash in order, as a log in memory holds them.
189 +
impl HashReader for [Hash] {
190 +
    fn read_hashes(&self, indexes: &[u64]) -> Result<Vec<Hash>> {
191 +
        indexes
192 +
            .iter()
193 +
            .map(|&i| {
194 +
                self.get(i as usize)
195 +
                    .copied()
196 +
                    .ok_or_else(|| TlogError::Read(format!("no stored hash {i}")))
197 +
            })
198 +
            .collect()
199 +
    }
200 +
}
201 +
202 +
impl HashReader for Vec<Hash> {
203 +
    fn read_hashes(&self, indexes: &[u64]) -> Result<Vec<Hash>> {
204 +
        self.as_slice().read_hashes(indexes)
205 +
    }
206 +
}
207 +
208 +
/// The largest power of two below `n` (n ≥ 2), and its log.
209 +
fn maxpow2(n: u64) -> (u64, u32) {
210 +
    let mut l = 0;
211 +
    while (1u64 << (l + 1)) < n {
212 +
        l += 1;
213 +
    }
214 +
    (1 << l, l)
215 +
}
216 +
217 +
/// Where the `n`th hash of tree level `level` is stored: level L's nth
218 +
/// hash is written right after level L+1's (2n+1)th, and level 0's nth
219 +
/// at n + n/2 + n/4 + ….
220 +
pub fn stored_hash_index(level: u32, mut n: u64) -> u64 {
221 +
    for _ in 0..level {
222 +
        n = 2 * n + 1;
223 +
    }
224 +
    let mut i = 0;
225 +
    let mut m = n;
226 +
    while m > 0 {
227 +
        i += m;
228 +
        m >>= 1;
229 +
    }
230 +
    i + level as u64
231 +
}
232 +
233 +
/// The level and position of the hash at a stored index.
234 +
pub fn split_stored_hash_index(index: u64) -> (u32, u64) {
235 +
    let mut n = index / 2;
236 +
    let mut index_n = stored_hash_index(0, n);
237 +
    debug_assert!(index_n <= index);
238 +
    loop {
239 +
        // Each new leaf n adds 1 + trailing_zeros(n+1) hashes.
240 +
        let x = index_n + 1 + (n + 1).trailing_zeros() as u64;
241 +
        if x > index {
242 +
            break;
243 +
        }
244 +
        n += 1;
245 +
        index_n = x;
246 +
    }
247 +
    let level = (index - index_n) as u32;
248 +
    (level, n >> level)
249 +
}
250 +
251 +
/// How many hashes a tree of `n` leaves stores.
252 +
pub fn stored_hash_count(n: u64) -> u64 {
253 +
    if n == 0 {
254 +
        return 0;
255 +
    }
256 +
    let mut count = stored_hash_index(0, n - 1) + 1;
257 +
    let mut i = n - 1;
258 +
    while i & 1 != 0 {
259 +
        count += 1;
260 +
        i >>= 1;
261 +
    }
262 +
    count
263 +
}
264 +
265 +
/// The hashes to append when leaf `n` (with hash `leaf`) joins a tree of
266 +
/// `n` leaves: the leaf's, then those of the subtrees it completes.
267 +
pub fn stored_hashes_for_leaf(
268 +
    n: u64,
269 +
    leaf: Hash,
270 +
    r: &(impl HashReader + ?Sized),
271 +
) -> Result<Vec<Hash>> {
272 +
    let m = (n + 1).trailing_zeros();
273 +
    let indexes: Vec<u64> = (0..m)
274 +
        .rev()
275 +
        .map(|i| stored_hash_index(i, (n >> i) - 1))
276 +
        .collect();
277 +
    let old = r.read_hashes(&indexes)?;
278 +
    let mut h = leaf;
279 +
    let mut out = vec![h];
280 +
    for i in 0..m as usize {
281 +
        h = node_hash(&old[m as usize - 1 - i], &h);
282 +
        out.push(h);
283 +
    }
284 +
    Ok(out)
285 +
}
286 +
287 +
fn sub_tree_index(mut lo: u64, hi: u64, need: &mut Vec<u64>) {
288 +
    while lo < hi {
289 +
        let (k, level) = maxpow2(hi - lo + 1);
290 +
        debug_assert_eq!(lo & (k - 1), 0);
291 +
        need.push(stored_hash_index(level, lo >> level));
292 +
        lo += k;
293 +
    }
294 +
}
295 +
296 +
fn sub_tree_hash(mut lo: u64, hi: u64, hashes: &[Hash]) -> (Hash, &[Hash]) {
297 +
    let mut count = 0;
298 +
    while lo < hi {
299 +
        let (k, _) = maxpow2(hi - lo + 1);
300 +
        count += 1;
301 +
        lo += k;
302 +
    }
303 +
    let mut h = hashes[count - 1];
304 +
    for i in (0..count - 1).rev() {
305 +
        h = node_hash(&hashes[i], &h);
306 +
    }
307 +
    (h, &hashes[count..])
308 +
}
309 +
310 +
/// The root of the first `n` leaves.
311 +
pub fn tree_hash(n: u64, r: &(impl HashReader + ?Sized)) -> Result<Hash> {
312 +
    if n == 0 {
313 +
        return Ok(empty_root());
314 +
    }
315 +
    let mut need = Vec::new();
316 +
    sub_tree_index(0, n, &mut need);
317 +
    let hashes = r.read_hashes(&need)?;
318 +
    Ok(sub_tree_hash(0, n, &hashes).0)
319 +
}
320 +
321 +
fn leaf_proof_index(lo: u64, hi: u64, n: u64, need: &mut Vec<u64>) {
322 +
    if lo + 1 == hi {
323 +
        return;
324 +
    }
325 +
    let (k, _) = maxpow2(hi - lo);
326 +
    if n < lo + k {
327 +
        leaf_proof_index(lo, lo + k, n, need);
328 +
        sub_tree_index(lo + k, hi, need);
329 +
    } else {
330 +
        sub_tree_index(lo, lo + k, need);
331 +
        leaf_proof_index(lo + k, hi, n, need);
332 +
    }
333 +
}
334 +
335 +
fn leaf_proof(lo: u64, hi: u64, n: u64, hashes: &[Hash]) -> (Vec<Hash>, &[Hash]) {
336 +
    if lo + 1 == hi {
337 +
        // The verifier has the leaf's hash.
338 +
        return (Vec::new(), hashes);
339 +
    }
340 +
    let (k, _) = maxpow2(hi - lo);
341 +
    let (mut p, th, rest);
342 +
    if n < lo + k {
343 +
        let (pp, h1) = leaf_proof(lo, lo + k, n, hashes);
344 +
        let (t, h2) = sub_tree_hash(lo + k, hi, h1);
345 +
        (p, th, rest) = (pp, t, h2);
346 +
    } else {
347 +
        let (t, h1) = sub_tree_hash(lo, lo + k, hashes);
348 +
        let (pp, h2) = leaf_proof(lo + k, hi, n, h1);
349 +
        (p, th, rest) = (pp, t, h2);
350 +
    }
351 +
    p.push(th);
352 +
    (p, rest)
353 +
}
354 +
355 +
/// The inclusion proof of leaf `n` in the tree of the first `t` leaves.
356 +
pub fn prove_inclusion(t: u64, n: u64, r: &(impl HashReader + ?Sized)) -> Result<Vec<Hash>> {
357 +
    if n >= t {
358 +
        return Err(TlogError::BadIndex(format!(
359 +
            "leaf {n} is not in a tree of {t}"
360 +
        )));
361 +
    }
362 +
    let mut need = Vec::new();
363 +
    leaf_proof_index(0, t, n, &mut need);
364 +
    if need.is_empty() {
365 +
        return Ok(Vec::new());
366 +
    }
367 +
    let hashes = r.read_hashes(&need)?;
368 +
    Ok(leaf_proof(0, t, n, &hashes).0)
369 +
}
370 +
371 +
fn run_inclusion(p: &[Hash], lo: u64, hi: u64, n: u64, leaf: Hash) -> Result<Hash> {
372 +
    if lo + 1 == hi {
373 +
        return if p.is_empty() {
374 +
            Ok(leaf)
375 +
        } else {
376 +
            Err(TlogError::ProofFailed)
377 +
        };
378 +
    }
379 +
    let Some((last, rest)) = p.split_last() else {
380 +
        return Err(TlogError::ProofFailed);
381 +
    };
382 +
    let (k, _) = maxpow2(hi - lo);
383 +
    if n < lo + k {
384 +
        Ok(node_hash(&run_inclusion(rest, lo, lo + k, n, leaf)?, last))
385 +
    } else {
386 +
        Ok(node_hash(last, &run_inclusion(rest, lo + k, hi, n, leaf)?))
387 +
    }
388 +
}
389 +
390 +
/// Check that leaf `n`, with hash `leaf`, is in the tree of `t` leaves
391 +
/// whose root is `root`.
392 +
pub fn check_inclusion(proof: &[Hash], t: u64, root: &Hash, n: u64, leaf: &Hash) -> Result<()> {
393 +
    if n >= t {
394 +
        return Err(TlogError::ProofFailed);
395 +
    }
396 +
    if run_inclusion(proof, 0, t, n, *leaf)? == *root {
397 +
        Ok(())
398 +
    } else {
399 +
        Err(TlogError::ProofFailed)
400 +
    }
401 +
}
402 +
403 +
fn tree_proof_index(lo: u64, hi: u64, n: u64, need: &mut Vec<u64>) {
404 +
    if n == hi {
405 +
        if lo != 0 {
406 +
            sub_tree_index(lo, hi, need);
407 +
        }
408 +
        return;
409 +
    }
410 +
    let (k, _) = maxpow2(hi - lo);
411 +
    if n <= lo + k {
412 +
        tree_proof_index(lo, lo + k, n, need);
413 +
        sub_tree_index(lo + k, hi, need);
414 +
    } else {
415 +
        sub_tree_index(lo, lo + k, need);
416 +
        tree_proof_index(lo + k, hi, n, need);
417 +
    }
418 +
}
419 +
420 +
fn tree_proof(lo: u64, hi: u64, n: u64, hashes: &[Hash]) -> (Vec<Hash>, &[Hash]) {
421 +
    if n == hi {
422 +
        if lo == 0 {
423 +
            // Exactly the old tree, whose root the verifier has.
424 +
            return (Vec::new(), hashes);
425 +
        }
426 +
        let (th, rest) = sub_tree_hash(lo, hi, hashes);
427 +
        return (vec![th], rest);
428 +
    }
429 +
    let (k, _) = maxpow2(hi - lo);
430 +
    let (mut p, th, rest);
431 +
    if n <= lo + k {
432 +
        let (pp, h1) = tree_proof(lo, lo + k, n, hashes);
433 +
        let (t, h2) = sub_tree_hash(lo + k, hi, h1);
434 +
        (p, th, rest) = (pp, t, h2);
435 +
    } else {
436 +
        let (t, h1) = sub_tree_hash(lo, lo + k, hashes);
437 +
        let (pp, h2) = tree_proof(lo + k, hi, n, h1);
438 +
        (p, th, rest) = (pp, t, h2);
439 +
    }
440 +
    p.push(th);
441 +
    (p, rest)
442 +
}
443 +
444 +
/// The consistency proof that the tree of `n` leaves is a prefix of the
445 +
/// tree of `t`.
446 +
pub fn prove_consistency(t: u64, n: u64, r: &(impl HashReader + ?Sized)) -> Result<Vec<Hash>> {
447 +
    if n < 1 || n > t {
448 +
        return Err(TlogError::BadIndex(format!(
449 +
            "no consistency proof from {n} to {t}"
450 +
        )));
451 +
    }
452 +
    let mut need = Vec::new();
453 +
    tree_proof_index(0, t, n, &mut need);
454 +
    if need.is_empty() {
455 +
        return Ok(Vec::new());
456 +
    }
457 +
    let hashes = r.read_hashes(&need)?;
458 +
    Ok(tree_proof(0, t, n, &hashes).0)
459 +
}
460 +
461 +
fn run_consistency(p: &[Hash], lo: u64, hi: u64, n: u64, old: Hash) -> Result<(Hash, Hash)> {
462 +
    if n == hi {
463 +
        if lo == 0 {
464 +
            return if p.is_empty() {
465 +
                Ok((old, old))
466 +
            } else {
467 +
                Err(TlogError::ProofFailed)
468 +
            };
469 +
        }
470 +
        return match p {
471 +
            [h] => Ok((*h, *h)),
472 +
            _ => Err(TlogError::ProofFailed),
473 +
        };
474 +
    }
475 +
    let Some((last, rest)) = p.split_last() else {
476 +
        return Err(TlogError::ProofFailed);
477 +
    };
478 +
    let (k, _) = maxpow2(hi - lo);
479 +
    if n <= lo + k {
480 +
        let (oh, th) = run_consistency(rest, lo, lo + k, n, old)?;
481 +
        Ok((oh, node_hash(&th, last)))
482 +
    } else {
483 +
        let (oh, th) = run_consistency(rest, lo + k, hi, n, old)?;
484 +
        Ok((node_hash(last, &oh), node_hash(last, &th)))
485 +
    }
486 +
}
487 +
488 +
/// Check that the tree of `n` leaves with root `old_root` is a prefix of
489 +
/// the tree of `t` leaves with root `root`.
490 +
pub fn check_consistency(
491 +
    proof: &[Hash],
492 +
    t: u64,
493 +
    root: &Hash,
494 +
    n: u64,
495 +
    old_root: &Hash,
496 +
) -> Result<()> {
497 +
    if n < 1 || n > t {
498 +
        return Err(TlogError::ProofFailed);
499 +
    }
500 +
    let (oh, th) = run_consistency(proof, 0, t, n, *old_root)?;
501 +
    if oh == *old_root && th == *root {
502 +
        Ok(())
503 +
    } else {
504 +
        Err(TlogError::ProofFailed)
505 +
    }
506 +
}
507 +
508 +
// ----------------------------------------------------------------- tiles
509 +
510 +
/// A tile (C2SP tlog-tiles, height 8): level `level`'s hashes for leaves
511 +
/// from `n`·256, `width` of them (256 when full).
512 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
513 +
pub struct Tile {
514 +
    pub level: u32,
515 +
    pub n: u64,
516 +
    pub width: u32,
517 +
}
518 +
519 +
/// A tile index as a path: three-digit groups, all but the last with an
520 +
/// `x`, as `x001/x234/067` for 1234067.
521 +
pub fn index_path(n: u64) -> String {
522 +
    let mut groups = Vec::new();
523 +
    let mut m = n;
524 +
    loop {
525 +
        groups.push(m % 1000);
526 +
        m /= 1000;
527 +
        if m == 0 {
528 +
            break;
529 +
        }
530 +
    }
531 +
    groups.reverse();
532 +
    let last = groups.len() - 1;
533 +
    groups
534 +
        .iter()
535 +
        .enumerate()
536 +
        .map(|(i, g)| {
537 +
            if i < last {
538 +
                format!("x{g:03}")
539 +
            } else {
540 +
                format!("{g:03}")
541 +
            }
542 +
        })
543 +
        .collect::<Vec<_>>()
544 +
        .join("/")
545 +
}
546 +
547 +
/// The index a path names (the inverse of [`index_path`]).
548 +
pub fn parse_index_path(path: &str) -> Option<u64> {
549 +
    let parts: Vec<&str> = path.split('/').collect();
550 +
    let mut n: u64 = 0;
551 +
    for (i, p) in parts.iter().enumerate() {
552 +
        let digits = if i + 1 < parts.len() {
553 +
            p.strip_prefix('x')?
554 +
        } else {
555 +
            p
556 +
        };
557 +
        if digits.len() != 3 || !digits.bytes().all(|c| c.is_ascii_digit()) {
558 +
            return None;
559 +
        }
560 +
        n = n.checked_mul(1000)?.checked_add(digits.parse().ok()?)?;
561 +
    }
562 +
    Some(n)
563 +
}
564 +
565 +
impl Tile {
566 +
    /// `tile/<L>/<N>[.p/<W>]`.
567 +
    pub fn path(&self) -> String {
568 +
        format!(
569 +
            "tile/{}/{}{}",
570 +
            self.level,
571 +
            index_path(self.n),
572 +
            self.partial()
573 +
        )
574 +
    }
575 +
576 +
    /// The entry bundle beside a level-0 tile: `tile/entries/<N>[.p/<W>]`.
577 +
    pub fn entries_path(&self) -> String {
578 +
        format!("tile/entries/{}{}", index_path(self.n), self.partial())
579 +
    }
580 +
581 +
    fn partial(&self) -> String {
582 +
        if (self.width as u64) < TILE_WIDTH {
583 +
            format!(".p/{}", self.width)
584 +
        } else {
585 +
            String::new()
586 +
        }
587 +
    }
588 +
589 +
    /// The tile a path names (`<L>/<N>[.p/<W>]` after `tile/`).
590 +
    pub fn parse(path: &str) -> Option<Tile> {
591 +
        let (level, rest) = path.split_once('/')?;
592 +
        let level: u32 = level.parse().ok()?;
593 +
        let (index, width) = match rest.split_once(".p/") {
594 +
            Some((i, w)) => {
595 +
                let w: u32 = w.parse().ok()?;
596 +
                if w == 0 || w as u64 >= TILE_WIDTH {
597 +
                    return None;
598 +
                }
599 +
                (i, w)
600 +
            }
601 +
            None => (rest, TILE_WIDTH as u32),
602 +
        };
603 +
        Some(Tile {
604 +
            level,
605 +
            n: parse_index_path(index)?,
606 +
            width,
607 +
        })
608 +
    }
609 +
610 +
    /// The tile holding the stored hash at `index`, and the byte range its
611 +
    /// hash comes from in the tile's data.
612 +
    pub fn for_index(index: u64) -> (Tile, usize, usize) {
613 +
        let (level, mut n) = split_stored_hash_index(index);
614 +
        let tl = level / TILE_HEIGHT;
615 +
        let within = level - tl * TILE_HEIGHT;
616 +
        let tn = (n << within) >> TILE_HEIGHT;
617 +
        n -= (tn << TILE_HEIGHT) >> within;
618 +
        let width = ((n + 1) << within) as u32;
619 +
        let tile = Tile {
620 +
            level: tl,
621 +
            n: tn,
622 +
            width,
623 +
        };
624 +
        (
625 +
            tile,
626 +
            ((n << within) * 32) as usize,
627 +
            (((n + 1) << within) * 32) as usize,
628 +
        )
629 +
    }
630 +
631 +
    /// The tile's data: its hashes, read from stored order.
632 +
    pub fn read_data(&self, r: &(impl HashReader + ?Sized)) -> Result<Vec<u8>> {
633 +
        let start = self.n << TILE_HEIGHT;
634 +
        let indexes: Vec<u64> = (0..self.width as u64)
635 +
            .map(|i| stored_hash_index(TILE_HEIGHT * self.level, start + i))
636 +
            .collect();
637 +
        Ok(r.read_hashes(&indexes)?.concat())
638 +
    }
639 +
640 +
    /// The stored hash at `index` from this tile's data.
641 +
    pub fn hash_at(&self, data: &[u8], index: u64) -> Result<Hash> {
642 +
        if self.width == 0
643 +
            || self.width as u64 > TILE_WIDTH
644 +
            || data.len() < self.width as usize * 32
645 +
        {
646 +
            return Err(TlogError::Read(format!("tile {} is short", self.path())));
647 +
        }
648 +
        let (t, start, end) = Tile::for_index(index);
649 +
        if t.level != self.level || t.n != self.n || t.width > self.width {
650 +
            return Err(TlogError::Read(format!(
651 +
                "stored hash {index} is not in tile {}",
652 +
                self.path()
653 +
            )));
654 +
        }
655 +
        Ok(tile_hash(&data[start..end]))
656 +
    }
657 +
658 +
    /// The tiles a tree that grows from `old` to `new` leaves writes, full
659 +
    /// ones and the new partial ones.
660 +
    pub fn new_tiles(old: u64, new: u64) -> Vec<Tile> {
661 +
        let mut tiles = Vec::new();
662 +
        let mut level = 0u32;
663 +
        while level < 8 && (new >> (TILE_HEIGHT * level)) > 0 {
664 +
            let old_n = old >> (TILE_HEIGHT * level);
665 +
            let new_n = new >> (TILE_HEIGHT * level);
666 +
            if old_n != new_n {
667 +
                for n in (old_n >> TILE_HEIGHT)..(new_n >> TILE_HEIGHT) {
668 +
                    tiles.push(Tile {
669 +
                        level,
670 +
                        n,
671 +
                        width: TILE_WIDTH as u32,
672 +
                    });
673 +
                }
674 +
                let n = new_n >> TILE_HEIGHT;
675 +
                let w = new_n - (n << TILE_HEIGHT);
676 +
                if w > 0 {
677 +
                    tiles.push(Tile {
678 +
                        level,
679 +
                        n,
680 +
                        width: w as u32,
681 +
                    });
682 +
                }
683 +
            }
684 +
            level += 1;
685 +
        }
686 +
        tiles
687 +
    }
688 +
}
689 +
690 +
fn tile_hash(data: &[u8]) -> Hash {
691 +
    if data.len() == 32 {
692 +
        let mut h = [0u8; 32];
693 +
        h.copy_from_slice(data);
694 +
        return h;
695 +
    }
696 +
    let half = data.len() / 2;
697 +
    node_hash(&tile_hash(&data[..half]), &tile_hash(&data[half..]))
698 +
}
699 +
700 +
// ----------------------------------------------------- notes, checkpoints
701 +
702 +
/// One signature line of a note: `— <name> <base64(key hash || sig)>`.
703 +
#[derive(Clone, Debug, PartialEq, Eq)]
704 +
pub struct NoteSignature {
705 +
    pub name: String,
706 +
    pub key_hash: [u8; 4],
707 +
    pub sig: Vec<u8>,
708 +
}
709 +
710 +
/// A C2SP signed note: its text and its signature lines.
711 +
#[derive(Clone, Debug, PartialEq, Eq)]
712 +
pub struct Note {
713 +
    /// The text, ending with a newline.
714 +
    pub text: String,
715 +
    pub sigs: Vec<NoteSignature>,
716 +
}
717 +
718 +
/// Signature types in a note's key hash (C2SP signed-note).
719 +
const SIG_ED25519: u8 = 0x01;
720 +
/// Oxygen's composite Ed25519 + ML-DSA-65 signature (spec §14.8).
721 +
const SIG_COMPOSITE: u8 = 0x4f;
722 +
723 +
fn key_hash(name: &str, sig_type: u8, key: &[u8]) -> [u8; 4] {
724 +
    let mut h = Sha256::new();
725 +
    h.update(name.as_bytes());
726 +
    h.update(b"\n");
727 +
    h.update([sig_type]);
728 +
    h.update(key);
729 +
    let d = h.finalize();
730 +
    [d[0], d[1], d[2], d[3]]
731 +
}
732 +
733 +
/// What a composite signature of a note signs (domain `checkpoint`).
734 +
fn note_target(text: &str) -> ObjectRef {
735 +
    let mut h = blake3::Hasher::new();
736 +
    h.update(b"oxygen-tlog-note\0");
737 +
    h.update(text.as_bytes());
738 +
    ObjectRef(*h.finalize().as_bytes())
739 +
}
740 +
741 +
fn valid_name(name: &str) -> bool {
742 +
    !name.is_empty() && !name.contains(|c: char| c.is_whitespace() || c == '+')
743 +
}
744 +
745 +
impl Note {
746 +
    /// The note as served: the text, a blank line, the signature lines.
747 +
    pub fn encode(&self) -> String {
748 +
        let mut out = self.text.clone();
749 +
        out.push('\n');
750 +
        for s in &self.sigs {
751 +
            let mut b = s.key_hash.to_vec();
752 +
            b.extend_from_slice(&s.sig);
753 +
            out.push_str(&format!(
754 +
                "\u{2014} {} {}\n",
755 +
                s.name,
756 +
                crate::dsse::base64(&b)
757 +
            ));
758 +
        }
759 +
        out
760 +
    }
761 +
762 +
    pub fn parse(s: &str) -> Result<Note> {
763 +
        let bad = |w: &str| TlogError::BadNote(w.to_string());
764 +
        let split = s
765 +
            .rfind("\n\n")
766 +
            .ok_or_else(|| bad("no blank line before the signatures"))?;
767 +
        let text = &s[..split + 1];
768 +
        if text.chars().any(|c| c.is_control() && c != '\n') {
769 +
            return Err(bad("control characters in the text"));
770 +
        }
771 +
        let mut sigs = Vec::new();
772 +
        for line in s[split + 2..].lines() {
773 +
            let rest = line
774 +
                .strip_prefix("\u{2014} ")
775 +
                .ok_or_else(|| bad("a signature line without its dash"))?;
776 +
            let (name, b64) = rest
777 +
                .split_once(' ')
778 +
                .ok_or_else(|| bad("a signature line without a key"))?;
779 +
            let b =
780 +
                crate::dsse::base64_decode(b64).ok_or_else(|| bad("a signature is not base64"))?;
781 +
            if b.len() < 5 || !valid_name(name) {
782 +
                return Err(bad("a malformed signature line"));
783 +
            }
784 +
            sigs.push(NoteSignature {
785 +
                name: name.to_string(),
786 +
                key_hash: [b[0], b[1], b[2], b[3]],
787 +
                sig: b[4..].to_vec(),
788 +
            });
789 +
        }
790 +
        if sigs.is_empty() {
791 +
            return Err(bad("no signatures"));
792 +
        }
793 +
        Ok(Note {
794 +
            text: text.to_string(),
795 +
            sigs,
796 +
        })
797 +
    }
798 +
799 +
    /// Sign as `name` with `identity`'s key: the Ed25519 line note tools
800 +
    /// read, and the composite line that keeps it post-quantum.
801 +
    pub fn sign(text: &str, name: &str, identity: &Identity, sk: &SecretKey) -> Result<Note> {
802 +
        if !valid_name(name) {
803 +
            return Err(TlogError::BadNote(format!(
804 +
                "{name:?} cannot name a note's key"
805 +
            )));
806 +
        }
807 +
        if !text.ends_with('\n') || text.contains("\n\n") {
808 +
            return Err(TlogError::BadNote(
809 +
                "the text must end with one newline and hold no blank line".into(),
810 +
            ));
811 +
        }
812 +
        let ed = NoteSignature {
813 +
            name: name.to_string(),
814 +
            key_hash: key_hash(name, SIG_ED25519, &identity.ed25519_pk),
815 +
            sig: sk.sign_ed25519(text.as_bytes()).to_vec(),
816 +
        };
817 +
        let composite = NoteSignature {
818 +
            name: name.to_string(),
819 +
            key_hash: key_hash(name, SIG_COMPOSITE, &identity.encode()),
820 +
            sig: cbor::encode(
821 +
                &sk.sign(Domain::Checkpoint, &identity.hash(), &note_target(text))
822 +
                    .to_value(),
823 +
            ),
824 +
        };
825 +
        Ok(Note {
826 +
            text: text.to_string(),
827 +
            sigs: vec![ed, composite],
828 +
        })
829 +
    }
830 +
831 +
    /// Both of `name`'s signatures by `identity` verify (a note signed
832 +
    /// with Ed25519 only is not enough).
833 +
    pub fn verify(&self, name: &str, identity: &Identity) -> Result<()> {
834 +
        let find = |t: u8, key: &[u8]| {
835 +
            let h = key_hash(name, t, key);
836 +
            self.sigs.iter().find(|s| s.name == name && s.key_hash == h)
837 +
        };
838 +
        let ed = find(SIG_ED25519, &identity.ed25519_pk)
839 +
            .ok_or_else(|| TlogError::BadSignature(format!("no Ed25519 signature by {name}")))?;
840 +
        identity
841 +
            .verify_ed25519(self.text.as_bytes(), &ed.sig)
842 +
            .map_err(|_| {
843 +
                TlogError::BadSignature(format!("{name}'s Ed25519 signature does not verify"))
844 +
            })?;
845 +
        let c = find(SIG_COMPOSITE, &identity.encode())
846 +
            .ok_or_else(|| TlogError::BadSignature(format!("no composite signature by {name}")))?;
847 +
        let sig = cbor::decode(&c.sig)
848 +
            .map_err(|e| e.to_string())
849 +
            .and_then(|v| Signature::from_value(&v).map_err(|e| e.to_string()))
850 +
            .map_err(|e| TlogError::BadSignature(format!("{name}'s composite signature: {e}")))?;
851 +
        verify(identity, &sig, Domain::Checkpoint, &note_target(&self.text)).map_err(|e| {
852 +
            TlogError::BadSignature(format!("{name}'s composite signature does not verify: {e}"))
853 +
        })
854 +
    }
855 +
}
856 +
857 +
/// A checkpoint (C2SP tlog-checkpoint): the log's origin, size and root.
858 +
#[derive(Clone, Debug, PartialEq, Eq)]
859 +
pub struct Checkpoint {
860 +
    /// The log's name: the hub's host and `/log`.
861 +
    pub origin: String,
862 +
    pub size: u64,
863 +
    pub root: Hash,
864 +
    /// Further lines, each without its newline.
865 +
    pub extensions: Vec<String>,
866 +
}
867 +
868 +
impl Checkpoint {
869 +
    pub fn text(&self) -> String {
870 +
        let mut t = format!(
871 +
            "{}\n{}\n{}\n",
872 +
            self.origin,
873 +
            self.size,
874 +
            crate::dsse::base64(&self.root)
875 +
        );
876 +
        for e in &self.extensions {
877 +
            t.push_str(e);
878 +
            t.push('\n');
879 +
        }
880 +
        t
881 +
    }
882 +
883 +
    pub fn parse(text: &str) -> Result<Checkpoint> {
884 +
        let bad = |w: &str| TlogError::BadNote(format!("checkpoint: {w}"));
885 +
        if !text.ends_with('\n') {
886 +
            return Err(bad("no final newline"));
887 +
        }
888 +
        let mut lines = text[..text.len() - 1].split('\n');
889 +
        let origin = lines
890 +
            .next()
891 +
            .filter(|o| !o.is_empty())
892 +
            .ok_or_else(|| bad("no origin"))?;
893 +
        let size = lines
894 +
            .next()
895 +
            .and_then(|s| {
896 +
                (s == "0" || !s.starts_with('0'))
897 +
                    .then(|| s.parse().ok())
898 +
                    .flatten()
899 +
            })
900 +
            .ok_or_else(|| bad("no tree size"))?;
901 +
        let root = lines
902 +
            .next()
903 +
            .and_then(crate::dsse::base64_decode)
904 +
            .and_then(|b| <Hash>::try_from(b).ok())
905 +
            .ok_or_else(|| bad("no root hash"))?;
906 +
        Ok(Checkpoint {
907 +
            origin: origin.to_string(),
908 +
            size,
909 +
            root,
910 +
            extensions: lines.map(String::from).collect(),
911 +
        })
912 +
    }
913 +
914 +
    /// Signed by the log's key, named by its origin.
915 +
    pub fn sign(&self, identity: &Identity, sk: &SecretKey) -> Result<Note> {
916 +
        Note::sign(&self.text(), &self.origin, identity, sk)
917 +
    }
918 +
919 +
    /// The checkpoint a note carries, once the log's key (`identity`)
920 +
    /// signed it for `origin`.
921 +
    pub fn verify(note: &Note, origin: &str, identity: &Identity) -> Result<Checkpoint> {
922 +
        note.verify(origin, identity)?;
923 +
        let c = Checkpoint::parse(&note.text)?;
924 +
        if c.origin != origin {
925 +
            return Err(TlogError::BadNote(format!(
926 +
                "checkpoint of {}, not of {origin}",
927 +
                c.origin
928 +
            )));
929 +
        }
930 +
        Ok(c)
931 +
    }
932 +
}
933 +
934 +
#[cfg(test)]
935 +
mod tests {
936 +
    use super::*;
937 +
938 +
    /// A log in memory: its stored hashes, grown leaf by leaf.
939 +
    fn grow(leaves: u64) -> (Vec<Hash>, Vec<Hash>) {
940 +
        let mut stored: Vec<Hash> = Vec::new();
941 +
        let mut leaf_hashes = Vec::new();
942 +
        for n in 0..leaves {
943 +
            let h = leaf_hash(format!("entry {n}").as_bytes());
944 +
            leaf_hashes.push(h);
945 +
            let add = stored_hashes_for_leaf(n, h, &stored).unwrap();
946 +
            assert_eq!(stored.len() as u64, stored_hash_index(0, n));
947 +
            stored.extend(add);
948 +
            assert_eq!(stored.len() as u64, stored_hash_count(n + 1));
949 +
        }
950 +
        (stored, leaf_hashes)
951 +
    }
952 +
953 +
    /// RFC 6962's MTH, straight from its definition.
954 +
    fn mth(leaves: &[Hash]) -> Hash {
955 +
        match leaves.len() {
956 +
            0 => empty_root(),
957 +
            1 => leaves[0],
958 +
            n => {
959 +
                let (k, _) = maxpow2(n as u64);
960 +
                node_hash(&mth(&leaves[..k as usize]), &mth(&leaves[k as usize..]))
961 +
            }
962 +
        }
963 +
    }
964 +
965 +
    #[test]
966 +
    fn stored_indexes_round_trip() {
967 +
        for level in 0..6 {
968 +
            for n in 0..300 {
969 +
                let i = stored_hash_index(level, n);
970 +
                assert_eq!(split_stored_hash_index(i), (level, n));
971 +
            }
972 +
        }
973 +
    }
974 +
975 +
    #[test]
976 +
    fn roots_match_rfc_6962_and_every_proof_verifies() {
977 +
        let (stored, leaves) = grow(140);
978 +
        for t in 1..=140u64 {
979 +
            let root = tree_hash(t, &stored).unwrap();
980 +
            assert_eq!(root, mth(&leaves[..t as usize]), "root of {t}");
981 +
            for n in 0..t {
982 +
                let p = prove_inclusion(t, n, &stored).unwrap();
983 +
                check_inclusion(&p, t, &root, n, &leaves[n as usize]).unwrap();
984 +
                // Another leaf, another tree, a tampered path: refused.
985 +
                let other = (n + 1) % t;
986 +
                if other != n {
987 +
                    assert!(check_inclusion(&p, t, &root, n, &leaves[other as usize]).is_err());
988 +
                }
989 +
                if let Some(first) = p.first() {
990 +
                    let mut bad = p.clone();
991 +
                    bad[0] = node_hash(first, first);
992 +
                    assert!(check_inclusion(&bad, t, &root, n, &leaves[n as usize]).is_err());
993 +
                }
994 +
                let old = n + 1;
995 +
                let old_root = tree_hash(old, &stored).unwrap();
996 +
                let c = prove_consistency(t, old, &stored).unwrap();
997 +
                check_consistency(&c, t, &root, old, &old_root).unwrap();
998 +
                if old < t {
999 +
                    let fork = node_hash(&old_root, &old_root);
1000 +
                    assert!(
1001 +
                        check_consistency(&c, t, &root, old, &fork).is_err(),
1002 +
                        "a forked history"
1003 +
                    );
1004 +
                }
1005 +
            }
1006 +
        }
1007 +
        assert_eq!(tree_hash(0, &stored).unwrap(), empty_root());
1008 +
    }
1009 +
1010 +
    #[test]
1011 +
    fn tiles_hold_the_stored_hashes() {
1012 +
        let n = 70_000u64;
1013 +
        let (stored, _) = grow(n);
1014 +
        let tiles = Tile::new_tiles(0, n);
1015 +
        // 273 full tiles of level 0 and a partial one, one full and one
1016 +
        // partial at level 1, a partial at level 2.
1017 +
        assert_eq!(tiles.iter().filter(|t| t.level == 0).count(), 274);
1018 +
        assert_eq!(tiles.iter().filter(|t| t.level == 2).count(), 1);
1019 +
        let datas: Vec<(Tile, Vec<u8>)> = tiles
1020 +
            .iter()
1021 +
            .map(|t| (*t, t.read_data(&stored).unwrap()))
1022 +
            .collect();
1023 +
        for (i, want) in stored.iter().enumerate().step_by(7) {
1024 +
            let (t, ..) = Tile::for_index(i as u64);
1025 +
            let (tile, data) = datas
1026 +
                .iter()
1027 +
                .find(|(d, _)| d.level == t.level && d.n == t.n && d.width >= t.width)
1028 +
                .expect("the tile holding it was written");
1029 +
            assert_eq!(
1030 +
                tile.hash_at(data, i as u64).unwrap(),
1031 +
                *want,
1032 +
                "stored hash {i}"
1033 +
            );
1034 +
        }
1035 +
        // Growing writes only what changed.
1036 +
        let grown = Tile::new_tiles(n, n + 300);
1037 +
        assert!(grown.iter().all(|t| t.level > 0 || t.n >= n / 256));
1038 +
    }
1039 +
1040 +
    #[test]
1041 +
    fn tile_paths() {
1042 +
        assert_eq!(index_path(1_234_067), "x001/x234/067");
1043 +
        assert_eq!(index_path(5), "005");
1044 +
        assert_eq!(index_path(1000), "x001/000");
1045 +
        assert_eq!(parse_index_path("x001/x234/067"), Some(1_234_067));
1046 +
        assert_eq!(parse_index_path("x001/234/067"), None);
1047 +
        let t = Tile {
1048 +
            level: 1,
1049 +
            n: 1234,
1050 +
            width: 17,
1051 +
        };
1052 +
        assert_eq!(t.path(), "tile/1/x001/234.p/17");
1053 +
        assert_eq!(Tile::parse("1/x001/234.p/17"), Some(t));
1054 +
        assert_eq!(
1055 +
            Tile {
1056 +
                level: 0,
1057 +
                n: 3,
1058 +
                width: 256
1059 +
            }
1060 +
            .entries_path(),
1061 +
            "tile/entries/003"
1062 +
        );
1063 +
        assert_eq!(Tile::parse("0/003.p/256"), None, "a full tile has no .p");
1064 +
    }
1065 +
1066 +
    #[test]
1067 +
    fn entries_and_bundles_round_trip() {
1068 +
        let e = Entry {
1069 +
            kind: "ref".into(),
1070 +
            repo: [7; 16],
1071 +
            object: ObjectRef([1; 32]),
1072 +
            signers: vec![ObjectRef([2; 32])],
1073 +
            received: 1_792_000_000_000,
1074 +
        };
1075 +
        assert_eq!(Entry::decode(&e.encode()).unwrap(), e);
1076 +
        let b = encode_bundle(&[e.encode(), b"x".to_vec()]).unwrap();
1077 +
        assert_eq!(decode_bundle(&b).unwrap(), vec![e.encode(), b"x".to_vec()]);
1078 +
        assert!(decode_bundle(&b[..b.len() - 1]).is_err());
1079 +
        assert_eq!(e.leaf_hash(), leaf_hash(&e.encode()));
1080 +
    }
1081 +
1082 +
    #[test]
1083 +
    fn checkpoints_are_signed_twice_and_checked() {
1084 +
        let sk = SecretKey::generate();
1085 +
        let hub = sk.identity("oxygenhub.io", "[email protected]", 1);
1086 +
        let (stored, _) = grow(10);
1087 +
        let c = Checkpoint {
1088 +
            origin: "oxygenhub.io/log".into(),
1089 +
            size: 10,
1090 +
            root: tree_hash(10, &stored).unwrap(),
1091 +
            extensions: vec![],
1092 +
        };
1093 +
        let note = c.sign(&hub, &sk).unwrap();
1094 +
        let served = note.encode();
1095 +
        assert!(served.starts_with("oxygenhub.io/log\n10\n"));
1096 +
        assert_eq!(served.matches("\n\u{2014} oxygenhub.io/log ").count(), 2);
1097 +
        let back = Note::parse(&served).unwrap();
1098 +
        assert_eq!(
1099 +
            Checkpoint::verify(&back, "oxygenhub.io/log", &hub).unwrap(),
1100 +
            c
1101 +
        );
1102 +
        // Another key, another origin, a changed size, a lost line: refused.
1103 +
        let other_sk = SecretKey::generate();
1104 +
        let other = other_sk.identity("x", "x@x", 1);
1105 +
        assert!(Checkpoint::verify(&back, "oxygenhub.io/log", &other).is_err());
1106 +
        assert!(Checkpoint::verify(&back, "elsewhere/log", &hub).is_err());
1107 +
        let forged = Note {
1108 +
            text: back.text.replace("\n10\n", "\n11\n"),
1109 +
            ..back.clone()
1110 +
        };
1111 +
        assert!(Checkpoint::verify(&forged, "oxygenhub.io/log", &hub).is_err());
1112 +
        let ed_only = Note {
1113 +
            sigs: vec![back.sigs[0].clone()],
1114 +
            ..back.clone()
1115 +
        };
1116 +
        assert!(
1117 +
            matches!(Checkpoint::verify(&ed_only, "oxygenhub.io/log", &hub), Err(TlogError::BadSignature(m)) if m.contains("composite")),
1118 +
            "the post-quantum line is required"
1119 +
        );
1120 +
        // The Ed25519 line is a standard note signature over the text.
1121 +
        let h = key_hash("oxygenhub.io/log", SIG_ED25519, &hub.ed25519_pk);
1122 +
        assert_eq!(back.sigs[0].key_hash, h);
1123 +
        assert!(hub
1124 +
            .verify_ed25519(back.text.as_bytes(), &back.sigs[0].sig)
1125 +
            .is_ok());
1126 +
    }
1127 +
}
modified Cargo.lock +2 −0
…
622 unchanged lines
623 623
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
624 624
dependencies = [
625 625
 "block-buffer",
626 +
 "const-oid",
626 627
 "crypto-common 0.2.2",
627 628
]
628 629
…
875 unchanged lines
1504 1505
 "rustls",
1505 1506
 "rustls-pemfile",
1506 1507
 "serde_json",
1508 +
 "sha2",
1507 1509
 "similar",
1508 1510
 "tempfile",
1509 1511
 "thiserror 1.0.69",
…
1443 unchanged lines
modified crates/oxy-core/Cargo.toml +1 −0
…
6 unchanged lines
7 7
8 8
[dependencies]
9 9
serde_json = "1"
10 +
sha2 = "0.11"
10 11
blake3 = { version = "1", features = ["rayon"] }
11 12
hex = "0.4"
12 13
rand = "0.8"
…
20 unchanged lines
modified crates/oxy-core/src/identity.rs +3 −0
…
322 unchanged lines
323 323
    Certificate,
324 324
    /// An author's grant to a certified subject (§14.7).
325 325
    Grant,
326 +
    /// A transparency log's checkpoint note (§14.8).
327 +
    Checkpoint,
326 328
}
327 329
328 330
impl Domain {
…
5 unchanged lines
334 336
            Domain::Challenge => "challenge",
335 337
            Domain::Certificate => "certificate",
336 338
            Domain::Grant => "grant",
339 +
            Domain::Checkpoint => "checkpoint",
337 340
        }
338 341
    }
339 342
}
…
433 unchanged lines
modified crates/oxy-core/src/lib.rs +1 −0
…
37 unchanged lines
38 38
pub mod strata;
39 39
pub mod subrepo;
40 40
pub mod timing;
41 +
pub mod tlog;
41 42
pub mod tree;
modified docs/spec.md +29 −1
…
619 unchanged lines
620 620
  ml   [3]: bytes(3309)
621 621
}
622 622
message = "oxy1 sig " || domain || 0x00 || KeyId || ObjectRef-being-signed
623 -
domain  = "change" | "attest" | "policy" | "certificate" | "grant"
623 +
domain  = "change" | "attest" | "policy" | "certificate" | "grant" | "checkpoint"
624 624
```
625 625
626 626
Both signatures are computed over the same `message`, which includes the `KeyId` of the composite pair. Verification MUST require both signatures to verify with the keys of the identity named by `key`. A signature with either half missing, empty, or failing is invalid. This is the same construction as the IETF LAMPS composite signature drafts and prevents stripping the post-quantum half.
…
128 unchanged lines
755 755
756 756
**Acceptance.** A host that issues certificates records when it accepted a reference attestation signed by a short-lived key: an `accepted` attestation (§14.4) naming that attestation's ObjectRef, its `hlc` the host's clock at the push, signed in domain `attest` by the issuer's key. The host keeps it indexed by the attestation it accepts (gc keeps it beside the bookmark's latest attestation) and serves it with the bookmark (§16, `refs` key 5). A fetch checks the certificate at the acceptance's time only when the acceptance names exactly the attestation being checked and verifies with the certificate's issuer; otherwise, including when there is none, at the attestation's own time. An acceptance by any other key changes nothing: the issuer is already trusted to vouch for the key, so it adds no trust it did not have.
757 757
758 +
### 14.8 The transparency log
759 +
760 +
A host keeps an append-only log of what it accepts, so it cannot show one history to some readers and another to others (oxygenhub#238, oxygenhub docs/transparency.md). Each accepted signed object (a reference or tag attestation, a seal, a rotation or revocation, a receipt, an approval, a policy, an identity, a certificate, an acceptance) becomes one entry, in the order the host accepted it:
761 +
762 +
```
763 +
Entry {
764 +
  version   [0]: 1
765 +
  kind      [1]: text           // "ref", "seal", "tag", "rotate", "revoke", "receipt", "approval",
766 +
                                //  "policy", "identity", "certificate", "accepted"
767 +
  repo      [2]: bytes(16)      // the repository's id; zero for identities
768 +
  object    [3]: ObjectRef      // the attestation, policy, identity or certificate
769 +
  signers   [4]: [KeyId]        // the keys whose signatures the host checked
770 +
  received  [5]: u64            // the host's clock, milliseconds (its claim)
771 +
}
772 +
```
773 +
774 +
**The tree** is RFC 6962's Merkle tree over the entries, with BLAKE3: a leaf's hash is `BLAKE3("oxygen-tlog-leaf\0" || cbor(Entry))`, an interior node's `BLAKE3("oxygen-tlog-node\0" || left || right)`, and the empty tree's root `BLAKE3("")`. A tree of n > 1 leaves splits at k, the largest power of two below n. Inclusion and consistency proofs are RFC 6962's (§2.1.1, §2.1.2), their hashes listed from the leaf upwards.
775 +
776 +
**Stored order.** Every hash of the tree is stored once, as soon as it is known: adding leaf n stores its hash and then the hash of each subtree it completes (one per trailing 1 bit of n). The hash of tree level L at position n is therefore at stored index `i(L, n)`, where n is first replaced L times by 2n+1, then i = n + ⌊n/2⌋ + ⌊n/4⌋ + … + L. The store is append-only.
777 +
778 +
**Tiles** (C2SP tlog-tiles, height 8). Level-L tile N holds the hashes of tree level 8L for leaves from N·256 on, 32 bytes each: 256 of them when full, fewer in a partial tile. Its path is `tile/<L>/<N>`, with `.p/<W>` appended for a partial tile of W hashes; N is written in groups of three digits, every group but the last prefixed `x` (1234067 is `x001/x234/067`). The entries of level-0 tile N are at `tile/entries/<N>[.p/<W>]`, each as a big-endian u16 length and its bytes. Full tiles never change.
779 +
780 +
**Checkpoints** (C2SP tlog-checkpoint) are C2SP signed notes. The text is the origin (the host's name and `/log`), the tree size in decimal and the root in base64, each line ending in a newline. It is signed twice under the origin's name:
781 +
- with the host key's Ed25519 half, as a standard note signature (type 0x01: key hash = SHA-256(name ‖ "\n" ‖ 0x01 ‖ public key)[:4], signature over the text), which note tools read;
782 +
- with the composite key, as type 0x4f (key hash over the identity object's bytes): the CBOR of a composite Signature in domain `checkpoint` over `BLAKE3("oxygen-tlog-note\0" || text)`.
783 +
784 +
A reader accepts a checkpoint only when both verify, so the log stays post-quantum. Which witnesses must cosign it, and how a client uses inclusion proofs, are §16's and oxygenhub docs/transparency.md's.
785 +
758 786
## 15. Transport
759 787
760 788
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