Skip to main content

confium_log_server/
api.rs

1//! Axum router + handlers for the log server API.
2
3use std::sync::Arc;
4
5use axum::Json;
6use axum::{
7    Router,
8    extract::{Path, Query, State},
9    http::StatusCode,
10    response::{IntoResponse, Json as AxumJson},
11    routing::{get, post},
12};
13use serde::Deserialize;
14use serde_json::{Value, json};
15
16use crate::cert::{classify_cert, fingerprint, parse_der};
17use crate::db::{Database, Entry};
18use crate::merkle::MerkleState;
19
20/// Shared server state. Cheaply cloneable (everything is behind an
21/// `Arc` / `Mutex`).
22pub struct AppState {
23    pub db: Database,
24    pub merkle: parking_lot::Mutex<MerkleState>,
25    pub page_size: usize,
26}
27
28#[derive(Debug, Deserialize)]
29pub struct AppendRequest {
30    pub artifact_type: String,
31    pub artifact_hash: String,
32}
33
34#[derive(Debug, Deserialize)]
35pub struct AppendCertRequest {
36    /// Base64-encoded DER bytes of the X.509 certificate.
37    pub certificate_der: String,
38}
39
40#[derive(Debug, Deserialize)]
41pub struct WitnessRequest {
42    pub witness_id: String,
43    /// Base64-encoded witness signature over `tree_size || root_hash`.
44    pub signature: String,
45}
46
47#[derive(Debug, Deserialize)]
48pub struct Pagination {
49    pub limit: Option<usize>,
50    pub before: Option<u64>,
51}
52
53pub fn router(state: Arc<AppState>) -> Router {
54    Router::new()
55        // Generic hash-entry API.
56        .route("/v1/append", post(append_hash))
57        .route("/v1/head", get(head))
58        .route("/v1/proof/{sequence}", get(proof))
59        .route("/v1/consistency/{old_size}", get(consistency))
60        // Cert-aware API.
61        .route("/v1/certificates", post(append_certificate))
62        .route("/v1/certificates/{fingerprint}", get(lookup_certificate))
63        .route("/v1/issuers/{issuer}/certificates", get(list_by_issuer))
64        // OTS anchoring.
65        .route("/v1/head/{sequence}/ots", get(get_ots_proof))
66        // Witness gossip.
67        .route("/v1/head/{sequence}/witness", post(post_witness))
68        .route("/v1/head/{sequence}/witnesses", get(list_witnesses))
69        .route("/v1/health", get(health))
70        .route("/metrics", get(metrics))
71        .with_state(state)
72}
73
74// ===== Generic hash-entry handlers =====
75
76async fn append_hash(
77    State(state): State<Arc<AppState>>,
78    AxumJson(req): AxumJson<AppendRequest>,
79) -> Result<impl IntoResponse, ApiError> {
80    let hash_bytes = hex::decode(&req.artifact_hash)
81        .map_err(|e| ApiError::new(StatusCode::BAD_REQUEST, format!("bad hex: {e}")))?;
82    if hash_bytes.len() != 32 {
83        return Err(ApiError::new(
84            StatusCode::BAD_REQUEST,
85            format!("artifact_hash must be 32 bytes, got {}", hash_bytes.len()),
86        ));
87    }
88    let mut arr = [0u8; 32];
89    arr.copy_from_slice(&hash_bytes);
90
91    let artifact_type: confium_transparency::entry::ArtifactType = req
92        .artifact_type
93        .parse()
94        .map_err(|e| ApiError::new(StatusCode::BAD_REQUEST, format!("artifact_type: {e}")))?;
95
96    // One clock reading feeds both the database row and the Merkle
97    // leaf; a rebuild must reproduce this exact leaf.
98    let now = chrono::Utc::now();
99    let timestamp = now.to_rfc3339();
100    let entry = Entry {
101        sequence: 0,
102        artifact_type: req.artifact_type,
103        artifact_hash: req.artifact_hash.clone(),
104        timestamp: timestamp.clone(),
105        issuer_distinguished_name: None,
106        subject_distinguished_name: None,
107        fingerprint_sha256: None,
108        valid_from: None,
109        valid_to: None,
110    };
111    let seq = state.db.append(&entry).map_err(internal_error)?;
112    {
113        let mut merkle = state.merkle.lock();
114        merkle.append(arr, artifact_type, now);
115    }
116
117    let root = state.merkle.lock().root();
118    let size = state.merkle.lock().len();
119    Ok(AxumJson(json!({
120        "sequence": seq,
121        "tree_size": size,
122        "root": hex::encode(root),
123        "timestamp": timestamp,
124    })))
125}
126
127async fn head(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, ApiError> {
128    let merkle = state.merkle.lock();
129    let root = merkle.root();
130    let size = merkle.len();
131    Ok(AxumJson(json!({
132        "tree_size": size,
133        "root": hex::encode(root),
134        "timestamp": chrono::Utc::now().to_rfc3339(),
135    })))
136}
137
138async fn proof(
139    State(state): State<Arc<AppState>>,
140    Path(sequence): Path<u64>,
141) -> Result<impl IntoResponse, ApiError> {
142    let merkle = state.merkle.lock();
143    let proof = merkle
144        .inclusion_proof(sequence)
145        .map_err(|e| ApiError::new(StatusCode::NOT_FOUND, e.to_string()))?;
146    let root = merkle.root();
147    let size = merkle.len();
148    // The leaf pre-image data: with sequence, timestamp, and
149    // entry_hash, an offline verifier can recompute the leaf from the
150    // artifact's SHA-256 and walk the proof to the root without
151    // trusting this server at all.
152    let entry = merkle
153        .tree
154        .entry(sequence)
155        .map_err(|e| ApiError::new(StatusCode::NOT_FOUND, e.to_string()))?;
156    let steps: Vec<Value> = proof
157        .steps
158        .iter()
159        .map(|s| {
160            json!({
161                "sibling": hex::encode(s.sibling),
162                "side": match s.side {
163                    confium_transparency::merkle::Side::Left => "left",
164                    confium_transparency::merkle::Side::Right => "right",
165                }
166            })
167        })
168        .collect();
169    Ok(AxumJson(json!({
170        "sequence": proof.sequence,
171        "steps": steps,
172        "root": hex::encode(root),
173        "tree_size": size,
174        "entry_hash": hex::encode(entry.entry_hash()),
175        "entry_timestamp": entry.timestamp.to_rfc3339(),
176    })))
177}
178
179async fn consistency(
180    State(state): State<Arc<AppState>>,
181    Path(old_size): Path<u64>,
182) -> Result<impl IntoResponse, ApiError> {
183    let merkle = state.merkle.lock();
184    let proof = merkle
185        .consistency_proof(old_size)
186        .map_err(|e| ApiError::new(StatusCode::BAD_REQUEST, e.to_string()))?;
187    let hashes: Vec<String> = proof.iter().map(hex::encode).collect();
188    Ok(AxumJson(json!({
189        "old_size": old_size,
190        "new_size": merkle.len(),
191        "new_root": hex::encode(merkle.root()),
192        "proof": hashes,
193    })))
194}
195
196// ===== Cert-aware handlers =====
197
198async fn append_certificate(
199    State(state): State<Arc<AppState>>,
200    AxumJson(req): AxumJson<AppendCertRequest>,
201) -> Result<impl IntoResponse, ApiError> {
202    let der_bytes = base64::Engine::decode(
203        &base64::engine::general_purpose::STANDARD,
204        req.certificate_der,
205    )
206    .map_err(|e| ApiError::new(StatusCode::BAD_REQUEST, format!("bad base64: {e}")))?;
207
208    let meta = parse_der(&der_bytes)
209        .map_err(|e| ApiError::new(StatusCode::BAD_REQUEST, format!("cert parse: {e}")))?;
210    let artifact_type = classify_cert(&der_bytes, &meta);
211    let fingerprint_hex = hex::encode(fingerprint(&der_bytes));
212
213    let typed: confium_transparency::entry::ArtifactType = artifact_type
214        .parse()
215        .map_err(|e| ApiError::new(StatusCode::BAD_REQUEST, format!("artifact_type: {e}")))?;
216    // One clock reading feeds both the database row and the Merkle
217    // leaf; a rebuild must reproduce this exact leaf.
218    let now = chrono::Utc::now();
219    let timestamp = now.to_rfc3339();
220    let entry = Entry {
221        sequence: 0,
222        artifact_type: artifact_type.clone(),
223        artifact_hash: fingerprint_hex.clone(),
224        timestamp: timestamp.clone(),
225        issuer_distinguished_name: Some(meta.issuer_distinguished_name.clone()),
226        subject_distinguished_name: Some(meta.subject_distinguished_name.clone()),
227        fingerprint_sha256: Some(meta.fingerprint_sha256.clone()),
228        valid_from: Some(meta.valid_from.clone()),
229        valid_to: Some(meta.valid_to.clone()),
230    };
231    let seq = state.db.append(&entry).map_err(internal_error)?;
232    {
233        let mut merkle = state.merkle.lock();
234        merkle.append(fingerprint(&der_bytes), typed, now);
235    }
236
237    let root = state.merkle.lock().root();
238    let size = state.merkle.lock().len();
239    Ok(AxumJson(json!({
240        "sequence": seq,
241        "tree_size": size,
242        "root": hex::encode(root),
243        "timestamp": timestamp,
244        "artifact_type": artifact_type,
245        "fingerprint_sha256": fingerprint_hex,
246        "issuer": meta.issuer_distinguished_name,
247        "subject": meta.subject_distinguished_name,
248    })))
249}
250
251async fn lookup_certificate(
252    State(state): State<Arc<AppState>>,
253    Path(fingerprint): Path<String>,
254) -> Result<impl IntoResponse, ApiError> {
255    let entries = state
256        .db
257        .entries_by_fingerprint(&fingerprint)
258        .map_err(internal_error)?;
259    if entries.is_empty() {
260        return Err(ApiError::new(
261            StatusCode::NOT_FOUND,
262            format!("no entries for fingerprint {fingerprint}"),
263        ));
264    }
265    Ok(AxumJson(json!(entries)))
266}
267
268async fn list_by_issuer(
269    State(state): State<Arc<AppState>>,
270    Path(issuer): Path<String>,
271    Query(page): Query<Pagination>,
272) -> Result<impl IntoResponse, ApiError> {
273    let limit = page.limit.unwrap_or(state.page_size);
274    let entries = state
275        .db
276        .entries_by_issuer(&issuer, limit)
277        .map_err(internal_error)?;
278    Ok(AxumJson(json!({
279        "issuer": issuer,
280        "count": entries.len(),
281        "limit": limit,
282        "entries": entries,
283    })))
284}
285
286// ===== OTS =====
287
288async fn get_ots_proof(
289    State(state): State<Arc<AppState>>,
290    Path(sequence): Path<u64>,
291) -> Result<impl IntoResponse, ApiError> {
292    let row = state.db.get_ots_proof(sequence).map_err(internal_error)?;
293    match row {
294        Some((proof, height, anchor_time)) => Ok(AxumJson(json!({
295            "tree_size": sequence,
296            "ots_proof": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &proof),
297            "bitcoin_height": height,
298            "anchor_time": anchor_time,
299        }))),
300        None => Err(ApiError::new(
301            StatusCode::NOT_FOUND,
302            format!("no OTS proof for tree size {sequence}"),
303        )),
304    }
305}
306
307// ===== Witness gossip =====
308
309async fn post_witness(
310    State(state): State<Arc<AppState>>,
311    Path(sequence): Path<u64>,
312    AxumJson(req): AxumJson<WitnessRequest>,
313) -> Result<impl IntoResponse, ApiError> {
314    let sig = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, req.signature)
315        .map_err(|e| ApiError::new(StatusCode::BAD_REQUEST, format!("bad base64: {e}")))?;
316
317    // Compute the root for this sequence (we trust the witness's
318    // signature over `tree_size || root`; we look up the root we
319    // observed for that tree size and verify the signature covers
320    // it. For the scaffold, we use the current root if sequence
321    // matches; a production deployment stores a per-sequence root
322    // snapshot.)
323    let root = state.merkle.lock().root();
324    state
325        .db
326        .store_witness_sig(sequence, &root, &req.witness_id, &sig)
327        .map_err(internal_error)?;
328    Ok(AxumJson(
329        json!({"accepted": true, "witness_id": req.witness_id}),
330    ))
331}
332
333async fn list_witnesses(
334    State(state): State<Arc<AppState>>,
335    Path(sequence): Path<u64>,
336) -> Result<impl IntoResponse, ApiError> {
337    let sigs = state
338        .db
339        .witness_sigs_for_size(sequence)
340        .map_err(internal_error)?;
341    let witnesses: Vec<Value> = sigs
342        .into_iter()
343        .map(|(wid, sig, ts)| {
344            json!({
345                "witness_id": wid,
346                "signature": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &sig),
347                "timestamp": ts,
348            })
349        })
350        .collect();
351    Ok(AxumJson(json!({
352        "tree_size": sequence,
353        "witnesses": witnesses,
354    })))
355}
356
357// ===== Health =====
358
359async fn health(State(state): State<Arc<AppState>>) -> impl IntoResponse {
360    let size = state.merkle.lock().len();
361    let count = state.db.entry_count().unwrap_or(0);
362    AxumJson(json!({
363        "ok": true,
364        "tree_size": size,
365        "entry_count": count,
366        "version": env!("CARGO_PKG_VERSION"),
367    }))
368}
369
370// ===== Prometheus metrics =====
371
372async fn metrics(State(state): State<Arc<AppState>>) -> impl IntoResponse {
373    let tree_size = state.merkle.lock().len();
374    let entry_count = state.db.entry_count().unwrap_or(0);
375    let witness_count = state
376        .db
377        .witness_sigs_for_size(tree_size)
378        .map(|s| s.len())
379        .unwrap_or(0);
380
381    let body = format!(
382        "# HELP confium_log_tree_size Current number of leaves in the Merkle tree.\n\
383         # TYPE confium_log_tree_size gauge\n\
384         confium_log_tree_size {tree_size}\n\
385         # HELP confium_log_entry_count Total entries ever appended.\n\
386         # TYPE confium_log_entry_count gauge\n\
387         confium_log_entry_count {entry_count}\n\
388         # HELP confium_log_witness_count Number of witnesses for the current tree head.\n\
389         # TYPE confium_log_witness_count gauge\n\
390         confium_log_witness_count {witness_count}\n"
391    );
392
393    (
394        [(
395            axum::http::header::CONTENT_TYPE,
396            "text/plain; version=0.0.4",
397        )],
398        body,
399    )
400}
401
402// ===== Error helpers =====
403
404fn internal_error<E: std::fmt::Display>(e: E) -> ApiError {
405    tracing::error!("internal error: {e}");
406    ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
407}
408
409#[derive(Debug)]
410pub struct ApiError {
411    pub status: StatusCode,
412    pub message: String,
413}
414
415impl ApiError {
416    pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
417        ApiError {
418            status,
419            message: message.into(),
420        }
421    }
422}
423
424impl IntoResponse for ApiError {
425    fn into_response(self) -> axum::response::Response {
426        let body = Json(json!({"error": self.message}));
427        (self.status, body).into_response()
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    //! Router-level round-trips: append an entry and immediately
434    //! fetch its inclusion proof. Regression coverage for the two
435    //! integration bugs the anchor action surfaced: axum 0.8
436    //! rejecting `:param` routes at startup, and the append response
437    //! reporting 1-based rowids while the proof endpoint indexes
438    //! 0-based Merkle leaves.
439
440    use super::*;
441    use axum::body::Body;
442    use axum::http::{Method, Request, StatusCode};
443    use tower::ServiceExt;
444
445    fn app() -> Router {
446        let db = Database::open(std::path::Path::new(":memory:")).unwrap();
447        db.init_schema().unwrap();
448        let merkle = MerkleState::from_db(&db).unwrap();
449        let state = Arc::new(AppState {
450            db,
451            merkle: parking_lot::Mutex::new(merkle),
452            page_size: 100,
453        });
454        router(state)
455    }
456
457    async fn send(app: &Router, method: Method, uri: &str, body: Option<Value>) -> Value {
458        let builder = Request::builder().method(method.clone()).uri(uri);
459        let request = match body {
460            Some(v) => builder
461                .header("content-type", "application/json")
462                .body(Body::from(v.to_string()))
463                .unwrap(),
464            None => builder.body(Body::empty()).unwrap(),
465        };
466        let response = app.clone().oneshot(request).await.unwrap();
467        let status = response.status();
468        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
469            .await
470            .unwrap();
471        let json: Value = if bytes.is_empty() {
472            Value::Null
473        } else {
474            serde_json::from_slice(&bytes)
475                .unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into_owned()))
476        };
477        assert_eq!(status, StatusCode::OK, "{method} {uri} -> {status}: {json}");
478        json
479    }
480
481    #[tokio::test]
482    async fn append_then_proof_round_trip() {
483        let app = app();
484
485        let first = send(
486            &app,
487            Method::POST,
488            "/v1/append",
489            Some(json!({
490                "artifact_type": "threshold_signature",
491                "artifact_hash": "ab".repeat(32),
492            })),
493        )
494        .await;
495        assert_eq!(first["sequence"], 0, "first entry must be sequence 0");
496        assert_eq!(first["tree_size"], 1);
497
498        let second = send(
499            &app,
500            Method::POST,
501            "/v1/append",
502            Some(json!({
503                "artifact_type": "threshold_signature",
504                "artifact_hash": "cd".repeat(32),
505            })),
506        )
507        .await;
508        assert_eq!(second["sequence"], 1);
509        assert_eq!(second["tree_size"], 2);
510
511        // The sequence the append response reported must be immediately
512        // usable on the proof endpoint — no off-by-one, no 404 window.
513        for sequence in [0u64, 1] {
514            let proof = send(&app, Method::GET, &format!("/v1/proof/{sequence}"), None).await;
515            assert_eq!(proof["sequence"], sequence);
516            assert_eq!(proof["root"], second["root"]);
517            assert_eq!(proof["tree_size"], 2);
518            // The leaf pre-image lets an offline verifier recompute
519            // the leaf from the artifact hash.
520            assert!(proof["entry_hash"].as_str().is_some_and(|h| h.len() == 64));
521            assert!(proof["entry_timestamp"].as_str().is_some());
522        }
523
524        let head = send(&app, Method::GET, "/v1/head", None).await;
525        assert_eq!(head["tree_size"], 2);
526        assert_eq!(head["root"], second["root"]);
527    }
528
529    #[tokio::test]
530    async fn proof_of_out_of_range_sequence_is_404() {
531        let app = app();
532        let request = Request::builder()
533            .method(Method::GET)
534            .uri("/v1/proof/0")
535            .body(Body::empty())
536            .unwrap();
537        let response = app.oneshot(request).await.unwrap();
538        assert_eq!(response.status(), StatusCode::NOT_FOUND);
539    }
540
541    /// Leaf hashes cover sequence, timestamp, and artifact hash. A
542    /// restart rebuilds the tree from the database — if the rebuild
543    /// stamped fresh timestamps, every leaf would change and every
544    /// proof issued before the restart would silently stop verifying.
545    /// The root must be identical across a rebuild.
546    #[tokio::test]
547    async fn rebuild_reproduces_the_same_root() {
548        let dir = tempfile::tempdir().unwrap();
549        let db = Database::open(&dir.path().join("log.db")).unwrap();
550        db.init_schema().unwrap();
551        let merkle = parking_lot::Mutex::new(MerkleState::from_db(&db).unwrap());
552        let state = Arc::new(AppState {
553            db,
554            merkle,
555            page_size: 100,
556        });
557        let app = router(state.clone());
558        for hash in ["ab".to_string(), "cd".to_string(), "ef".to_string()] {
559            send(
560                &app,
561                Method::POST,
562                "/v1/append",
563                Some(json!({
564                    "artifact_type": "threshold_signature",
565                    "artifact_hash": hash.repeat(32),
566                })),
567            )
568            .await;
569        }
570        let before = {
571            let m = state.merkle.lock();
572            (m.root(), m.len())
573        };
574
575        let rebuilt = MerkleState::from_db(&state.db).unwrap();
576        assert_eq!(rebuilt.root(), before.0, "rebuild must not change the root");
577        assert_eq!(rebuilt.len(), before.1);
578        assert_eq!(rebuilt.len(), 3);
579    }
580
581    #[tokio::test]
582    async fn append_rejects_unknown_artifact_type() {
583        let app = app();
584        let request = Request::builder()
585            .method(Method::POST)
586            .uri("/v1/append")
587            .header("content-type", "application/json")
588            .body(Body::from(
589                json!({
590                    "artifact_type": "not-a-type",
591                    "artifact_hash": "ab".repeat(32),
592                })
593                .to_string(),
594            ))
595            .unwrap();
596        let response = app.oneshot(request).await.unwrap();
597        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
598    }
599}