Hubs witness each other's transparency logs (step A5) 4f40eed0588f
A hub can witness other hubs' logs and have its own witnessed (C2SP tlog-witness, Oxygen spec §14.8): - as a witness: POST /log/witness/add-checkpoint cosigns, with this hub's key and clock under its host's name, a checkpoint that verifies with the log's key and extends the last one cosigned (tlog_witnessed); 409 with its size to a wrong guess, 422 to a fork, 403 to another key's note, 404 to a log it does not witness; - as a log: each new checkpoint goes to its witnesses with the consistency proof from what each last cosigned (tlog_cosigned), and their verified cosignatures are served with it. Configured in hub.toml [tlog] witnesses / witness_for (none by default: nothing changes until set). Migration 0077 for SQLite and Postgres (schema test green). Test: two hubs, two rounds cosigned and served, a fork refused, a wrong size told, an unknown log refused. Hub spec §13.5.3. Pins oxygen 9079d7fe. Refs #238
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key b7eeba31f43a
- When
- · 2026-10-18 00:41 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
4f40eed0588f55ff8e73d82b0f9cdc24a5864cb105bd3cbb8bab35126c9580fb- Depends on
- 380996db84e5 3b1bc27fe805 3eb491e9489b 42fa95540468 617c2840446c 7dbb41b09a99 8aa5ef384ef1 8f3d2ccfe6fc 93be9ce4653e 965b950f9b96 9a777fec4ed9 b64e2537d702 d23464c0ac60 d7d182467382 e5b97ac399e7
- Depended on by
- aeed63c00ad7 ec84907043c2 b5c199bd6b1a e57765dee61a 13b27cb2200c 8dcaeeb848c6 a2a96bf21455
- 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
- 22 atoms, 2 tree operations, 10 files
Browse the tree at this change
added crates/hub/migrations-pg/0077_tlog_witnesses.sql +14 −0
| 1 | + | -- Transparency-log witnesses (oxygenhub#238): the last checkpoint this |
|
| 2 | + | -- hub cosigned for each log it witnesses, and how far each of this hub's |
|
| 3 | + | -- witnesses has cosigned its own log. |
|
| 4 | + | CREATE TABLE tlog_witnessed ( |
|
| 5 | + | origin TEXT PRIMARY KEY, |
|
| 6 | + | size BIGINT NOT NULL, |
|
| 7 | + | root TEXT NOT NULL, |
|
| 8 | + | updated_ms BIGINT NOT NULL |
|
| 9 | + | ); |
|
| 10 | + | CREATE TABLE tlog_cosigned ( |
|
| 11 | + | witness TEXT PRIMARY KEY, |
|
| 12 | + | size BIGINT NOT NULL, |
|
| 13 | + | updated_ms BIGINT NOT NULL |
|
| 14 | + | ); |
added crates/hub/migrations/0077_tlog_witnesses.sql +14 −0
| 1 | + | -- Transparency-log witnesses (oxygenhub#238): the last checkpoint this |
|
| 2 | + | -- hub cosigned for each log it witnesses, and how far each of this hub's |
|
| 3 | + | -- witnesses has cosigned its own log. |
|
| 4 | + | CREATE TABLE tlog_witnessed ( |
|
| 5 | + | origin TEXT PRIMARY KEY, |
|
| 6 | + | size INTEGER NOT NULL, |
|
| 7 | + | root TEXT NOT NULL, |
|
| 8 | + | updated_ms INTEGER NOT NULL |
|
| 9 | + | ); |
|
| 10 | + | CREATE TABLE tlog_cosigned ( |
|
| 11 | + | witness TEXT PRIMARY KEY, |
|
| 12 | + | size INTEGER NOT NULL, |
|
| 13 | + | updated_ms INTEGER NOT NULL |
|
| 14 | + | ); |
modified crates/hub/src/config.rs +32 −0
| … | 24 unchanged lines |
||
| 25 | 25 | pub backups: Backups, |
|
| 26 | 26 | pub outbound: Outbound, |
|
| 27 | 27 | pub database: Database, |
|
| 28 | + | pub tlog: Tlog, |
|
| 28 | 29 | } |
|
| 29 | 30 | ||
| 30 | 31 | /// Where a hub built with the `postgres` feature keeps its tables (#211); a |
|
| … | 18 unchanged lines |
||
| 49 | 50 | pub allow: Vec<String>, |
|
| 50 | 51 | } |
|
| 51 | 52 | ||
| 53 | + | /// The transparency log's witnesses (docs/transparency.md, #238): who |
|
| 54 | + | /// cosigns this hub's checkpoints, and whose logs this hub cosigns. |
|
| 55 | + | #[derive(Clone, Debug, Default, Deserialize)] |
|
| 56 | + | #[serde(default, deny_unknown_fields)] |
|
| 57 | + | pub struct Tlog { |
|
| 58 | + | pub witnesses: Vec<TlogWitness>, |
|
| 59 | + | pub witness_for: Vec<TlogWitnessed>, |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | /// A witness this hub's log asks: its name in cosignatures, its |
|
| 63 | + | /// add-checkpoint address (`https://HUB/log/witness/add-checkpoint` for |
|
| 64 | + | /// another hub), and its identity object in base64 (another hub's |
|
| 65 | + | /// `/.well-known/oxyhub-key`). |
|
| 66 | + | #[derive(Clone, Debug, Deserialize)] |
|
| 67 | + | #[serde(deny_unknown_fields)] |
|
| 68 | + | pub struct TlogWitness { |
|
| 69 | + | pub name: String, |
|
| 70 | + | pub url: String, |
|
| 71 | + | pub key: String, |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | /// A log this hub witnesses: its origin (`HOST/log`) and its key, an |
|
| 75 | + | /// identity object in base64. |
|
| 76 | + | #[derive(Clone, Debug, Deserialize)] |
|
| 77 | + | #[serde(deny_unknown_fields)] |
|
| 78 | + | pub struct TlogWitnessed { |
|
| 79 | + | pub origin: String, |
|
| 80 | + | pub key: String, |
|
| 81 | + | } |
|
| 82 | + | ||
| 52 | 83 | /// Backups (docs/backups.md, oxygenhub#169): the directories on this host |
|
| 53 | 84 | /// an owner may back up to. Empty, only GCS destinations can be chosen. |
|
| 54 | 85 | #[derive(Clone, Debug, Deserialize)] |
|
| … | 271 unchanged lines |
||
| 326 | 357 | backups: Backups::default(), |
|
| 327 | 358 | outbound: Outbound::default(), |
|
| 328 | 359 | database: Database::default(), |
|
| 360 | + | tlog: Tlog::default(), |
|
| 329 | 361 | } |
|
| 330 | 362 | } |
|
| 331 | 363 | } |
|
| … | 132 unchanged lines |
modified crates/hub/src/db/tlog.rs +52 −0
| … | 42 unchanged lines |
||
| 43 | 43 | None => sqlx::query_as("SELECT * FROM tlog_entries ORDER BY idx DESC LIMIT $1").bind(limit).fetch_all(pool).await, |
|
| 44 | 44 | } |
|
| 45 | 45 | } |
|
| 46 | + | ||
| 47 | + | /// The last checkpoint this hub cosigned for log `origin`: its size and |
|
| 48 | + | /// root (hex). |
|
| 49 | + | pub async fn witnessed(pool: &Pool, origin: &str) -> sqlx::Result<Option<(i64, String)>> { |
|
| 50 | + | sqlx::query_as("SELECT size, root FROM tlog_witnessed WHERE origin = $1").bind(origin).fetch_optional(pool).await |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | /// Move what this hub cosigned for `origin` from `old` entries (none when |
|
| 54 | + | /// zero) to `size` with `root`; false when another request moved it first. |
|
| 55 | + | pub async fn witness_advance(pool: &Pool, origin: &str, old: i64, size: i64, root: &str) -> sqlx::Result<bool> { |
|
| 56 | + | let now = super::now_ms(); |
|
| 57 | + | let r = if old == 0 { |
|
| 58 | + | sqlx::query( |
|
| 59 | + | "INSERT INTO tlog_witnessed (origin, size, root, updated_ms) VALUES ($1, $2, $3, $4) \ |
|
| 60 | + | ON CONFLICT (origin) DO UPDATE SET size = $2, root = $3, updated_ms = $4 WHERE tlog_witnessed.size = 0", |
|
| 61 | + | ) |
|
| 62 | + | .bind(origin) |
|
| 63 | + | .bind(size) |
|
| 64 | + | .bind(root) |
|
| 65 | + | .bind(now) |
|
| 66 | + | .execute(pool) |
|
| 67 | + | .await? |
|
| 68 | + | } else { |
|
| 69 | + | sqlx::query("UPDATE tlog_witnessed SET size = $1, root = $2, updated_ms = $3 WHERE origin = $4 AND size = $5") |
|
| 70 | + | .bind(size) |
|
| 71 | + | .bind(root) |
|
| 72 | + | .bind(now) |
|
| 73 | + | .bind(origin) |
|
| 74 | + | .bind(old) |
|
| 75 | + | .execute(pool) |
|
| 76 | + | .await? |
|
| 77 | + | }; |
|
| 78 | + | Ok(r.rows_affected() == 1) |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | /// How far witness `name` has cosigned this hub's log. |
|
| 82 | + | pub async fn cosigned_size(pool: &Pool, name: &str) -> sqlx::Result<i64> { |
|
| 83 | + | Ok(sqlx::query_scalar("SELECT size FROM tlog_cosigned WHERE witness = $1").bind(name).fetch_optional(pool).await?.unwrap_or(0)) |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | pub async fn set_cosigned(pool: &Pool, name: &str, size: i64) -> sqlx::Result<()> { |
|
| 87 | + | sqlx::query( |
|
| 88 | + | "INSERT INTO tlog_cosigned (witness, size, updated_ms) VALUES ($1, $2, $3) \ |
|
| 89 | + | ON CONFLICT (witness) DO UPDATE SET size = $2, updated_ms = $3", |
|
| 90 | + | ) |
|
| 91 | + | .bind(name) |
|
| 92 | + | .bind(size) |
|
| 93 | + | .bind(super::now_ms()) |
|
| 94 | + | .execute(pool) |
|
| 95 | + | .await?; |
|
| 96 | + | Ok(()) |
|
| 97 | + | } |
modified crates/hub/src/lib.rs +1 −0
| … | 120 unchanged lines |
||
| 121 | 121 | let server = oxy_server::Server::new(backend); |
|
| 122 | 122 | let mail = mail::load(&db, &config.email).await; |
|
| 123 | 123 | let tlog = Arc::new(tlog::Log::open(&config.data.join("tlog"), &tlog::origin_of(config.base_url()))?); |
|
| 124 | + | tlog.configure(&config.tlog)?; |
|
| 124 | 125 | let app = Arc::new(App { |
|
| 125 | 126 | mail: std::sync::RwLock::new(mail), |
|
| 126 | 127 | config, |
|
| … | 224 unchanged lines |
modified crates/hub/src/tlog.rs +207 −3
| … | 20 unchanged lines |
||
| 21 | 21 | use axum::http::{header, StatusCode}; |
|
| 22 | 22 | use axum::response::{IntoResponse, Response}; |
|
| 23 | 23 | use oxy_core::hash::{KeyId, ObjectRef}; |
|
| 24 | + | use oxy_core::identity::Identity; |
|
| 24 | 25 | use oxy_core::repo::{PushReport, Repo}; |
|
| 25 | 26 | use oxy_core::tlog::{self, Checkpoint, Entry, Hash, HashReader, Tile, TlogError, TILE_WIDTH}; |
|
| 26 | 27 | use std::fs::{File, OpenOptions}; |
|
| … | 39 unchanged lines |
||
| 66 | 67 | files: Mutex<Files>, |
|
| 67 | 68 | /// The latest signed checkpoint: its size and its note. |
|
| 68 | 69 | checkpoint: Mutex<Option<(u64, String)>>, |
|
| 70 | + | /// Witnesses this log asks to cosign its checkpoints. |
|
| 71 | + | witnesses: std::sync::RwLock<Vec<Peer>>, |
|
| 72 | + | /// Logs this hub witnesses: their origins and keys. |
|
| 73 | + | witness_for: std::sync::RwLock<Vec<(String, Identity)>>, |
|
| 69 | 74 | } |
|
| 70 | 75 | ||
| 76 | + | /// A witness of this hub's log. |
|
| 77 | + | #[derive(Clone, Debug)] |
|
| 78 | + | pub struct Peer { |
|
| 79 | + | pub name: String, |
|
| 80 | + | /// Its add-checkpoint address. |
|
| 81 | + | pub url: String, |
|
| 82 | + | pub identity: Identity, |
|
| 83 | + | } |
|
| 84 | + | ||
| 71 | 85 | fn read_u64_at(f: &File, at: u64) -> std::io::Result<u64> { |
|
| 72 | 86 | let mut b = [0u8; 8]; |
|
| 73 | 87 | f.read_exact_at(&mut b, at)?; |
|
| … | 56 unchanged lines |
||
| 130 | 144 | let c = Checkpoint::parse(&n.text).ok()?; |
|
| 131 | 145 | (c.size <= size).then_some((c.size, note)) |
|
| 132 | 146 | }); |
|
| 133 | - | Ok(Log { dir: dir.to_path_buf(), origin: origin.to_string(), files: Mutex::new(files), checkpoint: Mutex::new(checkpoint) }) |
|
| 147 | + | Ok(Log { |
|
| 148 | + | dir: dir.to_path_buf(), |
|
| 149 | + | origin: origin.to_string(), |
|
| 150 | + | files: Mutex::new(files), |
|
| 151 | + | checkpoint: Mutex::new(checkpoint), |
|
| 152 | + | witnesses: Default::default(), |
|
| 153 | + | witness_for: Default::default(), |
|
| 154 | + | }) |
|
| 134 | 155 | } |
|
| 135 | 156 | ||
| 136 | 157 | fn entry_bytes(f: &Files, n: u64) -> anyhow::Result<Vec<u8>> { |
|
| … | 82 unchanged lines |
||
| 219 | 240 | } |
|
| 220 | 241 | let entries: Vec<Vec<u8>> = (first..first + t.width as u64).map(|n| Self::entry_bytes(&f, n)).collect::<anyhow::Result<_>>()?; |
|
| 221 | 242 | Ok(Some(tlog::encode_bundle(&entries)?)) |
|
| 243 | + | } |
|
| 244 | + | ||
| 245 | + | /// Take `[tlog]` from the configuration: witnesses and witnessed logs. |
|
| 246 | + | pub fn configure(&self, c: &crate::config::Tlog) -> anyhow::Result<()> { |
|
| 247 | + | let key = |b64: &str, what: &str| -> anyhow::Result<Identity> { |
|
| 248 | + | let bytes = oxy_core::dsse::base64_decode(b64.trim()).ok_or_else(|| anyhow::anyhow!("[tlog] {what}: the key is not base64"))?; |
|
| 249 | + | Identity::decode(&bytes).map_err(|e| anyhow::anyhow!("[tlog] {what}: {e}")) |
|
| 250 | + | }; |
|
| 251 | + | let mut ws = Vec::new(); |
|
| 252 | + | for w in &c.witnesses { |
|
| 253 | + | ws.push(Peer { name: w.name.clone(), url: w.url.clone(), identity: key(&w.key, &w.name)? }); |
|
| 254 | + | } |
|
| 255 | + | let mut wf = Vec::new(); |
|
| 256 | + | for l in &c.witness_for { |
|
| 257 | + | wf.push((l.origin.clone(), key(&l.key, &l.origin)?)); |
|
| 258 | + | } |
|
| 259 | + | self.set_witnesses(ws); |
|
| 260 | + | for (o, k) in wf { |
|
| 261 | + | self.add_witness_for(&o, k); |
|
| 262 | + | } |
|
| 263 | + | Ok(()) |
|
| 264 | + | } |
|
| 265 | + | ||
| 266 | + | pub fn set_witnesses(&self, peers: Vec<Peer>) { |
|
| 267 | + | *self.witnesses.write().unwrap() = peers; |
|
| 268 | + | } |
|
| 269 | + | ||
| 270 | + | pub fn witnesses(&self) -> Vec<Peer> { |
|
| 271 | + | self.witnesses.read().unwrap().clone() |
|
| 272 | + | } |
|
| 273 | + | ||
| 274 | + | /// Witness the log named `origin`, signed with `key`. |
|
| 275 | + | pub fn add_witness_for(&self, origin: &str, key: Identity) { |
|
| 276 | + | let mut w = self.witness_for.write().unwrap(); |
|
| 277 | + | w.retain(|(o, _)| o != origin); |
|
| 278 | + | w.push((origin.to_string(), key)); |
|
| 279 | + | } |
|
| 280 | + | ||
| 281 | + | fn witnessed_key(&self, origin: &str) -> Option<Identity> { |
|
| 282 | + | self.witness_for.read().unwrap().iter().find(|(o, _)| o == origin).map(|(_, k)| k.clone()) |
|
| 283 | + | } |
|
| 284 | + | ||
| 285 | + | /// This hub's name as a witness: its log's host. |
|
| 286 | + | pub fn witness_name(&self) -> &str { |
|
| 287 | + | self.origin.trim_end_matches("/log") |
|
| 288 | + | } |
|
| 289 | + | ||
| 290 | + | /// Replace the latest checkpoint's note with `note` (the same |
|
| 291 | + | /// checkpoint, with cosignatures added), unless a newer one was signed. |
|
| 292 | + | fn set_note(&self, size: u64, note: String) -> anyhow::Result<()> { |
|
| 293 | + | let mut cur = self.checkpoint.lock().unwrap(); |
|
| 294 | + | if cur.as_ref().map(|(s, _)| *s) != Some(size) { |
|
| 295 | + | return Ok(()); |
|
| 296 | + | } |
|
| 297 | + | let tmp = self.dir.join("checkpoint.new"); |
|
| 298 | + | std::fs::write(&tmp, ¬e)?; |
|
| 299 | + | File::open(&tmp)?.sync_all()?; |
|
| 300 | + | std::fs::rename(&tmp, self.dir.join("checkpoint"))?; |
|
| 301 | + | *cur = Some((size, note)); |
|
| 302 | + | Ok(()) |
|
| 222 | 303 | } |
|
| 223 | 304 | ||
| 224 | 305 | /// The latest signed checkpoint note. |
|
| … | 164 unchanged lines |
||
| 389 | 470 | } |
|
| 390 | 471 | } |
|
| 391 | 472 | backfill |
|
| 473 | + | } |
|
| 474 | + | ||
| 475 | + | /// Ask each witness to cosign the latest checkpoint, and serve their |
|
| 476 | + | /// cosignatures with it. A witness that answers 409 is told again from the |
|
| 477 | + | /// size it says it has; one that refuses is logged. |
|
| 478 | + | pub async fn collect(app: &Shared) { |
|
| 479 | + | let peers = app.tlog.witnesses(); |
|
| 480 | + | let Some(text) = app.tlog.checkpoint() else { return }; |
|
| 481 | + | if peers.is_empty() { |
|
| 482 | + | return; |
|
| 483 | + | } |
|
| 484 | + | let Ok(mut note) = tlog::Note::parse(&text) else { return }; |
|
| 485 | + | let Ok(cp) = Checkpoint::parse(¬e.text) else { return }; |
|
| 486 | + | // What a witness checks: the note as the log signed it. |
|
| 487 | + | let own = tlog::Note { text: note.text.clone(), sigs: note.sigs.iter().filter(|s| s.name == cp.origin).cloned().collect() }; |
|
| 488 | + | let mut changed = false; |
|
| 489 | + | for p in peers { |
|
| 490 | + | if note.cosigned_by(&p.name, &p.identity).is_ok() { |
|
| 491 | + | continue; |
|
| 492 | + | } |
|
| 493 | + | let mut old = db::tlog::cosigned_size(&app.db, &p.name).await.unwrap_or(0) as u64; |
|
| 494 | + | for _ in 0..2 { |
|
| 495 | + | let proof = if old == 0 || old >= cp.size { Vec::new() } else { app.tlog.prove_consistency(cp.size, old).unwrap_or_default() }; |
|
| 496 | + | let body = tlog::AddCheckpoint { old_size: old, proof, note: own.encode() }.encode(); |
|
| 497 | + | let client = match crate::net::client(&p.url, std::slice::from_ref(&p.url), std::time::Duration::from_secs(20)).await { |
|
| 498 | + | Ok(c) => c, |
|
| 499 | + | Err(e) => { |
|
| 500 | + | eprintln!("tlog: witness {}: {e}", p.name); |
|
| 501 | + | break; |
|
| 502 | + | } |
|
| 503 | + | }; |
|
| 504 | + | let res = match client.post(&p.url).header(header::CONTENT_TYPE, "text/plain").body(body).send().await { |
|
| 505 | + | Ok(r) => r, |
|
| 506 | + | Err(e) => { |
|
| 507 | + | eprintln!("tlog: witness {}: {e}", p.name); |
|
| 508 | + | break; |
|
| 509 | + | } |
|
| 510 | + | }; |
|
| 511 | + | let status = res.status(); |
|
| 512 | + | let answer = res.text().await.unwrap_or_default(); |
|
| 513 | + | if status == StatusCode::CONFLICT { |
|
| 514 | + | match answer.trim().parse() { |
|
| 515 | + | Ok(n) if n != old => { |
|
| 516 | + | old = n; |
|
| 517 | + | continue; |
|
| 518 | + | } |
|
| 519 | + | _ => { |
|
| 520 | + | eprintln!("tlog: witness {}: a conflict without a size: {answer}", p.name); |
|
| 521 | + | break; |
|
| 522 | + | } |
|
| 523 | + | } |
|
| 524 | + | } |
|
| 525 | + | if !status.is_success() { |
|
| 526 | + | eprintln!("tlog: witness {} refused the checkpoint of {} entries ({status}): {}", p.name, cp.size, answer.trim()); |
|
| 527 | + | break; |
|
| 528 | + | } |
|
| 529 | + | let lines = tlog::Note::parse(&format!("{}\n{answer}", note.text)).map(|n| n.sigs).unwrap_or_default(); |
|
| 530 | + | let theirs: Vec<_> = lines.into_iter().filter(|s| s.name == p.name).collect(); |
|
| 531 | + | let check = tlog::Note { text: note.text.clone(), sigs: theirs.clone() }; |
|
| 532 | + | match check.cosigned_by(&p.name, &p.identity) { |
|
| 533 | + | Ok(_) => { |
|
| 534 | + | note.sigs.extend(theirs); |
|
| 535 | + | db::tlog::set_cosigned(&app.db, &p.name, cp.size as i64).await.ok(); |
|
| 536 | + | changed = true; |
|
| 537 | + | } |
|
| 538 | + | Err(e) => eprintln!("tlog: witness {} answered a cosignature that does not verify: {e}", p.name), |
|
| 539 | + | } |
|
| 540 | + | break; |
|
| 541 | + | } |
|
| 542 | + | } |
|
| 543 | + | if changed { |
|
| 544 | + | if let Err(e) = app.tlog.set_note(cp.size, note.encode()) { |
|
| 545 | + | eprintln!("tlog: keeping the cosigned checkpoint: {e}"); |
|
| 546 | + | } |
|
| 547 | + | } |
|
| 548 | + | } |
|
| 549 | + | ||
| 550 | + | /// `POST /log/witness/add-checkpoint` (C2SP tlog-witness): cosign a |
|
| 551 | + | /// checkpoint of a log this hub witnesses, if it extends the last one |
|
| 552 | + | /// cosigned. |
|
| 553 | + | pub async fn add_checkpoint(State(app): State<Shared>, body: String) -> Response { |
|
| 554 | + | let Ok(req) = tlog::AddCheckpoint::parse(&body) else { |
|
| 555 | + | return plain(StatusCode::BAD_REQUEST, "not an add-checkpoint request"); |
|
| 556 | + | }; |
|
| 557 | + | let Some(origin) = tlog::Note::parse(&req.note).ok().and_then(|n| Checkpoint::parse(&n.text).ok()).map(|c| c.origin) else { |
|
| 558 | + | return plain(StatusCode::BAD_REQUEST, "the checkpoint does not parse"); |
|
| 559 | + | }; |
|
| 560 | + | let Some(key) = app.tlog.witnessed_key(&origin) else { |
|
| 561 | + | return plain(StatusCode::NOT_FOUND, &format!("this hub does not witness {origin}")); |
|
| 562 | + | }; |
|
| 563 | + | let latest = match db::tlog::witnessed(&app.db, &origin).await { |
|
| 564 | + | Ok(l) => l.and_then(|(size, root)| Some((size as u64, ObjectRef::from_hex(&root)?.0))), |
|
| 565 | + | Err(e) => return plain(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), |
|
| 566 | + | }; |
|
| 567 | + | let sk = match crate::keys::load_secret(&app.config.keys_dir()) { |
|
| 568 | + | Ok((_, sk)) => sk, |
|
| 569 | + | Err(e) => return plain(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), |
|
| 570 | + | }; |
|
| 571 | + | let me = tlog::WitnessKey { name: app.tlog.witness_name(), identity: &app.hub_key.identity, sk: &sk }; |
|
| 572 | + | let now = (db::now_ms() / 1000) as u64; |
|
| 573 | + | let conflict = |n: u64| ([(header::CONTENT_TYPE, "text/x.tlog.size")], format!("{n}\n")); |
|
| 574 | + | match tlog::witness(&req, &origin, &key, latest, &me, now) { |
|
| 575 | + | Ok(tlog::Witnessed::Cosigned { checkpoint, lines }) => { |
|
| 576 | + | let root = ObjectRef(checkpoint.root).to_hex(); |
|
| 577 | + | match db::tlog::witness_advance(&app.db, &origin, req.old_size as i64, checkpoint.size as i64, &root).await { |
|
| 578 | + | Ok(true) => { |
|
| 579 | + | let body: String = tlog::Note { text: String::new(), sigs: lines }.encode().trim_start_matches('\n').to_string(); |
|
| 580 | + | ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], body).into_response() |
|
| 581 | + | } |
|
| 582 | + | Ok(false) => { |
|
| 583 | + | let now = db::tlog::witnessed(&app.db, &origin).await.ok().flatten().map(|(s, _)| s as u64).unwrap_or(0); |
|
| 584 | + | (StatusCode::CONFLICT, conflict(now)).into_response() |
|
| 585 | + | } |
|
| 586 | + | Err(e) => plain(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), |
|
| 587 | + | } |
|
| 588 | + | } |
|
| 589 | + | Ok(tlog::Witnessed::Conflict(n)) => (StatusCode::CONFLICT, conflict(n)).into_response(), |
|
| 590 | + | Err(tlog::WitnessError::Unsigned(e)) => plain(StatusCode::FORBIDDEN, &e), |
|
| 591 | + | Err(tlog::WitnessError::Inconsistent(e)) => plain(StatusCode::UNPROCESSABLE_ENTITY, &e), |
|
| 592 | + | Err(tlog::WitnessError::Bad(e)) => plain(StatusCode::BAD_REQUEST, &e), |
|
| 593 | + | } |
|
| 392 | 594 | } |
|
| 393 | 595 | ||
| 394 | 596 | /// Sign a new checkpoint every few seconds while entries arrive. |
|
| … | 8 unchanged lines |
||
| 403 | 605 | tl.refresh_checkpoint(&identity, &sk) |
|
| 404 | 606 | }) |
|
| 405 | 607 | .await; |
|
| 406 | - | if let Ok(Err(e)) = done { |
|
| 407 | - | eprintln!("tlog: signing a checkpoint: {e}"); |
|
| 608 | + | match done { |
|
| 609 | + | Ok(Err(e)) => eprintln!("tlog: signing a checkpoint: {e}"), |
|
| 610 | + | // A new checkpoint, or one some witness has not cosigned yet. |
|
| 611 | + | _ => collect(&app).await, |
|
| 408 | 612 | } |
|
| 409 | 613 | } |
|
| 410 | 614 | } |
|
| … | 104 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 82 unchanged lines |
||
| 83 | 83 | .route("/log", get(transparency::page)) |
|
| 84 | 84 | .route("/log/checkpoint", get(crate::tlog::checkpoint)) |
|
| 85 | 85 | .route("/log/tile/{*path}", get(crate::tlog::tile)) |
|
| 86 | + | .route("/log/witness/add-checkpoint", axum::routing::post(crate::tlog::add_checkpoint)) |
|
| 86 | 87 | .route("/.well-known/oxyhub-ca.pem", get(home::hub_ca)) |
|
| 87 | 88 | .route("/.well-known/openid-configuration", get(crate::oidc::discovery)) |
|
| 88 | 89 | .route("/.well-known/jwks.json", get(crate::oidc::jwks)) |
|
| … | 709 unchanged lines |
modified crates/hub/tests/flow.rs +58 −0
| … | 5094 unchanged lines |
||
| 5095 | 5095 | assert!(err.contains("showed two histories") && err.contains("does not extend"), "{err}"); |
|
| 5096 | 5096 | } |
|
| 5097 | 5097 | ||
| 5098 | + | /// Witnesses (oxygenhub#238, step A5): one hub cosigns another's |
|
| 5099 | + | /// checkpoints when each extends the last it cosigned, and the log serves |
|
| 5100 | + | /// the cosignatures with its checkpoint; the witness refuses a fork (422), |
|
| 5101 | + | /// tells a log that guessed its size wrong (409), and knows only the logs |
|
| 5102 | + | /// it was told to witness (404). |
|
| 5103 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 5104 | + | async fn a_hub_witnesses_another_hub_s_log() { |
|
| 5105 | + | use oxy_core::tlog::{AddCheckpoint, Checkpoint, Note}; |
|
| 5106 | + | let log = hub().await; |
|
| 5107 | + | let witness = hub().await; |
|
| 5108 | + | let origin = oxygenhub::tlog::origin_of(log.app.config.base_url()); |
|
| 5109 | + | witness.app.tlog.add_witness_for(&origin, log.app.hub_key.identity.clone()); |
|
| 5110 | + | let name = witness.app.tlog.witness_name().to_string(); |
|
| 5111 | + | log.app.tlog.set_witnesses(vec![oxygenhub::tlog::Peer { |
|
| 5112 | + | name: name.clone(), |
|
| 5113 | + | url: format!("{}/log/witness/add-checkpoint", witness.base), |
|
| 5114 | + | identity: witness.app.hub_key.identity.clone(), |
|
| 5115 | + | }]); |
|
| 5116 | + | create(&log.app, "andy", "demo").await; |
|
| 5117 | + | let work = tempfile::tempdir().unwrap(); |
|
| 5118 | + | let mut c = client(&format!("{}/andy/demo", log.base), work.path()); |
|
| 5119 | + | c.create_identity("Andy", "[email protected]", oxy_core::repo::IdentityScope::Repo).unwrap(); |
|
| 5120 | + | let (_, sk) = oxygenhub::keys::load_secret(&log.app.config.keys_dir()).unwrap(); |
|
| 5121 | + | let log_id = log.app.hub_key.identity.clone(); |
|
| 5122 | + | let wid = witness.app.hub_key.identity.clone(); |
|
| 5123 | + | for n in 0..2 { |
|
| 5124 | + | write(work.path(), "a.txt", &format!("{n}\n")); |
|
| 5125 | + | c.commit(&format!("change {n}"), &[]).unwrap(); |
|
| 5126 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 5127 | + | log.app.tlog.refresh_checkpoint(&log_id, &sk).unwrap(); |
|
| 5128 | + | oxygenhub::tlog::collect(&log.app).await; |
|
| 5129 | + | let note = Note::parse(&log.app.tlog.checkpoint().unwrap()).unwrap(); |
|
| 5130 | + | assert!(note.cosigned_by(&name, &wid).is_ok(), "round {n}: {}", note.encode()); |
|
| 5131 | + | // Served as such. |
|
| 5132 | + | let (_, served) = call(&log.app, "GET", "/log/checkpoint", None, None).await; |
|
| 5133 | + | assert!(Note::parse(&served).unwrap().cosigned_by(&name, &wid).is_ok()); |
|
| 5134 | + | } |
|
| 5135 | + | let cp = Checkpoint::parse(&Note::parse(&log.app.tlog.checkpoint().unwrap()).unwrap().text).unwrap(); |
|
| 5136 | + | ||
| 5137 | + | let ask = |req: AddCheckpoint| { |
|
| 5138 | + | let app = witness.app.clone(); |
|
| 5139 | + | async move { call(&app, "POST", "/log/witness/add-checkpoint", None, Some(req.encode())).await } |
|
| 5140 | + | }; |
|
| 5141 | + | // Another log of the same size, signed by the log's own key: a fork. |
|
| 5142 | + | let forked = Checkpoint { root: [7; 32], ..cp.clone() }.sign(&log_id, &sk).unwrap().encode(); |
|
| 5143 | + | let (st, body) = ask(AddCheckpoint { old_size: cp.size, proof: vec![], note: forked.clone() }).await; |
|
| 5144 | + | assert_eq!(st, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); |
|
| 5145 | + | // A wrong idea of what the witness saw: its size, to try again from. |
|
| 5146 | + | let (st, body) = ask(AddCheckpoint { old_size: 1, proof: vec![], note: forked }).await; |
|
| 5147 | + | assert_eq!((st, body.trim()), (StatusCode::CONFLICT, cp.size.to_string().as_str())); |
|
| 5148 | + | // A log it was not told to witness. |
|
| 5149 | + | let stranger = oxy_core::identity::SecretKey::generate(); |
|
| 5150 | + | let sid = stranger.identity("elsewhere", "[email protected]", 1); |
|
| 5151 | + | let other = Checkpoint { origin: "elsewhere.example/log".into(), ..cp.clone() }.sign(&sid, &stranger).unwrap().encode(); |
|
| 5152 | + | let (st, _) = ask(AddCheckpoint { old_size: 0, proof: vec![], note: other }).await; |
|
| 5153 | + | assert_eq!(st, StatusCode::NOT_FOUND); |
|
| 5154 | + | } |
|
| 5155 | + | ||
| 5098 | 5156 | /// Keyless step 4c (oxygenhub#241): when a key the hub certified moves a |
|
| 5099 | 5157 | /// bookmark, the hub signs when it accepted the update and serves that |
|
| 5100 | 5158 | /// 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 +12 −2
| … | 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) |
|
| 597 | + | ### 13.5.3 The transparency log (oxygenhub#238, docs/transparency.md, steps 1-3 built 2026-10-18) |
|
| 598 | 598 | ||
| 599 | 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 | 600 | - **What it logs:** |
|
| … | 6 unchanged lines |
||
| 607 | 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 | 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 | 609 | - **Refs answers** carry the latest checkpoint and, for each bookmark's attestation, its entry and inclusion proof (Oxygen spec §16; step A3, 2026-10-18). An attestation served but never logged (pushed before the log began) is logged then, and proved on a later fetch. oxy pins the log's key per hub, requires each checkpoint to extend the last it saw (proved from the tiles), and fails a fetch whose hub showed two histories. |
|
| 610 | - | - **Not yet:** proofs in push responses, `oxy tlog verify`, witnesses and trusted time (docs/transparency.md). Backups do not copy the log yet. |
|
| 610 | + | - **Witnesses** (step A5, 2026-10-18): a hub can witness other hubs' logs and have its own witnessed (C2SP tlog-witness, Oxygen spec §14.8). |
|
| 611 | + | - As a witness it answers `POST /log/witness/add-checkpoint`: it cosigns, with its key and clock under its host's name, a checkpoint that verifies with the log's key and extends the last it cosigned (`tlog_witnessed`, migration 0077). It answers 409 with its size when the log guessed it wrong, 422 to a fork, 403 to another key's note and 404 to a log it was not told to witness. |
|
| 612 | + | - As a log it sends each new checkpoint to its witnesses, with the consistency proof from what each last cosigned (`tlog_cosigned`), and serves their cosignatures with the checkpoint. |
|
| 613 | + | - Both are set in `hub.toml`; the key is the other hub's `/.well-known/oxyhub-key` in base64: |
|
| 611 | 614 | ||
| 615 | + | ```toml |
|
| 616 | + | [tlog] |
|
| 617 | + | witnesses = [{ name = "gcp.oxygenhub.io", url = "https://gcp.oxygenhub.io/log/witness/add-checkpoint", key = "…" }] |
|
| 618 | + | witness_for = [{ origin = "oxygenhub.io/log", key = "…" }] |
|
| 619 | + | ``` |
|
| 620 | + | - **Not yet:** proofs in push responses, the `oxy witness` daemon, and trusted time (docs/transparency.md). Backups do not copy the log yet. |
|
| 621 | + | ||
| 612 | 622 | ### 13.6 Coverage reports (oxygenhub#113, done 2026-10-06) |
|
| 613 | 623 | ||
| 614 | 624 | Coverage is built in, as Coveralls or Codecov would be, fed by OxygenFlow. |
|
| … | 167 unchanged lines |