OXYGENHUB

The hub's transparency log (step 1 of docs/transparency.md) 965b950f9b96

The hub logs every signed object it accepts, in order, as entries of one
Merkle tree (Oxygen spec §14.8):
- from pushes: reference and tag attestations, seals, the policy, the
  hub's acceptances, and short-lived signers' certificates;
- kept receipts and sealed approvals;
- every certificate keyless/certify issues.

Each object is logged once (tlog_entries, migration 0076, SQLite and
Postgres), and a push before it is answered. The log is append-only
files in <data>/tlog/ (entries, offsets, stored hashes); a torn append
is cut back on start and its hashes rebuilt. A checkpoint is signed
every 5 seconds while entries arrive (Ed25519 and composite lines).

Served as C2SP tlog-tiles: /log/checkpoint, /log/tile/<L>/<N>[.p/<W>]
and /log/tile/entries/... (full tiles immutable), and a /log page. Tests:
a torn append keeps the root; a push's entries, checkpoint, tiles and
bundles check out against each other; nothing is logged twice. Pins
oxygen with the tlog library. Hub spec §13.5.3.

Refs #238
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key f6b4d04c81e3
When
· 2026-10-17 23:22 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
965b950f9b9657901127c61c6b689afee7d0c564d08d767a566a24a4719a3b59
Depends on
0024ccb1b050 026a76d251ce 070a7b64b368 18e381ea88c2 1f78655a0466 244724133fed 2a83f5303458 3556d08e17f9 380996db84e5 3b1bc27fe805 3dd6582de060 3eb491e9489b 42fa95540468 47e374020b59 559c39204234 617c2840446c 6ae3fa0af4f6 6ba07678ae86 6e006eaf1da4 8aa5ef384ef1 8f3d2ccfe6fc 93be9ce4653e aa0bfbd11f02 b64e2537d702 cc19697f8871 d23464c0ac60
Depended on by
ba0a55bb5b3c a66c05a6a881 ec84907043c2 166f6e9bf4f1 b5c199bd6b1a 9c04c779f032 e57765dee61a 13b27cb2200c 8a48dd830178 8dcaeeb848c6 05f1a85a2842 4f40eed0588f e5b97ac399e7
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
31 atoms, 6 tree operations, 17 files

Browse the tree at this change

added crates/hub/migrations-pg/0076_tlog_entries.sql +13 −0
1 +
-- The transparency log's index (oxygenhub#238, docs/transparency.md):
2 +
-- which entry logged which object, so a proof can be given for it and an
3 +
-- object is logged once. The log itself is append-only files in the data
4 +
-- directory (tlog/); these rows only point into it.
5 +
CREATE TABLE tlog_entries (
6 +
    idx         BIGINT PRIMARY KEY,
7 +
    object      TEXT NOT NULL,
8 +
    kind        TEXT NOT NULL,
9 +
    repo_id     BIGINT REFERENCES repositories(id) ON DELETE SET NULL,
10 +
    received_ms BIGINT NOT NULL
11 +
);
12 +
CREATE UNIQUE INDEX tlog_entries_object ON tlog_entries(object, kind);
13 +
CREATE INDEX tlog_entries_repo ON tlog_entries(repo_id, idx);
added crates/hub/migrations/0076_tlog_entries.sql +13 −0
1 +
-- The transparency log's index (oxygenhub#238, docs/transparency.md):
2 +
-- which entry logged which object, so a proof can be given for it and an
3 +
-- object is logged once. The log itself is append-only files in the data
4 +
-- directory (tlog/); these rows only point into it.
5 +
CREATE TABLE tlog_entries (
6 +
    idx         INTEGER PRIMARY KEY,
7 +
    object      TEXT NOT NULL,
8 +
    kind        TEXT NOT NULL,
9 +
    repo_id     INTEGER REFERENCES repositories(id) ON DELETE SET NULL,
10 +
    received_ms INTEGER NOT NULL
11 +
);
12 +
CREATE UNIQUE INDEX tlog_entries_object ON tlog_entries(object, kind);
13 +
CREATE INDEX tlog_entries_repo ON tlog_entries(repo_id, idx);
added crates/hub/src/db/tlog.rs +45 −0
1 +
//! The transparency log's index (migration 0076): which entry logged which
2 +
//! object. The log itself is files (`crate::tlog`).
3 +
4 +
use super::Pool;
5 +
use sqlx::FromRow;
6 +
7 +
#[derive(Clone, Debug, FromRow)]
8 +
pub struct Logged {
9 +
    pub idx: i64,
10 +
    pub object: String,
11 +
    pub kind: String,
12 +
    pub repo_id: Option<i64>,
13 +
    pub received_ms: i64,
14 +
}
15 +
16 +
/// The entry that logged `object` as `kind`, if one did.
17 +
pub async fn entry_of(pool: &Pool, object: &str, kind: &str) -> sqlx::Result<Option<i64>> {
18 +
    sqlx::query_scalar("SELECT idx FROM tlog_entries WHERE object = $1 AND kind = $2").bind(object).bind(kind).fetch_optional(pool).await
19 +
}
20 +
21 +
pub async fn record(pool: &Pool, idx: i64, object: &str, kind: &str, repo_id: Option<i64>, received_ms: i64) -> sqlx::Result<()> {
22 +
    sqlx::query("INSERT INTO tlog_entries (idx, object, kind, repo_id, received_ms) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING")
23 +
        .bind(idx)
24 +
        .bind(object)
25 +
        .bind(kind)
26 +
        .bind(repo_id)
27 +
        .bind(received_ms)
28 +
        .execute(pool)
29 +
        .await?;
30 +
    Ok(())
31 +
}
32 +
33 +
/// The newest entries, newest first; of one repository when given.
34 +
pub async fn recent(pool: &Pool, repo_id: Option<i64>, limit: i64) -> sqlx::Result<Vec<Logged>> {
35 +
    match repo_id {
36 +
        Some(r) => {
37 +
            sqlx::query_as("SELECT * FROM tlog_entries WHERE repo_id = $1 ORDER BY idx DESC LIMIT $2")
38 +
                .bind(r)
39 +
                .bind(limit)
40 +
                .fetch_all(pool)
41 +
                .await
42 +
        }
43 +
        None => sqlx::query_as("SELECT * FROM tlog_entries ORDER BY idx DESC LIMIT $1").bind(limit).fetch_all(pool).await,
44 +
    }
45 +
}
added crates/hub/src/tlog.rs +477 −0
1 +
//! The hub's transparency log (oxygenhub#238, docs/transparency.md, Oxygen
2 +
//! spec §14.8): one entry per signed object the hub accepts, in the order
3 +
//! it accepts them, kept as append-only files and served as C2SP
4 +
//! tlog-tiles with a signed checkpoint, so anyone can check the hub shows
5 +
//! everyone the same history.
6 +
//!
7 +
//! Files in `<data>/tlog/`:
8 +
//! - `entries`: each entry as a big-endian u16 length and its bytes;
9 +
//! - `offsets`: each entry's offset in `entries`, a u64 (little-endian);
10 +
//! - `hashes`: every hash of the tree in stored order (Oxygen spec §14.8);
11 +
//! - `checkpoint`: the latest signed checkpoint note (replaced, not
12 +
//!   appended, as it is only a signature over the others).
13 +
//!
14 +
//! Nothing is rewritten: a crash mid-append leaves at most a tail that is
15 +
//! cut back to the last whole entry on open, and the hashes are rebuilt
16 +
//! from the entries. The database's `tlog_entries` points from objects to
17 +
//! entry numbers.
18 +
19 +
use crate::{db, Shared};
20 +
use axum::extract::{Path as UrlPath, State};
21 +
use axum::http::{header, StatusCode};
22 +
use axum::response::{IntoResponse, Response};
23 +
use oxy_core::hash::{KeyId, ObjectRef};
24 +
use oxy_core::repo::{PushReport, Repo};
25 +
use oxy_core::tlog::{self, Checkpoint, Entry, Hash, HashReader, Tile, TlogError, TILE_WIDTH};
26 +
use std::fs::{File, OpenOptions};
27 +
use std::io::Write;
28 +
use std::os::unix::fs::FileExt;
29 +
use std::path::{Path, PathBuf};
30 +
use std::sync::Mutex;
31 +
32 +
/// How often a new checkpoint is signed while entries arrive.
33 +
pub const CHECKPOINT_EVERY: std::time::Duration = std::time::Duration::from_secs(5);
34 +
35 +
struct Files {
36 +
    entries: File,
37 +
    offsets: File,
38 +
    hashes: File,
39 +
    /// Entries in the log.
40 +
    size: u64,
41 +
    /// Bytes in `entries`.
42 +
    entries_len: u64,
43 +
}
44 +
45 +
/// The stored hashes in a file, by stored index.
46 +
struct FileHashes<'a>(&'a File);
47 +
48 +
impl HashReader for FileHashes<'_> {
49 +
    fn read_hashes(&self, indexes: &[u64]) -> Result<Vec<Hash>, TlogError> {
50 +
        indexes
51 +
            .iter()
52 +
            .map(|&i| {
53 +
                let mut h = [0u8; 32];
54 +
                self.0.read_exact_at(&mut h, i * 32).map_err(|e| TlogError::Read(format!("stored hash {i}: {e}")))?;
55 +
                Ok(h)
56 +
            })
57 +
            .collect()
58 +
    }
59 +
}
60 +
61 +
/// The log on disk.
62 +
pub struct Log {
63 +
    dir: PathBuf,
64 +
    /// The log's name in its checkpoints: the hub's host and `/log`.
65 +
    pub origin: String,
66 +
    files: Mutex<Files>,
67 +
    /// The latest signed checkpoint: its size and its note.
68 +
    checkpoint: Mutex<Option<(u64, String)>>,
69 +
}
70 +
71 +
fn read_u64_at(f: &File, at: u64) -> std::io::Result<u64> {
72 +
    let mut b = [0u8; 8];
73 +
    f.read_exact_at(&mut b, at)?;
74 +
    Ok(u64::from_le_bytes(b))
75 +
}
76 +
77 +
/// The origin of a hub's log: its host (and port) and `/log`.
78 +
pub fn origin_of(base_url: &str) -> String {
79 +
    let host = base_url.split_once("://").map(|(_, h)| h).unwrap_or(base_url);
80 +
    format!("{}/log", host.trim_end_matches('/'))
81 +
}
82 +
83 +
impl Log {
84 +
    /// Open (or start) the log in `dir`, recovering from an interrupted
85 +
    /// append.
86 +
    pub fn open(dir: &Path, origin: &str) -> anyhow::Result<Log> {
87 +
        std::fs::create_dir_all(dir)?;
88 +
        let open = |name: &str| OpenOptions::new().read(true).append(true).create(true).open(dir.join(name));
89 +
        let (entries, offsets, hashes) = (open("entries")?, open("offsets")?, open("hashes")?);
90 +
        let entries_on_disk = entries.metadata()?.len();
91 +
        // The whole entries: those whose offset was written and whose
92 +
        // bytes all were.
93 +
        let mut size = offsets.metadata()?.len() / 8;
94 +
        let mut entries_len = 0;
95 +
        while size > 0 {
96 +
            let off = read_u64_at(&offsets, (size - 1) * 8)?;
97 +
            let mut len = [0u8; 2];
98 +
            if off + 2 <= entries_on_disk && entries.read_exact_at(&mut len, off).is_ok() {
99 +
                let end = off + 2 + u16::from_be_bytes(len) as u64;
100 +
                if end <= entries_on_disk {
101 +
                    entries_len = end;
102 +
                    break;
103 +
                }
104 +
            }
105 +
            size -= 1;
106 +
        }
107 +
        offsets.set_len(size * 8)?;
108 +
        entries.set_len(entries_len)?;
109 +
        // The hashes of every leaf whose hashes were all written; the rest
110 +
        // are computed again from the entries.
111 +
        let have = hashes.metadata()?.len() / 32;
112 +
        let mut whole = size;
113 +
        while tlog::stored_hash_count(whole) > have {
114 +
            whole -= 1;
115 +
        }
116 +
        hashes.set_len(tlog::stored_hash_count(whole) * 32)?;
117 +
        let mut files = Files { entries, offsets, hashes, size: whole, entries_len };
118 +
        for n in whole..size {
119 +
            let bytes = Self::entry_bytes(&files, n)?;
120 +
            let add = tlog::stored_hashes_for_leaf(n, tlog::leaf_hash(&bytes), &FileHashes(&files.hashes))?;
121 +
            for h in add {
122 +
                files.hashes.write_all(&h)?;
123 +
            }
124 +
            files.size = n + 1;
125 +
        }
126 +
        files.size = size;
127 +
        files.hashes.sync_data()?;
128 +
        let checkpoint = std::fs::read_to_string(dir.join("checkpoint")).ok().and_then(|note| {
129 +
            let n = tlog::Note::parse(&note).ok()?;
130 +
            let c = Checkpoint::parse(&n.text).ok()?;
131 +
            (c.size <= size).then_some((c.size, note))
132 +
        });
133 +
        Ok(Log { dir: dir.to_path_buf(), origin: origin.to_string(), files: Mutex::new(files), checkpoint: Mutex::new(checkpoint) })
134 +
    }
135 +
136 +
    fn entry_bytes(f: &Files, n: u64) -> anyhow::Result<Vec<u8>> {
137 +
        let off = read_u64_at(&f.offsets, n * 8)?;
138 +
        let mut len = [0u8; 2];
139 +
        f.entries.read_exact_at(&mut len, off)?;
140 +
        let mut b = vec![0u8; u16::from_be_bytes(len) as usize];
141 +
        f.entries.read_exact_at(&mut b, off + 2)?;
142 +
        Ok(b)
143 +
    }
144 +
145 +
    /// Entries in the log.
146 +
    pub fn size(&self) -> u64 {
147 +
        self.files.lock().unwrap().size
148 +
    }
149 +
150 +
    /// Append entries, durably; their numbers.
151 +
    pub fn append(&self, entries: &[Entry]) -> anyhow::Result<Vec<u64>> {
152 +
        let mut f = self.files.lock().unwrap();
153 +
        let mut out = Vec::with_capacity(entries.len());
154 +
        for e in entries {
155 +
            let bytes = e.encode();
156 +
            let n = f.size;
157 +
            let add = tlog::stored_hashes_for_leaf(n, tlog::leaf_hash(&bytes), &FileHashes(&f.hashes))?;
158 +
            let off = f.entries_len;
159 +
            let mut rec = (bytes.len() as u16).to_be_bytes().to_vec();
160 +
            rec.extend_from_slice(&bytes);
161 +
            f.entries.write_all(&rec)?;
162 +
            f.entries_len += rec.len() as u64;
163 +
            f.offsets.write_all(&off.to_le_bytes())?;
164 +
            let mut hs = Vec::with_capacity(add.len() * 32);
165 +
            for h in add {
166 +
                hs.extend_from_slice(&h);
167 +
            }
168 +
            f.hashes.write_all(&hs)?;
169 +
            f.size = n + 1;
170 +
            out.push(n);
171 +
        }
172 +
        f.entries.sync_data()?;
173 +
        f.offsets.sync_data()?;
174 +
        f.hashes.sync_data()?;
175 +
        Ok(out)
176 +
    }
177 +
178 +
    /// The root of the first `n` entries.
179 +
    pub fn tree_hash(&self, n: u64) -> anyhow::Result<Hash> {
180 +
        let f = self.files.lock().unwrap();
181 +
        Ok(tlog::tree_hash(n, &FileHashes(&f.hashes))?)
182 +
    }
183 +
184 +
    /// Entry `n`'s inclusion proof in the tree of `t` entries.
185 +
    pub fn prove_inclusion(&self, t: u64, n: u64) -> anyhow::Result<Vec<Hash>> {
186 +
        let f = self.files.lock().unwrap();
187 +
        Ok(tlog::prove_inclusion(t, n, &FileHashes(&f.hashes))?)
188 +
    }
189 +
190 +
    /// The consistency proof from `n` entries to `t`.
191 +
    pub fn prove_consistency(&self, t: u64, n: u64) -> anyhow::Result<Vec<Hash>> {
192 +
        let f = self.files.lock().unwrap();
193 +
        Ok(tlog::prove_consistency(t, n, &FileHashes(&f.hashes))?)
194 +
    }
195 +
196 +
    /// A tile's hashes, when the log holds all of them.
197 +
    pub fn tile(&self, t: &Tile) -> anyhow::Result<Option<Vec<u8>>> {
198 +
        let f = self.files.lock().unwrap();
199 +
        let at_level = f.size >> (tlog::TILE_HEIGHT * t.level);
200 +
        if at_level < (t.n << tlog::TILE_HEIGHT) + t.width as u64 {
201 +
            return Ok(None);
202 +
        }
203 +
        Ok(Some(t.read_data(&FileHashes(&f.hashes))?))
204 +
    }
205 +
206 +
    /// The entry bundle beside level-0 tile `t`, when the log holds it.
207 +
    pub fn bundle(&self, t: &Tile) -> anyhow::Result<Option<Vec<u8>>> {
208 +
        let f = self.files.lock().unwrap();
209 +
        let first = t.n << tlog::TILE_HEIGHT;
210 +
        if f.size < first + t.width as u64 {
211 +
            return Ok(None);
212 +
        }
213 +
        let entries: Vec<Vec<u8>> = (first..first + t.width as u64).map(|n| Self::entry_bytes(&f, n)).collect::<anyhow::Result<_>>()?;
214 +
        Ok(Some(tlog::encode_bundle(&entries)?))
215 +
    }
216 +
217 +
    /// The latest signed checkpoint note.
218 +
    pub fn checkpoint(&self) -> Option<String> {
219 +
        self.checkpoint.lock().unwrap().as_ref().map(|(_, n)| n.clone())
220 +
    }
221 +
222 +
    /// Sign a checkpoint of the log as it is, unless the last one already
223 +
    /// covers it. True when a new one was signed.
224 +
    pub fn refresh_checkpoint(&self, identity: &oxy_core::identity::Identity, sk: &oxy_core::identity::SecretKey) -> anyhow::Result<bool> {
225 +
        let size = self.size();
226 +
        if self.checkpoint.lock().unwrap().as_ref().is_some_and(|(s, _)| *s >= size) {
227 +
            return Ok(false);
228 +
        }
229 +
        let c = Checkpoint { origin: self.origin.clone(), size, root: self.tree_hash(size)?, extensions: vec![] };
230 +
        let note = c.sign(identity, sk)?.encode();
231 +
        let tmp = self.dir.join("checkpoint.new");
232 +
        std::fs::write(&tmp, &note)?;
233 +
        File::open(&tmp)?.sync_all()?;
234 +
        std::fs::rename(&tmp, self.dir.join("checkpoint"))?;
235 +
        *self.checkpoint.lock().unwrap() = Some((size, note));
236 +
        Ok(true)
237 +
    }
238 +
}
239 +
240 +
/// An object to log.
241 +
#[derive(Clone, Debug)]
242 +
pub struct Pending {
243 +
    pub kind: &'static str,
244 +
    /// The repository's id (zero for none) and its row on this hub.
245 +
    pub repo: [u8; 16],
246 +
    pub repo_row: Option<i64>,
247 +
    pub object: ObjectRef,
248 +
    pub signers: Vec<KeyId>,
249 +
}
250 +
251 +
/// Log what is not logged yet. A failure is reported, never fatal: the
252 +
/// object stays accepted (enforcing logging before acceptance comes with
253 +
/// inclusion proofs in push responses).
254 +
pub async fn log(app: &Shared, items: Vec<Pending>) -> Vec<u64> {
255 +
    let mut fresh = Vec::new();
256 +
    for p in items {
257 +
        let object = p.object.to_hex();
258 +
        match db::tlog::entry_of(&app.db, &object, p.kind).await {
259 +
            Ok(Some(_)) => continue,
260 +
            Ok(None) => {}
261 +
            Err(e) => {
262 +
                eprintln!("tlog: looking up {} {}: {e}", p.kind, &object[..12]);
263 +
                continue;
264 +
            }
265 +
        }
266 +
        if !fresh.iter().any(|q: &Pending| q.kind == p.kind && q.object == p.object) {
267 +
            fresh.push(p);
268 +
        }
269 +
    }
270 +
    if fresh.is_empty() {
271 +
        return vec![];
272 +
    }
273 +
    let now = db::now_ms();
274 +
    let entries: Vec<Entry> = fresh
275 +
        .iter()
276 +
        .map(|p| Entry { kind: p.kind.to_string(), repo: p.repo, object: p.object, signers: p.signers.clone(), received: now as u64 })
277 +
        .collect();
278 +
    let tl = app.tlog.clone();
279 +
    let numbers = match tokio::task::spawn_blocking(move || tl.append(&entries)).await {
280 +
        Ok(Ok(n)) => n,
281 +
        Ok(Err(e)) => {
282 +
            eprintln!("tlog: appending {} entries: {e}", fresh.len());
283 +
            return vec![];
284 +
        }
285 +
        Err(e) => {
286 +
            eprintln!("tlog: appending: {e}");
287 +
            return vec![];
288 +
        }
289 +
    };
290 +
    for (p, n) in fresh.iter().zip(&numbers) {
291 +
        if let Err(e) = db::tlog::record(&app.db, *n as i64, &p.object.to_hex(), p.kind, p.repo_row, now).await {
292 +
            eprintln!("tlog: indexing entry {n}: {e}");
293 +
        }
294 +
    }
295 +
    numbers
296 +
}
297 +
298 +
fn sig_keys(sigs: &[oxy_core::identity::Signature]) -> Vec<KeyId> {
299 +
    let mut keys: Vec<KeyId> = sigs.iter().map(|s| s.key).collect();
300 +
    keys.sort();
301 +
    keys.dedup();
302 +
    keys
303 +
}
304 +
305 +
/// The certificates of short-lived keys among `signers`, to log beside
306 +
/// what they signed.
307 +
fn certificates(repo: &Repo, rid: [u8; 16], row: Option<i64>, signers: &[KeyId], out: &mut Vec<Pending>) {
308 +
    for k in signers {
309 +
        if let Ok(Some(c)) = repo.store().certificate_of(k) {
310 +
            let issuer = repo.store().get_certificate(&c).map(|sc| vec![sc.cert.issuer]).unwrap_or_default();
311 +
            out.push(Pending { kind: "certificate", repo: rid, repo_row: row, object: c, signers: issuer });
312 +
        }
313 +
    }
314 +
}
315 +
316 +
/// What a push brought that the log keeps: the attestations of the
317 +
/// references it moved (and the hub's acceptance of them), tags, seals,
318 +
/// the repository's policy, and the certificates of short-lived signers.
319 +
pub fn push_entries(repo: &Repo, row: Option<i64>, report: &PushReport) -> Vec<Pending> {
320 +
    let Ok(rid) = repo.id() else { return vec![] };
321 +
    let mut out = Vec::new();
322 +
    let add = |out: &mut Vec<Pending>, kind: &'static str, object: ObjectRef, signers: Vec<KeyId>| {
323 +
        certificates(repo, rid, row, &signers, out);
324 +
        out.push(Pending { kind, repo: rid, repo_row: row, object, signers });
325 +
    };
326 +
    for u in &report.updated {
327 +
        if u.new_state.is_zero() || !report.results.iter().any(|r| r.ok && r.name == u.name) {
328 +
            continue;
329 +
        }
330 +
        if let Some(tag) = u.name.strip_prefix("tags/") {
331 +
            if let Ok(Some((r, sa))) = repo.tag_attest(tag) {
332 +
                add(&mut out, "tag", r, sig_keys(&sa.sigs));
333 +
            }
334 +
        } else if let Ok(Some((r, sa))) = repo.latest_ref_attest(&u.name) {
335 +
            add(&mut out, "ref", r, sig_keys(&sa.sigs));
336 +
            if let Ok(Some(acc)) = repo.acceptance_of(&r) {
337 +
                add(&mut out, "accepted", acc.attest.hash(), sig_keys(&acc.sigs));
338 +
            }
339 +
        }
340 +
    }
341 +
    let mut seals = std::collections::BTreeSet::new();
342 +
    for h in &report.imported {
343 +
        for s in repo.store().seals_for(h).unwrap_or_default() {
344 +
            seals.insert(s);
345 +
        }
346 +
    }
347 +
    for s in seals {
348 +
        let signers = sig_keys(&repo.store().signatures(&s).unwrap_or_default());
349 +
        add(&mut out, "seal", s, signers);
350 +
    }
351 +
    if let Ok(Some((r, sp))) = repo.current_policy() {
352 +
        add(&mut out, "policy", r, sig_keys(&sp.sigs));
353 +
    }
354 +
    out
355 +
}
356 +
357 +
/// Sign a new checkpoint every few seconds while entries arrive.
358 +
pub async fn checkpointer(app: Shared) {
359 +
    let mut tick = tokio::time::interval(CHECKPOINT_EVERY);
360 +
    loop {
361 +
        tick.tick().await;
362 +
        let (identity, keys) = (app.hub_key.identity.clone(), app.config.keys_dir());
363 +
        let tl = app.tlog.clone();
364 +
        let done = tokio::task::spawn_blocking(move || -> anyhow::Result<bool> {
365 +
            let (_, sk) = crate::keys::load_secret(&keys)?;
366 +
            tl.refresh_checkpoint(&identity, &sk)
367 +
        })
368 +
        .await;
369 +
        if let Ok(Err(e)) = done {
370 +
            eprintln!("tlog: signing a checkpoint: {e}");
371 +
        }
372 +
    }
373 +
}
374 +
375 +
fn plain(status: StatusCode, why: &str) -> Response {
376 +
    (status, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], format!("{why}\n")).into_response()
377 +
}
378 +
379 +
/// `GET /log/checkpoint`: the latest signed checkpoint (C2SP
380 +
/// tlog-checkpoint, a signed note).
381 +
pub async fn checkpoint(State(app): State<Shared>) -> Response {
382 +
    match app.tlog.checkpoint() {
383 +
        Some(note) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8"), (header::CACHE_CONTROL, "no-cache")], note).into_response(),
384 +
        None => plain(StatusCode::SERVICE_UNAVAILABLE, "no checkpoint yet: the log signs one within seconds"),
385 +
    }
386 +
}
387 +
388 +
/// `GET /log/tile/{L}/{N}[.p/{W}]` and `/log/tile/entries/{N}[.p/{W}]`:
389 +
/// a tile of hashes or an entry bundle (C2SP tlog-tiles). Full tiles never
390 +
/// change and are cached for good.
391 +
pub async fn tile(State(app): State<Shared>, UrlPath(path): UrlPath<String>) -> Response {
392 +
    let (entries, rest) = match path.strip_prefix("entries/") {
393 +
        Some(r) => (true, format!("0/{r}")),
394 +
        None => (false, path.clone()),
395 +
    };
396 +
    let Some(t) = Tile::parse(&rest) else {
397 +
        return plain(StatusCode::NOT_FOUND, "no such tile");
398 +
    };
399 +
    let tl = app.tlog.clone();
400 +
    let got = tokio::task::spawn_blocking(move || if entries { tl.bundle(&t) } else { tl.tile(&t) }).await;
401 +
    match got {
402 +
        Ok(Ok(Some(data))) => {
403 +
            let cache = if t.width as u64 == TILE_WIDTH { "public, max-age=31536000, immutable" } else { "public, max-age=5" };
404 +
            ([(header::CONTENT_TYPE, "application/octet-stream"), (header::CACHE_CONTROL, cache)], data).into_response()
405 +
        }
406 +
        Ok(Ok(None)) => plain(StatusCode::NOT_FOUND, "the log does not hold that tile yet"),
407 +
        Ok(Err(e)) => plain(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
408 +
        Err(e) => plain(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
409 +
    }
410 +
}
411 +
412 +
#[cfg(test)]
413 +
mod tests {
414 +
    use super::*;
415 +
416 +
    fn entry(i: u64) -> Entry {
417 +
        Entry { kind: "ref".into(), repo: [1; 16], object: ObjectRef([i as u8; 32]), signers: vec![], received: i }
418 +
    }
419 +
420 +
    #[test]
421 +
    fn the_log_survives_a_torn_append_and_keeps_its_root() {
422 +
        let dir = tempfile::tempdir().unwrap();
423 +
        let log = Log::open(dir.path(), "hub.example/log").unwrap();
424 +
        let first: Vec<Entry> = (0..300).map(entry).collect();
425 +
        assert_eq!(log.append(&first).unwrap(), (0..300).collect::<Vec<_>>());
426 +
        let root = log.tree_hash(300).unwrap();
427 +
        // Leaves alone, as RFC 6962 roots them.
428 +
        let mut stored: Vec<Hash> = Vec::new();
429 +
        for (n, e) in first.iter().enumerate() {
430 +
            stored.extend(tlog::stored_hashes_for_leaf(n as u64, e.leaf_hash(), &stored).unwrap());
431 +
        }
432 +
        assert_eq!(root, tlog::tree_hash(300, &stored).unwrap());
433 +
        drop(log);
434 +
        // A crash after the entry and part of the hashes were written.
435 +
        let torn = dir.path().join("hashes");
436 +
        let len = std::fs::metadata(&torn).unwrap().len();
437 +
        OpenOptions::new().write(true).open(&torn).unwrap().set_len(len - 40).unwrap();
438 +
        let mut f = OpenOptions::new().append(true).open(dir.path().join("entries")).unwrap();
439 +
        f.write_all(&[0, 200, 1, 2]).unwrap(); // a partial entry
440 +
        let log = Log::open(dir.path(), "hub.example/log").unwrap();
441 +
        assert_eq!(log.size(), 300);
442 +
        assert_eq!(log.tree_hash(300).unwrap(), root);
443 +
        log.append(&[entry(300)]).unwrap();
444 +
        assert_eq!(log.size(), 301);
445 +
        let p = log.prove_inclusion(301, 7).unwrap();
446 +
        tlog::check_inclusion(&p, 301, &log.tree_hash(301).unwrap(), 7, &first[7].leaf_hash()).unwrap();
447 +
        let c = log.prove_consistency(301, 300).unwrap();
448 +
        tlog::check_consistency(&c, 301, &log.tree_hash(301).unwrap(), 300, &root).unwrap();
449 +
        // Tiles and bundles: the full level-0 tile and the partial one.
450 +
        let full = log.tile(&Tile { level: 0, n: 0, width: 256 }).unwrap().unwrap();
451 +
        assert_eq!(full.len(), 256 * 32);
452 +
        assert!(log.tile(&Tile { level: 0, n: 1, width: 45 }).unwrap().is_some());
453 +
        assert!(log.tile(&Tile { level: 0, n: 1, width: 46 }).unwrap().is_none(), "not yet");
454 +
        let bundle = log.bundle(&Tile { level: 0, n: 1, width: 45 }).unwrap().unwrap();
455 +
        assert_eq!(tlog::decode_bundle(&bundle).unwrap()[0], first[256].encode());
456 +
    }
457 +
458 +
    #[test]
459 +
    fn checkpoints_are_signed_when_the_log_grows() {
460 +
        let dir = tempfile::tempdir().unwrap();
461 +
        let sk = oxy_core::identity::SecretKey::generate();
462 +
        let hub = sk.identity("hub", "[email protected]", 1);
463 +
        let log = Log::open(dir.path(), &origin_of("https://hub.example:7343/")).unwrap();
464 +
        assert_eq!(log.origin, "hub.example:7343/log");
465 +
        assert!(log.refresh_checkpoint(&hub, &sk).unwrap(), "the empty log's");
466 +
        assert!(!log.refresh_checkpoint(&hub, &sk).unwrap(), "nothing new");
467 +
        log.append(&[entry(1), entry(2)]).unwrap();
468 +
        assert!(log.refresh_checkpoint(&hub, &sk).unwrap());
469 +
        let note = tlog::Note::parse(&log.checkpoint().unwrap()).unwrap();
470 +
        let c = Checkpoint::verify(&note, "hub.example:7343/log", &hub).unwrap();
471 +
        assert_eq!((c.size, c.root), (2, log.tree_hash(2).unwrap()));
472 +
        // Kept across a restart.
473 +
        drop(log);
474 +
        let log = Log::open(dir.path(), "hub.example:7343/log").unwrap();
475 +
        assert_eq!(log.checkpoint().map(|n| n.contains("\n2\n")), Some(true));
476 +
    }
477 +
}
added crates/hub/src/web/transparency.rs +60 −0
1 +
//! `/log` (oxygenhub#238): the transparency log's latest checkpoint and
2 +
//! its newest entries, with where to fetch the rest (C2SP tlog-tiles).
3 +
4 +
use super::{filters, Page, Shell};
5 +
use crate::auth::Viewer;
6 +
use crate::{db, Shared};
7 +
use askama::Template;
8 +
use axum::extract::State;
9 +
10 +
pub struct Row {
11 +
    pub idx: i64,
12 +
    pub kind: String,
13 +
    pub object: String,
14 +
    /// `owner/name`, when the entry is a repository's this hub still has.
15 +
    pub repo: Option<String>,
16 +
    pub received_ms: i64,
17 +
}
18 +
19 +
#[derive(Template)]
20 +
#[template(path = "log.html")]
21 +
pub struct LogPage {
22 +
    shell: Shell,
23 +
    origin: String,
24 +
    size: u64,
25 +
    note: Option<String>,
26 +
    base: String,
27 +
    rows: Vec<Row>,
28 +
}
29 +
30 +
pub async fn page(State(app): State<Shared>, viewer: Viewer) -> Page<LogPage> {
31 +
    let shell = Shell::new(&app, "/log", &viewer);
32 +
    let recent = db::tlog::recent(&app.db, None, 50).await.unwrap_or_default();
33 +
    let mut rows = Vec::with_capacity(recent.len());
34 +
    for r in recent {
35 +
        // Entries of private repositories show only their hashes.
36 +
        let repo = match r.repo_id {
37 +
            Some(id) => match db::repos::get_by_id(&app.db, id).await {
38 +
                Ok(Some(row))
39 +
                    if crate::auth::roles::allows(
40 +
                        crate::auth::roles::role_for(&app.db, viewer.account(), &row).await.unwrap_or(None),
41 +
                        crate::auth::roles::Access::Read,
42 +
                    ) =>
43 +
                {
44 +
                    Some(row.slug())
45 +
                }
46 +
                _ => None,
47 +
            },
48 +
            None => None,
49 +
        };
50 +
        rows.push(Row { idx: r.idx, kind: r.kind, object: r.object, repo, received_ms: r.received_ms });
51 +
    }
52 +
    Page(LogPage {
53 +
        shell,
54 +
        origin: app.tlog.origin.clone(),
55 +
        size: app.tlog.size(),
56 +
        note: app.tlog.checkpoint(),
57 +
        base: app.config.base_url().trim_end_matches('/').to_string(),
58 +
        rows,
59 +
    })
60 +
}
added templates/log.html +21 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}Transparency log{% endblock %}
3 +
{% block content %}
4 +
<div class="head"><h1>Transparency log <small>{{ size }} entries, append-only</small></h1></div>
5 +
<section class="panel">
6 +
  <p>Every signed object this hub accepts (pushed references and tags, seals, policies, receipts, approvals, certificates and the hub's acceptances) is an entry in one Merkle tree, in the order it was accepted. Anyone can fetch the tree and check that the hub shows everyone the same history (Oxygen spec §14.8).</p>
7 +
  <ul class="small">
8 +
    <li>Checkpoint: <a href="/log/checkpoint"><code>{{ base }}/log/checkpoint</code></a> (a C2SP signed note, origin <code>{{ origin }}</code>, signed with <a href="/.well-known/oxyhub-key">the hub's key</a>)</li>
9 +
    <li>Tiles and entries: <code>{{ base }}/log/tile/…</code> (C2SP tlog-tiles, height 8)</li>
10 +
  </ul>
11 +
  {% if let Some(n) = note %}<pre class="small">{{ n }}</pre>{% else %}<p class="empty">No checkpoint yet: the hub signs one within seconds.</p>{% endif %}
12 +
</section>
13 +
{% if rows.is_empty() %}<p class="empty">Nothing has been logged yet.</p>{% else %}
14 +
<div class="table"><table>
15 +
<tr><th>#</th><th>When</th><th>Kind</th><th>Repository</th><th>Object</th></tr>
16 +
{% for r in rows %}
17 +
<tr><td class="px">{{ r.idx }}</td><td><time title="{{ r.received_ms|when_i }}">{{ r.received_ms|ago_i }}</time></td><td>{{ r.kind }}</td><td>{% if let Some(s) = r.repo %}<a href="/{{ s }}">{{ s }}</a>{% else %}<span class="mute">—</span>{% endif %}</td><td class="small"><code title="{{ r.object }}">{{ r.object|short }}</code></td></tr>
18 +
{% endfor %}
19 +
</table></div>
20 +
{% endif %}
21 +
{% endblock %}
modified Cargo.lock +5 −3
…
909 unchanged lines
910 910
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
911 911
dependencies = [
912 912
 "block-buffer 0.12.1",
913 +
 "const-oid 0.10.2",
913 914
 "crypto-common 0.2.2",
914 915
]
915 916
…
1385 unchanged lines
2301 2302
2302 2303
[[package]]
2303 2304
name = "oxy-core"
2304 -
version = "0.4.5"
2305 +
version = "0.4.6"
2305 2306
dependencies = [
2306 2307
 "blake3",
2307 2308
 "ed25519-dalek",
…
8 unchanged lines
2316 2317
 "rustls",
2317 2318
 "rustls-pemfile",
2318 2319
 "serde_json",
2320 +
 "sha2 0.11.0",
2319 2321
 "similar",
2320 2322
 "thiserror 1.0.69",
2321 2323
 "ureq",
…
3 unchanged lines
2325 2327
2326 2328
[[package]]
2327 2329
name = "oxy-server"
2328 -
version = "0.4.5"
2330 +
version = "0.4.6"
2329 2331
dependencies = [
2330 2332
 "axum",
2331 2333
 "axum-server",
…
4 unchanged lines
2336 2338
2337 2339
[[package]]
2338 2340
name = "oxy-strata"
2339 -
version = "0.4.5"
2341 +
version = "0.4.6"
2340 2342
dependencies = [
2341 2343
 "async-trait",
2342 2344
 "bytes",
…
2529 unchanged lines
modified crates/hub/src/db/mod.rs +1 −0
…
13 unchanged lines
14 14
pub mod repos;
15 15
pub mod runners;
16 16
pub mod sessions;
17 +
pub mod tlog;
17 18
18 19
#[cfg(not(feature = "postgres"))]
19 20
pub use sqlite::{begin_write, open, Db};
…
147 unchanged lines
modified crates/hub/src/flow.rs +11 −0
…
339 unchanged lines
340 340
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?;
341 341
    let job = jobs.iter().find(|j| j.name == name).ok_or("no such job")?;
342 342
    may_decide(app, row, run, job, &who).await?;
343 +
    let (rid, signers) = (sa.attest.repo, sa.sigs.iter().map(|s| s.key).collect::<Vec<_>>());
343 344
    let kept = app.repos.write(&repo_name, move |r, _| Ok(r.add_approval(&sa)?)).await.map_err(|e| e.to_string())?;
345 +
    crate::tlog::log(app, vec![crate::tlog::Pending { kind: "approval", repo: rid, repo_row: Some(row.id), object: kept, signers }]).await;
344 346
    decide_with(app, row, run, name, &who, given.approve, Some(&kept.to_hex())).await
345 347
}
346 348
…
1968 unchanged lines
2315 2317
        }
2316 2318
    };
2317 2319
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
2320 +
    let (rid, signers) = (sr.attest.repo, sr.sigs.iter().map(|s| s.key).collect::<Vec<_>>());
2321 +
    let cert = sr.certificate.as_ref().map(|c| (c.cert.cert.hash(), c.cert.cert.issuer));
2318 2322
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
2323 +
    // The transparency log (#238): the receipt, and a short-lived key's
2324 +
    // certificate beside it.
2325 +
    let mut entries = vec![crate::tlog::Pending { kind: "receipt", repo: rid, repo_row: Some(row.id), object: attest, signers }];
2326 +
    if let Some((c, issuer)) = cert {
2327 +
        entries.push(crate::tlog::Pending { kind: "certificate", repo: rid, repo_row: Some(row.id), object: c, signers: vec![issuer] });
2328 +
    }
2329 +
    crate::tlog::log(app, entries).await;
2319 2330
    Ok((attest.to_hex(), signer))
2320 2331
}
2321 2332
…
202 unchanged lines
modified crates/hub/src/keyless.rs +12 −0
…
210 unchanged lines
211 211
    let signed = SignedCertificate::sign(cert, &app.hub_key.identity, &secret);
212 212
    let detail = format!("{} for {} until {}", signed.cert.key_id().short(12), proven.subject, expires);
213 213
    db::accounts::audit(&app.db, None, "keyless.certified", &proven.target, &detail).await.ok();
214 +
    // Every certificate is logged (#238), as Fulcio's are in Rekor.
215 +
    crate::tlog::log(
216 +
        &app,
217 +
        vec![crate::tlog::Pending {
218 +
            kind: "certificate",
219 +
            repo: [0; 16],
220 +
            repo_row: None,
221 +
            object: signed.cert.hash(),
222 +
            signers: vec![app.hub_key.key],
223 +
        }],
224 +
    )
225 +
    .await;
214 226
    Ok(Json(CertifyResponse { certificate: oxy_core::dsse::base64(&signed.encode()), subject: proven.subject, expires_ms: expires }))
215 227
}
216 228
…
143 unchanged lines
modified crates/hub/src/lib.rs +6 −0
…
39 unchanged lines
40 40
pub mod storage;
41 41
pub mod swarm;
42 42
pub mod throttle;
43 +
pub mod tlog;
43 44
pub mod variables;
44 45
pub mod web;
45 46
pub mod wiki;
…
32 unchanged lines
78 79
    /// The image swarm's tracker (oxygenhub#144).
79 80
    pub swarm: swarm::Tracker,
80 81
    pub started: std::time::Instant,
82 +
    /// The transparency log (#238).
83 +
    pub tlog: Arc<tlog::Log>,
81 84
}
82 85
83 86
impl App {
…
33 unchanged lines
117 120
    let slot = backend.app_slot();
118 121
    let server = oxy_server::Server::new(backend);
119 122
    let mail = mail::load(&db, &config.email).await;
123 +
    let tlog = Arc::new(tlog::Log::open(&config.data.join("tlog"), &tlog::origin_of(config.base_url()))?);
120 124
    let app = Arc::new(App {
121 125
        mail: std::sync::RwLock::new(mail),
122 126
        config,
…
12 unchanged lines
135 139
        rooms: Arc::new(live::Rooms::default()),
136 140
        swarm: Default::default(),
137 141
        started: std::time::Instant::now(),
142 +
        tlog,
138 143
    });
139 144
    *slot.lock().unwrap() = Some(Arc::downgrade(&app));
140 145
    storage::load(&app).await?;
…
1 unchanged lines
142 147
    junit::load(&app).await?;
143 148
    reports::load(&app).await?;
144 149
    tokio::spawn(hooks::worker(app.clone()));
150 +
    tokio::spawn(tlog::checkpointer(app.clone()));
145 151
    tokio::spawn(flow::reaper(app.clone()));
146 152
    tokio::spawn(flow::scheduler(app.clone()));
147 153
    tokio::spawn(flow::placement(app.clone()));
…
197 unchanged lines
modified crates/hub/src/protocol.rs +9 −0
…
345 unchanged lines
346 346
            .filter(|u| u.old_state != u.new_state && report.results.iter().any(|r| r.ok && r.name == u.name))
347 347
            .map(|u| (u.name.clone(), u.new_state.to_hex(), u.heads.clone()))
348 348
            .collect();
349 +
        // The transparency log (#238): what the push brought, logged before
350 +
        // the push is answered.
351 +
        let app = self.app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade());
352 +
        if let Some(app) = app {
353 +
            let to_log = self.repos.open(&parsed).map(|r| crate::tlog::push_entries(&r, Some(row.id), report)).unwrap_or_default();
354 +
            if !to_log.is_empty() {
355 +
                self.block(crate::tlog::log(&app, to_log));
356 +
            }
357 +
        }
349 358
        let app_hint = self.app_hint.clone();
350 359
        self.runtime.spawn(async move {
351 360
            let who = pusher.as_ref().map(|(id, name)| (*id, name.as_str()));
…
98 unchanged lines
modified crates/hub/src/web/mod.rs +4 −0
…
20 unchanged lines
21 21
pub mod icons;
22 22
mod live_stream;
23 23
mod security_txt;
24 +
mod transparency;
24 25
25 26
/// A state as people read it (oxygenhub#170): `02a6c9d4746a+… (14 heads)`
26 27
/// for heads joined by `+`, which can run to hundreds of characters; a
…
52 unchanged lines
79 80
        .route("/health", get(home::health))
80 81
        .route("/about", get(home::about))
81 82
        .route("/.well-known/oxyhub-key", get(home::hub_key))
83 +
        .route("/log", get(transparency::page))
84 +
        .route("/log/checkpoint", get(crate::tlog::checkpoint))
85 +
        .route("/log/tile/{*path}", get(crate::tlog::tile))
82 86
        .route("/.well-known/oxyhub-ca.pem", get(home::hub_ca))
83 87
        .route("/.well-known/openid-configuration", get(crate::oidc::discovery))
84 88
        .route("/.well-known/jwks.json", get(crate::oidc::jwks))
…
709 unchanged lines
modified crates/hub/tests/flow.rs +68 −0
…
4952 unchanged lines
4953 4953
    assert!(!page.contains("not one this hub"), "not an unknown key");
4954 4954
}
4955 4955
4956 +
/// The transparency log (oxygenhub#238): a push's reference attestation,
4957 +
/// seal and policy become entries; the checkpoint is signed by the hub's
4958 +
/// key; the tiles and entry bundles served rebuild the checkpoint's root;
4959 +
/// and the `/log` page lists them.
4960 +
#[tokio::test(flavor = "multi_thread")]
4961 +
async fn a_push_is_logged_and_the_log_checks_out() {
4962 +
    use oxy_core::tlog::{self, Checkpoint, Entry, Hash, Note, Tile};
4963 +
    let h = hub().await;
4964 +
    create(&h.app, "andy", "demo").await;
4965 +
    let url = format!("{}/andy/demo", h.base);
4966 +
    let work = tempfile::tempdir().unwrap();
4967 +
    let mut c = client(&url, work.path());
4968 +
    c.create_identity("Andy", "[email protected]", oxy_core::repo::IdentityScope::Repo).unwrap();
4969 +
    write(work.path(), "a.txt", "a\n");
4970 +
    c.commit("a", &[]).unwrap();
4971 +
    c.policy_init().unwrap();
4972 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
4973 +
    let host = h.app.repos.open(&RepoName::parse("andy/demo").unwrap()).unwrap();
4974 +
    let (attest, _) = host.latest_ref_attest("main").unwrap().unwrap();
4975 +
4976 +
    // Sign a checkpoint now rather than wait for the next tick.
4977 +
    let (_, sk) = oxygenhub::keys::load_secret(&h.app.config.keys_dir()).unwrap();
4978 +
    h.app.tlog.refresh_checkpoint(&h.app.hub_key.identity, &sk).unwrap();
4979 +
    let (st, note) = call(&h.app, "GET", "/log/checkpoint", None, None).await;
4980 +
    assert_eq!(st, StatusCode::OK, "{note}");
4981 +
    let origin = oxygenhub::tlog::origin_of(h.app.config.base_url());
4982 +
    let cp = Checkpoint::verify(&Note::parse(&note).unwrap(), &origin, &h.app.hub_key.identity).unwrap();
4983 +
    assert!(cp.size >= 3, "ref, seal and policy: {}", cp.size);
4984 +
4985 +
    // The entries, from the bundle beside the partial level-0 tile.
4986 +
    let bundle_path = Tile { level: 0, n: 0, width: cp.size as u32 }.entries_path();
4987 +
    let (st, _) = call(&h.app, "GET", &format!("/log/{bundle_path}"), None, None).await;
4988 +
    assert_eq!(st, StatusCode::OK);
4989 +
    let res =
4990 +
        oxygenhub::router(h.app.clone()).oneshot(Request::get(format!("/log/{bundle_path}")).body(Body::empty()).unwrap()).await.unwrap();
4991 +
    assert_eq!(res.headers()[header::CACHE_CONTROL], "public, max-age=5", "a partial tile changes");
4992 +
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
4993 +
    let entries: Vec<Entry> = tlog::decode_bundle(&bytes).unwrap().iter().map(|b| Entry::decode(b).unwrap()).collect();
4994 +
    let kinds: Vec<&str> = entries.iter().map(|e| e.kind.as_str()).collect();
4995 +
    assert!(kinds.contains(&"ref") && kinds.contains(&"seal") && kinds.contains(&"policy"), "{kinds:?}");
4996 +
    let r = entries.iter().find(|e| e.kind == "ref").unwrap();
4997 +
    assert_eq!(r.object, attest);
4998 +
    assert_eq!(r.repo, c.id().unwrap());
4999 +
5000 +
    // The leaves rebuild the checkpoint's root, and the hash tile served
5001 +
    // is what they make.
5002 +
    let mut stored: Vec<Hash> = Vec::new();
5003 +
    for (n, e) in entries.iter().enumerate() {
5004 +
        stored.extend(tlog::stored_hashes_for_leaf(n as u64, e.leaf_hash(), &stored).unwrap());
5005 +
    }
5006 +
    assert_eq!(tlog::tree_hash(cp.size, &stored).unwrap(), cp.root);
5007 +
    let t = Tile { level: 0, n: 0, width: cp.size as u32 };
5008 +
    let res =
5009 +
        oxygenhub::router(h.app.clone()).oneshot(Request::get(format!("/log/{}", t.path())).body(Body::empty()).unwrap()).await.unwrap();
5010 +
    assert_eq!(res.status(), StatusCode::OK);
5011 +
    let data = res.into_body().collect().await.unwrap().to_bytes();
5012 +
    assert_eq!(data.to_vec(), t.read_data(&stored).unwrap());
5013 +
    let (st, _) = call(&h.app, "GET", "/log/tile/0/000.p/200", None, None).await;
5014 +
    assert_eq!(st, StatusCode::NOT_FOUND, "not held yet");
5015 +
5016 +
    // Pushing the same again logs nothing new.
5017 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
5018 +
    assert_eq!(h.app.tlog.size(), cp.size);
5019 +
    let (st, page) = call(&h.app, "GET", "/log", None, None).await;
5020 +
    assert_eq!(st, StatusCode::OK);
5021 +
    assert!(page.contains("Transparency log") && page.contains("andy/demo") && page.contains(&origin), "{page}");
5022 +
}
5023 +
4956 5024
/// Keyless step 4c (oxygenhub#241): when a key the hub certified moves a
4957 5025
/// bookmark, the hub signs when it accepted the update and serves that
4958 5026
/// with the bookmark, so a fetch checks the certificate at the hub's time
…
181 unchanged lines
replaced deps/oxygenflow +0 −0

No line changes.

modified docs/spec.md +14 −0
…
593 unchanged lines
594 594
595 595
**Agent grants** (step 5, built 2026-10-17). An agent holds no key: its owner signs an Oxygen grant (§14.7) letting `agent:NAME@ACCOUNT` act for them, with the key their oxy home names (`oh agent grant NAME [--scope changes,seal,refs] [--for 30d]`), and `POST /api/v1/agents/grants {"grant": base64 signed grant}` keeps it and answers with the agent's token (`oxyagent_…`, shown once, kept as its BLAKE3 hash). The hub accepts a grant only for the caller's own `agent:NAME@USERNAME`, naming this hub's key as issuer, signed by a key linked to the caller's account and not yet ended; any personal token of the account may send it, since the signature is what authorises it. The agent exchanges the token at `keyless/certify` for ten-minute certificates carrying the grant (claims `agent` and `grant`, scope at most the grant's, never past its end), so what its fresh keys sign counts in the owner's name; oxy does this itself with `author.signer = keyless` and the token from `oxy auth grant`. The token is also the agent's credential for the protocol (fetch and push): it acts as an agent's token of the owner's account, writing when the grant gives `refs` and reading otherwise, so the agent's home holds no other secret, and oxy offers it when the home has no hub token. `GET /api/v1/agents/grants` lists the caller's grants without tokens; `DELETE /api/v1/agents/grants/{id}` revokes one at once (`oh agent list`, `oh agent revoke ID`). Migration 0075 (`agent_grants`); audited as `agent.granted` and `agent.revoked`.
596 596
597 +
### 13.5.3 The transparency log (oxygenhub#238, docs/transparency.md, step 1 built 2026-10-18)
598 +
599 +
The hub logs every signed object it accepts, in the order it accepts them, as entries of one Merkle tree (Oxygen spec §14.8):
600 +
- **What it logs:**
601 +
  - from pushes: reference and tag attestations, seals, the repository's policy, the hub's own acceptances, and the certificates of short-lived signers;
602 +
  - receipts as they are kept, and sealed approvals;
603 +
  - every certificate `keyless/certify` issues.
604 +
605 +
  An object is logged once (`tlog_entries`, migration 0076, maps objects to entry numbers), and a push is logged before it is answered.
606 +
- **Storage:** the log is append-only files in `<data>/tlog/`: `entries` (each a u16 length and its bytes), `offsets`, `hashes` (every tree hash in stored order) and `checkpoint`. A torn append is cut back to the last whole entry on start and its hashes rebuilt; nothing is ever rewritten.
607 +
- **Checkpoints:** every 5 seconds while entries arrive, the hub signs a checkpoint with its key. The origin is the base URL's host and `/log`, and the note carries an Ed25519 line and a composite line.
608 +
- **Serving:** it serves `GET /log/checkpoint` (no-cache), `GET /log/tile/<L>/<N>[.p/<W>]` and `GET /log/tile/entries/<N>[.p/<W>]` (C2SP tlog-tiles: full tiles cached for a year as immutable, partial ones for 5 s; a tile the log does not hold yet is 404), and `/log`, a page with the checkpoint and the newest entries (a private repository's entries show only their hashes).
609 +
- **Not yet:** inclusion proofs in push and refs responses, oxy's checks, witnesses and trusted time are the next steps (docs/transparency.md). Backups do not copy the log yet.
610 +
597 611
### 13.6 Coverage reports (oxygenhub#113, done 2026-10-06)
598 612
599 613
Coverage is built in, as Coveralls or Codecov would be, fed by OxygenFlow.
…
167 unchanged lines
modified docs/transparency.md +5 −1
…
1 unchanged lines
2 2
3 3
Design for oxygenhub#238 (an append-only, witnessed log) and #240 (trusted
4 4
time for signatures), written 2026-10-17. **Status: accepted** (Andy, 2026-10-17, the three
5 -
decisions below as recommended). Nothing here is built yet.
5 +
decisions below as recommended). Built 2026-10-18: step 1's log (oxy-core
6 +
`tlog`, Oxygen spec §14.8; the hub's log, served as tiles with signed
7 +
checkpoints, hub spec §13.5.3); inclusion proofs in responses and the rest
8 +
follow. Hashes are BLAKE3, like the rest of Oxygen: tools for the tile and
9 +
note formats read the log, but only Oxygen's code recomputes its roots.
6 10
7 11
## Why
8 12
…
168 unchanged lines