OXYGENHUB
◆ v0.6.0 oxygen / crates / oxy-server / src / witness.rs
8.9 KiB · Rust · last changed oxy witness serve: a transparency-log witness anyone can run 18 days ago source blame history raw
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
}