1use 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
20pub 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 pub certificate_der: String,
38}
39
40#[derive(Debug, Deserialize)]
41pub struct WitnessRequest {
42 pub witness_id: String,
43 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 .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 .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 .route("/v1/head/{sequence}/ots", get(get_ots_proof))
66 .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
74async 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 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 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
196async 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 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
286async 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
307async 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 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
357async 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
370async 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
402fn 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 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 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 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 #[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}