OXYGENHUB

Coverage / crates/oxy-server/src/witness.rs 79.8%

170 of 213 lines run, 18 of 40 functions · run #368 on main · run missed

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
405×
fn state_file(w: &Witness, origin: &str) -> PathBuf {
415×
    let safe: String = origin
425×
        .chars()
4375×
        .map(|c| {
4475×
            if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
4570×
                c
46
            } else {
475×
                '_'
48
            }
4975×
        })
505×
        .collect();
515×
    w.cfg.state.join(safe)
525×
}
53
543×
fn read_state(w: &Witness, origin: &str) -> Option<(u64, tlog::Hash)> {
553×
    let text = std::fs::read_to_string(state_file(w, origin)).ok()?;
562×
    let (size, root) = text.trim().split_once(' ')?;
572×
    let root = oxy_core::hash::ObjectRef::from_hex(root)?;
582×
    Some((size.parse().ok()?, root.0))
593×
}
60
610×
fn plain(status: StatusCode, why: &str) -> Response {
620×
    (
630×
        status,
640×
        [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
650×
        format!("{why}\n"),
660×
    )
670×
        .into_response()
680×
}
69
703×
async fn add_checkpoint(State(w): State<Arc<Witness>>, body: String) -> Response {
713×
    let Ok(req) = AddCheckpoint::parse(&body) else {
720×
        return plain(StatusCode::BAD_REQUEST, "not an add-checkpoint request");
73
    };
743×
    let Some(origin) = Note::parse(&req.note)
753×
        .ok()
763×
        .and_then(|n| Checkpoint::parse(&n.text).ok())
773×
        .map(|c| c.origin)
78
    else {
790×
        return plain(StatusCode::BAD_REQUEST, "the checkpoint does not parse");
80
    };
813×
    let Some(key) = w
823×
        .cfg
833×
        .logs
843×
        .iter()
853×
        .find(|(o, _)| *o == origin)
863×
        .map(|(_, k)| k.clone())
87
    else {
880×
        return plain(
89
            StatusCode::NOT_FOUND,
900×
            &format!("this witness does not witness {origin}"),
91
        );
92
    };
933×
    let _held = w.lock.lock().unwrap_or_else(|p| p.into_inner());
943×
    let latest = read_state(&w, &origin);
953×
    let me = WitnessKey {
963×
        name: &w.cfg.name,
973×
        identity: &w.cfg.identity,
983×
        sk: &w.cfg.sk,
993×
    };
1003×
    let now = std::time::SystemTime::now()
1013×
        .duration_since(std::time::UNIX_EPOCH)
1023×
        .map(|d| d.as_secs())
1033×
        .unwrap_or(0);
1043×
    let conflict = |n: u64| {
1051×
        (
1061×
            StatusCode::CONFLICT,
1071×
            [(header::CONTENT_TYPE, "text/x.tlog.size")],
1081×
            format!("{n}\n"),
1091×
        )
1101×
            .into_response()
1111×
    };
1123×
    match tlog::witness(&req, &origin, &key, latest, &me, now) {
1132×
        Ok(Witnessed::Cosigned { checkpoint, lines }) => {
1142×
            let text = format!(
115
                "{} {}\n",
116
                checkpoint.size,
1172×
                oxy_core::hash::ObjectRef(checkpoint.root).to_hex()
118
            );
1192×
            if let Err(e) = std::fs::create_dir_all(&w.cfg.state).and_then(|_| {
1202×
                oxy_core::durable::write_atomic(&state_file(&w, &origin), text.as_bytes())
1212×
            }) {
1220×
                return plain(
123
                    StatusCode::INTERNAL_SERVER_ERROR,
1240×
                    &format!("keeping the witness's state: {e}"),
125
                );
1262×
            }
1272×
            let body = Note {
1282×
                text: String::new(),
1292×
                sigs: lines,
1302×
            }
1312×
            .encode();
1322×
            (
1332×
                [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
1342×
                body.trim_start_matches('\n').to_string(),
1352×
            )
1362×
                .into_response()
137
        }
1381×
        Ok(Witnessed::Conflict(n)) => conflict(n),
1390×
        Err(WitnessError::Unsigned(e)) => plain(StatusCode::FORBIDDEN, &e),
1400×
        Err(WitnessError::Inconsistent(e)) => plain(StatusCode::UNPROCESSABLE_ENTITY, &e),
1410×
        Err(WitnessError::Bad(e)) => plain(StatusCode::BAD_REQUEST, &e),
142
    }
1433×
}
144
145
/// The witness's routes: `POST /add-checkpoint`.
1463×
pub fn router(cfg: WitnessConfig) -> Router {
1473×
    Router::new()
1483×
        .route("/add-checkpoint", post(add_checkpoint))
1493×
        .with_state(Arc::new(Witness {
1503×
            cfg,
1513×
            lock: Mutex::new(()),
1523×
        }))
1533×
}
154
155
/// Serve a witness on `listener` until stopped.
1560×
pub fn run_blocking(
1570×
    listener: std::net::TcpListener,
1580×
    mut cfg: WitnessConfig,
1590×
) -> std::io::Result<()> {
1600×
    let rt = tokio::runtime::Builder::new_multi_thread()
1610×
        .enable_all()
1620×
        .build()?;
1630×
    let tls = cfg.tls.take();
1640×
    rt.block_on(async move {
1650×
        listener.set_nonblocking(true)?;
1660×
        match tls {
1670×
            Some(tls) => {
1680×
                let _ = rustls::crypto::ring::default_provider().install_default();
1690×
                let rustls_cfg =
1700×
                    axum_server::tls_rustls::RustlsConfig::from_pem_file(&tls.cert, &tls.key)
1710×
                        .await
1720×
                        .map_err(|e| std::io::Error::other(format!("loading TLS files: {e}")))?;
1730×
                axum_server::from_tcp_rustls(listener, rustls_cfg)?
1740×
                    .serve(router(cfg).into_make_service())
1750×
                    .await
176
            }
1770×
            None => axum::serve(tokio::net::TcpListener::from_std(listener)?, router(cfg)).await,
178
        }
1790×
    })
1800×
}
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]
1901×
    async fn the_witness_cosigns_and_keeps_what_it_cosigned() {
1911×
        let dir = tempfile::tempdir().unwrap();
1921×
        let log_sk = SecretKey::generate();
1931×
        let log = log_sk.identity("log", "[email protected]", 1);
1941×
        let w_sk = SecretKey::generate();
1951×
        let w = w_sk.identity("witness", "[email protected]", 1);
1961×
        let cfg = || WitnessConfig {
1973×
            name: "witness.example".into(),
1983×
            identity: w.clone(),
1993×
            sk: w_sk.clone(),
2003×
            logs: vec![("hub.example/log".into(), log.clone())],
2013×
            state: dir.path().join("state"),
2023×
            tls: None,
2033×
        };
2041×
        let mut stored: Vec<tlog::Hash> = Vec::new();
2059×
        for n in 0..9u64 {
2069×
            stored.extend(
2079×
                tlog::stored_hashes_for_leaf(n, tlog::leaf_hash(&[n as u8]), &stored).unwrap(),
2089×
            );
2099×
        }
2104×
        let note = |n: u64| {
2114×
            Checkpoint {
2124×
                origin: "hub.example/log".into(),
2134×
                size: n,
2144×
                root: tlog::tree_hash(n, &stored).unwrap(),
2154×
                extensions: vec![],
2164×
            }
2174×
            .sign(&log, &log_sk)
2184×
            .unwrap()
2194×
            .encode()
2204×
        };
2213×
        let post = |body: String| async {
2223×
            let res = router(cfg())
2233×
                .oneshot(
2243×
                    Request::post("/add-checkpoint")
2253×
                        .body(Body::from(body))
2263×
                        .unwrap(),
2273×
                )
2283×
                .await
2293×
                .unwrap();
2303×
            let st = res.status();
2313×
            let b = axum::body::to_bytes(res.into_body(), 1 << 20)
2323×
                .await
2333×
                .unwrap();
2343×
            (st, String::from_utf8(b.to_vec()).unwrap())
2356×
        };
2361×
        let (st, lines) = post(
2371×
            AddCheckpoint {
2381×
                old_size: 0,
2391×
                proof: vec![],
2401×
                note: note(4),
2411×
            }
2421×
            .encode(),
2431×
        )
2441×
        .await;
2451×
        assert_eq!(st, StatusCode::OK, "{lines}");
2461×
        let cosigned =
2471×
            Note::parse(&format!("{}\n{lines}", Note::parse(&note(4)).unwrap().text)).unwrap();
2481×
        assert!(cosigned.cosigned_by("witness.example", &w).is_ok());
249
        // Kept across a restart (a new router): a wrong old size is told.
2501×
        let (st, size) = post(
2511×
            AddCheckpoint {
2521×
                old_size: 0,
2531×
                proof: vec![],
2541×
                note: note(9),
2551×
            }
2561×
            .encode(),
2571×
        )
2581×
        .await;
2591×
        assert_eq!((st, size.trim()), (StatusCode::CONFLICT, "4"));
2601×
        let proof = tlog::prove_consistency(9, 4, &stored).unwrap();
2611×
        let (st, _) = post(
2621×
            AddCheckpoint {
2631×
                old_size: 4,
2641×
                proof,
2651×
                note: note(9),
2661×
            }
2671×
            .encode(),
2681×
        )
2691×
        .await;
2701×
        assert_eq!(st, StatusCode::OK);
2711×
    }
272
}