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