OXYGENHUB

oxy witness serve: a transparency-log witness anyone can run 51d4862a16d8

The third kind of witness in oxygenhub docs/transparency.md, beside hubs
witnessing each other. `oxy witness serve --name HOST --log ORIGIN=FILE
...` serves C2SP tlog-witness POST /add-checkpoint (oxy-server's
witness module). It cosigns, with this oxy home's key and its own clock,
each checkpoint that verifies with the log's key and extends the last
one it cosigned. The same rule as the hub's witness: 409 with its size,
422 to a fork, 403 to another key, 404 to an unknown log. It keeps what
it cosigned per log in --state, replaced atomically. Test: cosigns,
keeps its state across a restart, tells a wrong size, cosigns the next
with its proof. Guide: Transparency logs.

Refs oxygenhub#238
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 758a6d7c52c9
When
· 2026-10-18 00:46 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
51d4862a16d8de259898ae8d07a48ad1887fc810ee30de0e46b1cddaa8f6e95e
Depends on
4dee277c327a 61e3bce7ea5b 750251b21a96 e59512f2f075 ee06a6c557b7 fce44ae9ad29 ff0af1ee299d
Depended on by
nothing yet
In bookmarks
carried-not-removed doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
11 atoms, 1 tree operation, 7 files

Browse the tree at this change

added crates/oxy-server/src/witness.rs +272 −0
1 +
//! `oxy witness serve` (Oxygen spec §14.8, oxygenhub docs/transparency.md):
2 +
//! a small transparency-log witness anyone can run. It cosigns the
3 +
//! checkpoints of the logs it is told about (C2SP tlog-witness), each only
4 +
//! when it extends the last one this witness cosigned, with its own key
5 +
//! and clock, so a log cannot show two histories without the witness
6 +
//! seeing it.
7 +
//!
8 +
//! State is one file per log in the state directory: the size and root of
9 +
//! the last checkpoint cosigned, replaced atomically.
10 +
11 +
use axum::extract::State;
12 +
use axum::http::{header, StatusCode};
13 +
use axum::response::{IntoResponse, Response};
14 +
use axum::routing::post;
15 +
use axum::Router;
16 +
use oxy_core::identity::{Identity, SecretKey};
17 +
use oxy_core::tlog::{self, AddCheckpoint, Checkpoint, Note, WitnessError, WitnessKey, Witnessed};
18 +
use std::path::PathBuf;
19 +
use std::sync::{Arc, Mutex};
20 +
21 +
/// What a witness serves.
22 +
pub struct WitnessConfig {
23 +
    /// Its name in cosignatures (a host name, no spaces).
24 +
    pub name: String,
25 +
    pub identity: Identity,
26 +
    pub sk: SecretKey,
27 +
    /// The logs it witnesses: their origins and keys.
28 +
    pub logs: Vec<(String, Identity)>,
29 +
    /// Where it keeps what it cosigned.
30 +
    pub state: PathBuf,
31 +
    pub tls: Option<crate::TlsFiles>,
32 +
}
33 +
34 +
struct Witness {
35 +
    cfg: WitnessConfig,
36 +
    /// One add-checkpoint at a time: each reads and moves its log's state.
37 +
    lock: Mutex<()>,
38 +
}
39 +
40 +
fn state_file(w: &Witness, origin: &str) -> PathBuf {
41 +
    let safe: String = origin
42 +
        .chars()
43 +
        .map(|c| {
44 +
            if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
45 +
                c
46 +
            } else {
47 +
                '_'
48 +
            }
49 +
        })
50 +
        .collect();
51 +
    w.cfg.state.join(safe)
52 +
}
53 +
54 +
fn read_state(w: &Witness, origin: &str) -> Option<(u64, tlog::Hash)> {
55 +
    let text = std::fs::read_to_string(state_file(w, origin)).ok()?;
56 +
    let (size, root) = text.trim().split_once(' ')?;
57 +
    let root = oxy_core::hash::ObjectRef::from_hex(root)?;
58 +
    Some((size.parse().ok()?, root.0))
59 +
}
60 +
61 +
fn plain(status: StatusCode, why: &str) -> Response {
62 +
    (
63 +
        status,
64 +
        [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
65 +
        format!("{why}\n"),
66 +
    )
67 +
        .into_response()
68 +
}
69 +
70 +
async fn add_checkpoint(State(w): State<Arc<Witness>>, body: String) -> Response {
71 +
    let Ok(req) = AddCheckpoint::parse(&body) else {
72 +
        return plain(StatusCode::BAD_REQUEST, "not an add-checkpoint request");
73 +
    };
74 +
    let Some(origin) = Note::parse(&req.note)
75 +
        .ok()
76 +
        .and_then(|n| Checkpoint::parse(&n.text).ok())
77 +
        .map(|c| c.origin)
78 +
    else {
79 +
        return plain(StatusCode::BAD_REQUEST, "the checkpoint does not parse");
80 +
    };
81 +
    let Some(key) = w
82 +
        .cfg
83 +
        .logs
84 +
        .iter()
85 +
        .find(|(o, _)| *o == origin)
86 +
        .map(|(_, k)| k.clone())
87 +
    else {
88 +
        return plain(
89 +
            StatusCode::NOT_FOUND,
90 +
            &format!("this witness does not witness {origin}"),
91 +
        );
92 +
    };
93 +
    let _held = w.lock.lock().unwrap_or_else(|p| p.into_inner());
94 +
    let latest = read_state(&w, &origin);
95 +
    let me = WitnessKey {
96 +
        name: &w.cfg.name,
97 +
        identity: &w.cfg.identity,
98 +
        sk: &w.cfg.sk,
99 +
    };
100 +
    let now = std::time::SystemTime::now()
101 +
        .duration_since(std::time::UNIX_EPOCH)
102 +
        .map(|d| d.as_secs())
103 +
        .unwrap_or(0);
104 +
    let conflict = |n: u64| {
105 +
        (
106 +
            StatusCode::CONFLICT,
107 +
            [(header::CONTENT_TYPE, "text/x.tlog.size")],
108 +
            format!("{n}\n"),
109 +
        )
110 +
            .into_response()
111 +
    };
112 +
    match tlog::witness(&req, &origin, &key, latest, &me, now) {
113 +
        Ok(Witnessed::Cosigned { checkpoint, lines }) => {
114 +
            let text = format!(
115 +
                "{} {}\n",
116 +
                checkpoint.size,
117 +
                oxy_core::hash::ObjectRef(checkpoint.root).to_hex()
118 +
            );
119 +
            if let Err(e) = std::fs::create_dir_all(&w.cfg.state).and_then(|_| {
120 +
                oxy_core::durable::write_atomic(&state_file(&w, &origin), text.as_bytes())
121 +
            }) {
122 +
                return plain(
123 +
                    StatusCode::INTERNAL_SERVER_ERROR,
124 +
                    &format!("keeping the witness's state: {e}"),
125 +
                );
126 +
            }
127 +
            let body = Note {
128 +
                text: String::new(),
129 +
                sigs: lines,
130 +
            }
131 +
            .encode();
132 +
            (
133 +
                [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
134 +
                body.trim_start_matches('\n').to_string(),
135 +
            )
136 +
                .into_response()
137 +
        }
138 +
        Ok(Witnessed::Conflict(n)) => conflict(n),
139 +
        Err(WitnessError::Unsigned(e)) => plain(StatusCode::FORBIDDEN, &e),
140 +
        Err(WitnessError::Inconsistent(e)) => plain(StatusCode::UNPROCESSABLE_ENTITY, &e),
141 +
        Err(WitnessError::Bad(e)) => plain(StatusCode::BAD_REQUEST, &e),
142 +
    }
143 +
}
144 +
145 +
/// The witness's routes: `POST /add-checkpoint`.
146 +
pub fn router(cfg: WitnessConfig) -> Router {
147 +
    Router::new()
148 +
        .route("/add-checkpoint", post(add_checkpoint))
149 +
        .with_state(Arc::new(Witness {
150 +
            cfg,
151 +
            lock: Mutex::new(()),
152 +
        }))
153 +
}
154 +
155 +
/// Serve a witness on `listener` until stopped.
156 +
pub fn run_blocking(
157 +
    listener: std::net::TcpListener,
158 +
    mut cfg: WitnessConfig,
159 +
) -> std::io::Result<()> {
160 +
    let rt = tokio::runtime::Builder::new_multi_thread()
161 +
        .enable_all()
162 +
        .build()?;
163 +
    let tls = cfg.tls.take();
164 +
    rt.block_on(async move {
165 +
        listener.set_nonblocking(true)?;
166 +
        match tls {
167 +
            Some(tls) => {
168 +
                let _ = rustls::crypto::ring::default_provider().install_default();
169 +
                let rustls_cfg =
170 +
                    axum_server::tls_rustls::RustlsConfig::from_pem_file(&tls.cert, &tls.key)
171 +
                        .await
172 +
                        .map_err(|e| std::io::Error::other(format!("loading TLS files: {e}")))?;
173 +
                axum_server::from_tcp_rustls(listener, rustls_cfg)?
174 +
                    .serve(router(cfg).into_make_service())
175 +
                    .await
176 +
            }
177 +
            None => axum::serve(tokio::net::TcpListener::from_std(listener)?, router(cfg)).await,
178 +
        }
179 +
    })
180 +
}
181 +
182 +
#[cfg(test)]
183 +
mod tests {
184 +
    use super::*;
185 +
    use axum::body::Body;
186 +
    use axum::http::Request;
187 +
    use tower::ServiceExt;
188 +
189 +
    #[tokio::test]
190 +
    async fn the_witness_cosigns_and_keeps_what_it_cosigned() {
191 +
        let dir = tempfile::tempdir().unwrap();
192 +
        let log_sk = SecretKey::generate();
193 +
        let log = log_sk.identity("log", "[email protected]", 1);
194 +
        let w_sk = SecretKey::generate();
195 +
        let w = w_sk.identity("witness", "[email protected]", 1);
196 +
        let cfg = || WitnessConfig {
197 +
            name: "witness.example".into(),
198 +
            identity: w.clone(),
199 +
            sk: w_sk.clone(),
200 +
            logs: vec![("hub.example/log".into(), log.clone())],
201 +
            state: dir.path().join("state"),
202 +
            tls: None,
203 +
        };
204 +
        let mut stored: Vec<tlog::Hash> = Vec::new();
205 +
        for n in 0..9u64 {
206 +
            stored.extend(
207 +
                tlog::stored_hashes_for_leaf(n, tlog::leaf_hash(&[n as u8]), &stored).unwrap(),
208 +
            );
209 +
        }
210 +
        let note = |n: u64| {
211 +
            Checkpoint {
212 +
                origin: "hub.example/log".into(),
213 +
                size: n,
214 +
                root: tlog::tree_hash(n, &stored).unwrap(),
215 +
                extensions: vec![],
216 +
            }
217 +
            .sign(&log, &log_sk)
218 +
            .unwrap()
219 +
            .encode()
220 +
        };
221 +
        let post = |body: String| async {
222 +
            let res = router(cfg())
223 +
                .oneshot(
224 +
                    Request::post("/add-checkpoint")
225 +
                        .body(Body::from(body))
226 +
                        .unwrap(),
227 +
                )
228 +
                .await
229 +
                .unwrap();
230 +
            let st = res.status();
231 +
            let b = axum::body::to_bytes(res.into_body(), 1 << 20)
232 +
                .await
233 +
                .unwrap();
234 +
            (st, String::from_utf8(b.to_vec()).unwrap())
235 +
        };
236 +
        let (st, lines) = post(
237 +
            AddCheckpoint {
238 +
                old_size: 0,
239 +
                proof: vec![],
240 +
                note: note(4),
241 +
            }
242 +
            .encode(),
243 +
        )
244 +
        .await;
245 +
        assert_eq!(st, StatusCode::OK, "{lines}");
246 +
        let cosigned =
247 +
            Note::parse(&format!("{}\n{lines}", Note::parse(&note(4)).unwrap().text)).unwrap();
248 +
        assert!(cosigned.cosigned_by("witness.example", &w).is_ok());
249 +
        // Kept across a restart (a new router): a wrong old size is told.
250 +
        let (st, size) = post(
251 +
            AddCheckpoint {
252 +
                old_size: 0,
253 +
                proof: vec![],
254 +
                note: note(9),
255 +
            }
256 +
            .encode(),
257 +
        )
258 +
        .await;
259 +
        assert_eq!((st, size.trim()), (StatusCode::CONFLICT, "4"));
260 +
        let proof = tlog::prove_consistency(9, 4, &stored).unwrap();
261 +
        let (st, _) = post(
262 +
            AddCheckpoint {
263 +
                old_size: 4,
264 +
                proof,
265 +
                note: note(9),
266 +
            }
267 +
            .encode(),
268 +
        )
269 +
        .await;
270 +
        assert_eq!(st, StatusCode::OK);
271 +
    }
272 +
}
modified Cargo.lock +1 −0
…
1524 unchanged lines
1525 1525
 "rustls",
1526 1526
 "tempfile",
1527 1527
 "tokio",
1528 +
 "tower",
1528 1529
]
1529 1530
1530 1531
[[package]]
…
1424 unchanged lines
modified crates/oxy-server/Cargo.toml +1 −0
…
14 unchanged lines
15 15
[dev-dependencies]
16 16
rcgen = "0.14"
17 17
tempfile = "3"
18 +
tower = { version = "0.5", features = ["util"] }
modified crates/oxy-server/src/lib.rs +2 −0
…
10 unchanged lines
11 11
//! and authorization) and routing to [`Server::handle`]; `router` is the
12 12
//! standalone server built on [`DirBackend`].
13 13
14 +
pub mod witness;
15 +
14 16
use axum::body::Bytes;
15 17
use axum::extract::{Path, State};
16 18
use axum::http::{header, HeaderMap, Method, StatusCode};
…
438 unchanged lines
modified docs/guide.md +10 −0
…
705 unchanged lines
706 706
oxy tlog verify https://oxygenhub.io --all
707 707
oxy tlog verify --key <KeyId>           # what a key signed
708 708
```
709 +
710 +
Anyone can **witness** a hub's log: a witness cosigns each checkpoint that extends the last one it cosigned, with its own key and clock, so a hub cannot show two histories without the witness noticing. The hub lists its witnesses in `hub.toml` (`[tlog] witnesses`), and its served checkpoint carries their cosignatures.
711 +
712 +
```
713 +
curl -fsSL https://oxygenhub.io/.well-known/oxyhub-key -o oxygenhub.identity
714 +
oxy witness serve --name witness.example.org --log oxygenhub.io/log=oxygenhub.identity \
715 +
    --bind 0.0.0.0:8891 --tls-cert cert.pem --tls-key key.pem
716 +
```
717 +
718 +
It signs with your oxy home's key and keeps what it cosigned in `--state` (default `witness-state`).
modified src/cli/mod.rs +44 −0
…
372 unchanged lines
373 373
        #[command(subcommand)]
374 374
        cmd: SubmoduleCmd,
375 375
    },
376 +
    /// Witness hubs' transparency logs: cosign each checkpoint that extends
377 +
    /// the last one cosigned (spec §14.8)
378 +
    Witness {
379 +
        #[command(subcommand)]
380 +
        cmd: WitnessCmd,
381 +
    },
376 382
    /// Hubs' transparency logs: check that a hub shows everyone the same
377 383
    /// history (spec §14.8)
378 384
    Tlog {
…
287 unchanged lines
666 672
        /// versions of the files they touch
667 673
        #[arg(long, conflicts_with = "discard")]
668 674
        carry: bool,
675 +
    },
676 +
}
677 +
678 +
#[derive(Subcommand)]
679 +
enum WitnessCmd {
680 +
    /// Serve a witness (C2SP tlog-witness: POST /add-checkpoint), signing
681 +
    /// with this oxy home's key (author.key)
682 +
    Serve {
683 +
        /// The witness's name in cosignatures: a host name
684 +
        #[arg(long)]
685 +
        name: String,
686 +
        /// A log to witness, as ORIGIN=FILE: its origin (HOST/log) and its
687 +
        /// key's identity file (a hub's /.well-known/oxyhub-key); repeatable
688 +
        #[arg(long = "log", required = true)]
689 +
        logs: Vec<String>,
690 +
        /// Where to keep what it cosigned
691 +
        #[arg(long, default_value = "witness-state")]
692 +
        state: PathBuf,
693 +
        /// Address and port to listen on
694 +
        #[arg(long, default_value = "127.0.0.1:8891")]
695 +
        bind: String,
696 +
        /// PEM certificate chain; enables HTTPS together with --tls-key
697 +
        #[arg(long, requires = "tls_key")]
698 +
        tls_cert: Option<PathBuf>,
699 +
        /// PEM private key for --tls-cert
700 +
        #[arg(long, requires = "tls_cert")]
701 +
        tls_key: Option<PathBuf>,
669 702
    },
670 703
}
671 704
…
655 unchanged lines
1327 1360
            SubmoduleCmd::Status { json } => sync::submodule_status(json),
1328 1361
            SubmoduleCmd::Remove { path } => sync::submodule_remove(&path),
1329 1362
        },
1363 +
        Cmd::Witness {
1364 +
            cmd:
1365 +
                WitnessCmd::Serve {
1366 +
                    name,
1367 +
                    logs,
1368 +
                    state,
1369 +
                    bind,
1370 +
                    tls_cert,
1371 +
                    tls_key,
1372 +
                },
1373 +
        } => tlog::witness_serve(&name, &logs, state, &bind, tls_cert.zip(tls_key)),
1330 1374
        Cmd::Tlog { cmd } => match cmd {
1331 1375
            TlogCmd::Verify {
1332 1376
                hub,
…
213 unchanged lines
modified src/cli/tlog.rs +54 −1
…
5 unchanged lines
6 6
use super::ui;
7 7
use anyhow::{anyhow, Result};
8 8
use oxy_core::hash::KeyId;
9 -
use std::path::Path;
9 +
use std::path::{Path, PathBuf};
10 10
11 11
pub fn verify(hub: Option<&str>, key: Option<&str>, all: bool, as_json: bool) -> Result<i32> {
12 12
    let repo = open()?;
…
105 unchanged lines
118 118
    }
119 119
    Ok(0)
120 120
}
121 +
122 +
/// `oxy witness serve`: cosign the named logs' checkpoints.
123 +
pub fn witness_serve(
124 +
    name: &str,
125 +
    logs: &[String],
126 +
    state: PathBuf,
127 +
    bind: &str,
128 +
    tls: Option<(PathBuf, PathBuf)>,
129 +
) -> Result<i32> {
130 +
    let home = oxy_core::repo::default_home();
131 +
    let (identity, sk) = oxy_core::repo::home_key(&home)
132 +
        .map_err(|e| anyhow!("{e}"))?
133 +
        .ok_or_else(|| anyhow!("{} names no key (author.key) with its secret: a witness signs with it; oxy identity create", home.display()))?;
134 +
    let mut parsed = Vec::new();
135 +
    for l in logs {
136 +
        let (origin, file) = l
137 +
            .split_once('=')
138 +
            .ok_or_else(|| anyhow!("--log takes ORIGIN=FILE, as oxygenhub.io/log=hub.identity"))?;
139 +
        let bytes = std::fs::read(file).map_err(|e| anyhow!("{file}: {e}"))?;
140 +
        let key =
141 +
            oxy_core::identity::Identity::decode(&bytes).map_err(|e| anyhow!("{file}: {e}"))?;
142 +
        parsed.push((origin.to_string(), key));
143 +
    }
144 +
    let listener = std::net::TcpListener::bind(bind).map_err(|e| anyhow!("binding {bind}: {e}"))?;
145 +
    println!(
146 +
        "{} {} logs as {} with key {} on {}://{bind}/add-checkpoint",
147 +
        ui::done("Witnessing"),
148 +
        parsed.len(),
149 +
        ui::path(name),
150 +
        identity.hash().short(12),
151 +
        if tls.is_some() { "https" } else { "http" }
152 +
    );
153 +
    for (o, k) in &parsed {
154 +
        println!(
155 +
            "  {} {} {}",
156 +
            ui::note(),
157 +
            o,
158 +
            ui::dim(format!("signed by {}", k.hash().short(12)))
159 +
        );
160 +
    }
161 +
    oxy_server::witness::run_blocking(
162 +
        listener,
163 +
        oxy_server::witness::WitnessConfig {
164 +
            name: name.to_string(),
165 +
            identity,
166 +
            sk,
167 +
            logs: parsed,
168 +
            state,
169 +
            tls: tls.map(|(cert, key)| oxy_server::TlsFiles { cert, key }),
170 +
        },
171 +
    )?;
172 +
    Ok(0)
173 +
}