OXYGENHUB

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, &note)?;
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(&note.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