Skip to main content

kipuka/ca/
init.rs

1//! CA initialization and validation.
2//!
3//! Loads CA keys and certificates from files or PKCS#11 URIs, validates
4//! that the CA certificate has the required extensions (Basic Constraints
5//! CA:TRUE, Key Usage keyCertSign), and builds the per-CA runtime state.
6
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11use tracing::{debug, info, warn};
12
13/// Errors during CA initialization.
14#[derive(Debug, Error)]
15pub enum CaInitError {
16    /// The CA certificate file could not be read.
17    #[error("failed to read CA certificate from {path}: {source}")]
18    CertRead {
19        path: String,
20        source: std::io::Error,
21    },
22
23    /// No certificates found in the CA certificate file.
24    #[error("no certificates found in {path}")]
25    NoCertificates { path: String },
26
27    /// The CA certificate does not have Basic Constraints CA:TRUE.
28    #[error("CA certificate is missing Basic Constraints CA:TRUE")]
29    NotCaCertificate,
30
31    /// The CA certificate Key Usage does not include keyCertSign.
32    #[error("CA certificate Key Usage does not include keyCertSign")]
33    MissingKeyCertSign,
34
35    /// The CA private key could not be loaded.
36    #[error("failed to load CA private key from {path}: {reason}")]
37    KeyLoad { path: String, reason: String },
38
39    /// The key is referenced by a PKCS#11 URI and requires HSM setup.
40    #[error("PKCS#11 URI detected for CA key: {uri}")]
41    Pkcs11Uri { uri: String },
42
43    /// General configuration error.
44    #[error("CA init error: {0}")]
45    Config(String),
46}
47
48/// Configuration for a single CA backend.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct CaConfig {
51    /// Unique identifier for this CA.
52    pub id: String,
53    /// Path to the CA certificate chain (PEM).
54    pub cert_chain_path: PathBuf,
55    /// Path to the CA private key (PEM, PKCS#8, or PKCS#11 URI).
56    pub key_path: PathBuf,
57    /// Whether the key is stored in an HSM (PKCS#11).
58    pub hsm: bool,
59    /// Priority for HA selection (lower = higher priority).
60    pub priority: u32,
61    /// Weight for weighted distribution.
62    pub weight: u32,
63    /// Base URL for remote CA operations (if this is a proxy to an upstream CA).
64    pub endpoint: Option<String>,
65}
66
67/// Initialized CA instance ready for certificate issuance.
68pub struct CaInstance {
69    /// Configuration this instance was built from.
70    pub config: CaConfig,
71    /// Loaded CA certificate chain (DER-encoded).
72    pub cert_chain: Vec<Vec<u8>>,
73    /// Whether the private key is HSM-backed.
74    pub hsm_backed: bool,
75    // In a full implementation, this would hold the signing key handle:
76    // - Software key: `openssl::pkey::PKey<openssl::pkey::Private>`
77    // - HSM key: `kipuka_hsm::HsmContext` + key handle
78}
79
80impl CaInstance {
81    /// Initialize a CA from configuration.
82    ///
83    /// Loads the certificate chain, validates CA extensions, and prepares
84    /// the signing key (or detects PKCS#11 URI for HSM delegation).
85    pub fn from_config(config: &CaConfig) -> Result<Self, CaInitError> {
86        let cert_chain = load_cert_chain(&config.cert_chain_path)?;
87        validate_ca_certificate(&cert_chain)?;
88
89        // Check if key is PKCS#11 URI.
90        if config.hsm || is_pkcs11_uri(&config.key_path)? {
91            info!(
92                ca_id = %config.id,
93                "CA key is HSM-backed (PKCS#11); deferring to kipuka-hsm"
94            );
95            return Ok(Self {
96                config: config.clone(),
97                cert_chain,
98                hsm_backed: true,
99            });
100        }
101
102        // Software key: validate the file is readable.
103        if !config.key_path.exists() {
104            return Err(CaInitError::KeyLoad {
105                path: config.key_path.display().to_string(),
106                reason: "file does not exist".into(),
107            });
108        }
109
110        info!(
111            ca_id = %config.id,
112            cert_count = cert_chain.len(),
113            hsm = false,
114            "CA instance initialized"
115        );
116
117        Ok(Self {
118            config: config.clone(),
119            cert_chain,
120            hsm_backed: false,
121        })
122    }
123
124    /// The CA identifier.
125    pub fn id(&self) -> &str {
126        &self.config.id
127    }
128}
129
130/// Load a PEM certificate chain and return DER-encoded certificates.
131fn load_cert_chain(path: &Path) -> Result<Vec<Vec<u8>>, CaInitError> {
132    let pem_data = std::fs::read(path).map_err(|e| CaInitError::CertRead {
133        path: path.display().to_string(),
134        source: e,
135    })?;
136
137    let certs: Vec<Vec<u8>> = rustls_pemfile::certs(&mut &pem_data[..])
138        .filter_map(|r| r.ok())
139        .map(|c| c.to_vec())
140        .collect();
141
142    if certs.is_empty() {
143        return Err(CaInitError::NoCertificates {
144            path: path.display().to_string(),
145        });
146    }
147
148    debug!(
149        path = %path.display(),
150        count = certs.len(),
151        "loaded CA certificate chain"
152    );
153
154    Ok(certs)
155}
156
157/// Validate that the first certificate in the chain is a CA certificate.
158///
159/// Checks:
160/// - Basic Constraints: CA:TRUE
161/// - Key Usage: keyCertSign
162///
163/// Uses `synta-certificate` for X.509 parsing when available. For now,
164/// performs a best-effort check by looking for known ASN.1 OID patterns
165/// in the DER encoding.
166fn validate_ca_certificate(chain: &[Vec<u8>]) -> Result<(), CaInitError> {
167    let ca_cert_der = chain.first().ok_or(CaInitError::NotCaCertificate)?;
168
169    // Basic Constraints OID: 2.5.29.19 = 55 1D 13
170    let bc_oid = [0x55, 0x1D, 0x13];
171    if !contains_subsequence(ca_cert_der, &bc_oid) {
172        warn!("CA certificate may be missing Basic Constraints extension");
173        // Don't fail hard yet; the full implementation will use synta-certificate
174        // for proper ASN.1 parsing.
175    }
176
177    // Key Usage OID: 2.5.29.15 = 55 1D 0F
178    let ku_oid = [0x55, 0x1D, 0x0F];
179    if !contains_subsequence(ca_cert_der, &ku_oid) {
180        warn!("CA certificate may be missing Key Usage extension");
181    }
182
183    debug!("CA certificate validation passed (basic OID check)");
184    Ok(())
185}
186
187/// Check if a key file path contains a PKCS#11 URI.
188fn is_pkcs11_uri(path: &Path) -> Result<bool, CaInitError> {
189    if let Some(s) = path.to_str()
190        && s.starts_with("pkcs11:")
191    {
192        return Ok(true);
193    }
194
195    // Also check file contents for a PKCS#11 URI.
196    if path.exists()
197        && let Ok(contents) = std::fs::read_to_string(path)
198        && contents.trim().starts_with("pkcs11:")
199    {
200        return Ok(true);
201    }
202
203    Ok(false)
204}
205
206/// Naive subsequence search in a byte slice.
207fn contains_subsequence(haystack: &[u8], needle: &[u8]) -> bool {
208    haystack
209        .windows(needle.len())
210        .any(|window| window == needle)
211}