Skip to main content

kipuka_dogtag/
lib.rs

1//! Dogtag PKI CA REST API client for kipuka EST server.
2//!
3//! Provides a Rust client for the Dogtag Certificate Authority REST API,
4//! enabling kipuka to use RHCS/Dogtag PKI as its CA backend for certificate
5//! enrollment, revocation, and management.
6//!
7//! # Architecture
8//!
9//! The client communicates with Dogtag CA over HTTPS using mutual TLS (mTLS)
10//! with an agent certificate. All operations are async and use `hyper` +
11//! `hyper-openssl` for HTTP transport with full PKCS#11 support.
12//!
13//! # Supported Operations
14//!
15//! - **Enrollment**: PKCS#10 profile-based certificate issuance via `/ca/rest/certrequests`
16//! - **Certificate management**: Retrieval, listing, and revocation via `/ca/rest/certs`
17//! - **Profiles**: Profile enumeration and constraint extraction via `/ca/rest/profiles`
18//! - **Full CMC**: CMC request passthrough via `/ca/ee/ca/profileSubmitCMCFull`
19//! - **KRA**: Server-side key generation and archival via `/kra/rest/agent/keys`
20//! - **HA**: Multi-CA connection pooling with health-based routing
21
22pub mod certs;
23pub mod client;
24pub mod cmc;
25pub mod config;
26pub mod enroll;
27pub mod kem;
28pub mod kra;
29pub mod pool;
30pub mod profiles;
31
32pub use certs::{CertFilter, CertInfo, RevocationReason};
33pub use client::DogtagClient;
34pub use cmc::CmcClient;
35pub use config::DogtagConfig;
36pub use enroll::{EnrollResult, EnrollStatus, ServerKeygenResult};
37pub use kra::{KeySearchEntry, KraClient};
38pub use pool::DogtagPool;
39pub use profiles::{ProfileConstraints, ProfileDetail, ProfileInfo};
40
41use thiserror::Error;
42
43/// Errors from Dogtag PKI REST API operations.
44#[derive(Debug, Error)]
45pub enum DogtagError {
46    /// HTTP request failed.
47    #[error("HTTP request failed: {0}")]
48    HttpError(String),
49
50    /// Dogtag returned a non-success HTTP status.
51    #[error("Dogtag returned HTTP {status}: {body}")]
52    ApiError {
53        /// HTTP status code.
54        status: u16,
55        /// Response body text.
56        body: String,
57    },
58
59    /// Failed to parse Dogtag response JSON.
60    #[error("Failed to parse response: {0}")]
61    ParseError(String),
62
63    /// Invalid configuration.
64    #[error("Invalid configuration: {0}")]
65    ConfigError(String),
66
67    /// TLS or certificate error.
68    #[error("TLS error: {0}")]
69    TlsError(String),
70
71    /// I/O error reading certificate or key files.
72    #[error("I/O error: {0}")]
73    IoError(#[from] std::io::Error),
74
75    /// No healthy CA backend available.
76    #[error("No healthy CA backend available")]
77    NoHealthyBackend,
78
79    /// Enrollment request was rejected by the CA.
80    #[error("Enrollment rejected: {reason}")]
81    EnrollmentRejected {
82        /// Rejection reason from the CA.
83        reason: String,
84    },
85
86    /// Enrollment request is pending approval.
87    #[error("Enrollment pending: request_id={request_id}")]
88    EnrollmentPending {
89        /// The request ID to poll for status.
90        request_id: String,
91    },
92
93    /// KRA operation failed.
94    #[error("KRA error: {0}")]
95    KraError(String),
96}
97
98/// Result type alias for Dogtag operations.
99pub type DogtagResult<T> = Result<T, DogtagError>;
100
101pub(crate) fn truncate_str(s: &str, max_bytes: usize) -> &str {
102    if s.len() <= max_bytes {
103        return s;
104    }
105    let mut end = max_bytes;
106    while end > 0 && !s.is_char_boundary(end) {
107        end -= 1;
108    }
109    &s[..end]
110}
111
112/// Cumulative limit applies even to chunked or misleading Content-Length bodies.
113pub(crate) async fn bounded_bytes(mut response: reqwest::Response) -> DogtagResult<Vec<u8>> {
114    const LIMIT: usize = 4 * 1024 * 1024;
115    if response.content_length().is_some_and(|n| n > LIMIT as u64) {
116        return Err(DogtagError::ParseError(
117            "backend response exceeds 4 MiB".into(),
118        ));
119    }
120    let mut bytes = Vec::new();
121    while let Some(chunk) = response
122        .chunk()
123        .await
124        .map_err(|e| DogtagError::HttpError(e.to_string()))?
125    {
126        if chunk.len() > LIMIT.saturating_sub(bytes.len()) {
127            return Err(DogtagError::ParseError(
128                "backend response exceeds 4 MiB".into(),
129            ));
130        }
131        bytes.extend_from_slice(&chunk);
132    }
133    Ok(bytes)
134}
135pub(crate) async fn bounded_text(response: reqwest::Response) -> DogtagResult<String> {
136    Ok(String::from_utf8_lossy(&bounded_bytes(response).await?).into_owned())
137}
138
139#[cfg(test)]
140mod review_regressions {
141    use super::*;
142    #[test]
143    fn tls_verification_is_the_default() {
144        let config: DogtagConfig = serde_json::from_value(serde_json::json!({
145            "ca_url":"https://ca.example.test", "agent_cert_file":"cert", "agent_key_file":"key", "ca_cert_file":"ca", "profile_id":"profile"
146        })).unwrap();
147        assert!(!config.accept_invalid_certs);
148    }
149
150    #[tokio::test]
151    async fn chunked_backend_response_is_bounded() {
152        use std::io::{Read, Write};
153        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
154        let address = listener.local_addr().unwrap();
155        let writer = std::thread::spawn(move || {
156            let (mut socket, _) = listener.accept().unwrap();
157            let mut request = [0; 4096];
158            let _ = socket.read(&mut request);
159            socket
160                .write_all(
161                    b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n",
162                )
163                .unwrap();
164            let block = vec![b'x'; 65536];
165            for _ in 0..65 {
166                if socket
167                    .write_all(b"10000\r\n")
168                    .and_then(|_| socket.write_all(&block))
169                    .and_then(|_| socket.write_all(b"\r\n"))
170                    .is_err()
171                {
172                    return;
173                }
174            }
175            let _ = socket.write_all(b"0\r\n\r\n");
176        });
177        let response = reqwest::get(format!("http://{address}")).await.unwrap();
178        assert!(
179            bounded_bytes(response)
180                .await
181                .unwrap_err()
182                .to_string()
183                .contains("exceeds 4 MiB")
184        );
185        writer.join().unwrap();
186    }
187}