Skip to main content

koi_certmesh/
ca.rs

1//! Certificate Authority creation and certificate issuance.
2//!
3//! Creates ECDSA P-256 root CA certificates using `rcgen` and issues
4//! service certificates for mesh members signed by the CA.
5
6use chrono::{DateTime, Duration, Utc};
7use koi_crypto::keys::{self, CaKeyPair, CryptoError};
8use koi_crypto::pinning;
9use koi_crypto::unlock_slots::{self, SlotTable};
10use rcgen::{
11    BasicConstraints, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair,
12    KeyUsagePurpose, SanType,
13};
14use zeroize::Zeroizing;
15
16use crate::error::CertmeshError;
17
18/// Default leaf lifetime (days) for a CA-issued certificate when the caller
19/// passes `0`. Matches the [`crate::roster::CertPolicy`] default
20/// (`leaf_lifetime_days = 90`, ADR-017) so the CA's own self-enrolled leaf and a
21/// member-signed leaf age on the same schedule.
22pub const DEFAULT_LEAF_LIFETIME_DAYS: u32 = 90;
23
24/// CA certificate validity period.
25const CA_VALIDITY_YEARS: i64 = 10;
26
27/// Holds the decrypted CA state in memory.
28pub struct CaState {
29    /// The CA's cryptographic key pair (koi-crypto type, zeroized on drop).
30    /// Used to sign the trust bundle (ADR-017 P1) and kept alive for its Drop impl.
31    pub(crate) key: CaKeyPair,
32    /// The CA's rcgen KeyPair for signing operations.
33    pub(crate) rcgen_key: KeyPair,
34    /// The self-signed CA certificate.
35    pub(crate) ca_cert: rcgen::Certificate,
36    /// CA certificate in PEM format.
37    pub cert_pem: String,
38    /// CA certificate in DER format (for fingerprinting).
39    pub(crate) cert_der: Vec<u8>,
40}
41
42/// Result of issuing a certificate to a member.
43#[derive(Debug, Clone)]
44pub struct IssuedCert {
45    pub cert_pem: String,
46    pub key_pem: String,
47    pub ca_pem: String,
48    pub fullchain_pem: String,
49    pub fingerprint: String,
50    pub expires: DateTime<Utc>,
51}
52
53/// Load the slot table from disk. Returns `None` if no slot table exists
54/// (legacy passphrase-direct encryption).
55pub fn load_slot_table(path: &std::path::Path) -> Result<Option<SlotTable>, CertmeshError> {
56    if !path.exists() {
57        return Ok(None);
58    }
59    let table = SlotTable::load(path).map_err(|e| CertmeshError::Crypto(e.to_string()))?;
60    Ok(Some(table))
61}
62
63/// Save the slot table to disk.
64pub fn save_slot_table(table: &SlotTable, path: &std::path::Path) -> Result<(), CertmeshError> {
65    table
66        .save(path)
67        .map_err(|e| CertmeshError::Crypto(e.to_string()))?;
68    Ok(())
69}
70
71/// Apply the least-privilege **leaf** certificate profile (ADR-017 F10).
72///
73/// Every member/service certificate the CA issues — whether key-generated by
74/// [`issue_certificate`] (the CA's own self-enroll) or CSR-signed by
75/// [`crate::csr::sign_csr`] (members) — carries the same profile:
76/// - `BasicConstraints: CA:FALSE` (a leaf can never act as a CA),
77/// - `KeyUsage = digitalSignature, keyEncipherment`,
78/// - `ExtendedKeyUsage = serverAuth, clientAuth` (mesh peers act as both).
79pub(crate) fn apply_leaf_profile(params: &mut CertificateParams) {
80    params.is_ca = IsCa::ExplicitNoCa;
81    params.key_usages = vec![
82        KeyUsagePurpose::DigitalSignature,
83        KeyUsagePurpose::KeyEncipherment,
84    ];
85    params.extended_key_usages = vec![
86        ExtendedKeyUsagePurpose::ServerAuth,
87        ExtendedKeyUsagePurpose::ClientAuth,
88    ];
89}
90
91/// Build the CA's CertificateParams (without key - rcgen 0.13 style).
92fn build_ca_params() -> Result<CertificateParams, CertmeshError> {
93    let mut ca_params = CertificateParams::default();
94    ca_params
95        .distinguished_name
96        .push(DnType::CommonName, "Koi Certmesh CA");
97    ca_params
98        .distinguished_name
99        .push(DnType::OrganizationName, "Koi");
100
101    // The CA may sign leaves but NOT sub-CAs (path length 0). (ADR-017 F10)
102    ca_params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
103    ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
104
105    let not_before = Utc::now();
106    let not_after = not_before + Duration::days(CA_VALIDITY_YEARS * 365);
107    ca_params.not_before = time::OffsetDateTime::from_unix_timestamp(not_before.timestamp())
108        .unwrap_or(time::OffsetDateTime::now_utc());
109    ca_params.not_after = time::OffsetDateTime::from_unix_timestamp(not_after.timestamp())
110        .unwrap_or(time::OffsetDateTime::now_utc());
111
112    Ok(ca_params)
113}
114
115/// Create a new CA with envelope encryption.
116///
117/// Generates a keypair, creates a self-signed root CA certificate,
118/// encrypts the key with a random master key, creates a slot table
119/// with a passphrase slot, and writes everything to disk.
120///
121/// Returns the CA state and the master key (so callers can add
122/// additional unlock slots before discarding it).
123pub fn create_ca(
124    passphrase: &str,
125    entropy_seed: &[u8],
126    paths: &crate::CertmeshPaths,
127) -> Result<(CaState, Zeroizing<[u8; 32]>), CertmeshError> {
128    let ca_key = keys::generate_ca_keypair(entropy_seed)
129        .map_err(|e| CertmeshError::Crypto(e.to_string()))?;
130
131    // Build rcgen KeyPair from our P-256 key
132    let key_pem = ca_key
133        .private_key_pem()
134        .map_err(|e| CertmeshError::Crypto(e.to_string()))?;
135    let rcgen_key =
136        KeyPair::from_pem(&key_pem).map_err(|e| CertmeshError::Certificate(e.to_string()))?;
137
138    // Build CA params and self-sign (rcgen 0.13: params consumed, key by ref)
139    let ca_params = build_ca_params()?;
140    let ca_cert = ca_params
141        .self_signed(&rcgen_key)
142        .map_err(|e| CertmeshError::Certificate(e.to_string()))?;
143
144    let cert_pem = ca_cert.pem();
145    let cert_der = ca_cert.der().to_vec();
146
147    // Envelope encryption: master key wraps CA key, passphrase wraps master key
148    let dir = paths.ca_dir();
149    std::fs::create_dir_all(&dir)?;
150
151    let ca_key_der =
152        keys::ca_keypair_to_der(&ca_key).map_err(|e| CertmeshError::Crypto(e.to_string()))?;
153    let (encrypted_key, slot_table, master_key) =
154        unlock_slots::envelope_encrypt_new(&ca_key_der, passphrase)
155            .map_err(|e| CertmeshError::Crypto(e.to_string()))?;
156
157    keys::save_encrypted_key(&paths.ca_key_path(), &encrypted_key)?;
158    slot_table
159        .save(&paths.slot_table_path())
160        .map_err(|e| CertmeshError::Crypto(e.to_string()))?;
161
162    // Save CA certificate
163    std::fs::write(paths.ca_cert_path(), &cert_pem)?;
164
165    // Platform credential binding - seal the ciphertext in the OS
166    // credential store so the key blob is machine-bound.
167    if koi_crypto::tpm::is_available() {
168        if let Err(e) =
169            koi_crypto::tpm::seal_key_material("koi-certmesh-ca", &encrypted_key.ciphertext)
170        {
171            tracing::warn!(error = %e, "Platform credential sealing failed; falling back to software-only protection");
172        } else {
173            tracing::info!("CA key material sealed in platform credential store");
174        }
175    }
176
177    tracing::info!("CA created with envelope encryption");
178
179    Ok((
180        CaState {
181            key: ca_key,
182            rcgen_key,
183            ca_cert,
184            cert_pem,
185            cert_der,
186        },
187        master_key,
188    ))
189}
190
191/// Load an existing CA by decrypting the key with the passphrase.
192///
193/// Supports both legacy (direct passphrase encryption) and envelope
194/// encryption (slot table). Legacy keys are auto-migrated to envelope
195/// encryption on load.
196pub fn load_ca(passphrase: &str, paths: &crate::CertmeshPaths) -> Result<CaState, CertmeshError> {
197    let key_path = paths.ca_key_path();
198    let slot_path = paths.slot_table_path();
199
200    if !key_path.exists() {
201        return Err(CertmeshError::CaNotInitialized);
202    }
203
204    let encrypted = keys::load_encrypted_key(&key_path)?;
205
206    let ca_key_der = if slot_path.exists() {
207        // ── Envelope encryption path ──
208        let slot_table =
209            SlotTable::load(&slot_path).map_err(|e| CertmeshError::Crypto(e.to_string()))?;
210        let master_key = slot_table
211            .unwrap_with_passphrase(passphrase)
212            .map_err(|e| match e {
213                CryptoError::Decryption(_) => {
214                    CertmeshError::Crypto("wrong passphrase or corrupted key file".into())
215                }
216                other => CertmeshError::Crypto(other.to_string()),
217            })?;
218        unlock_slots::decrypt_with_master_key(&encrypted, &master_key)
219            .map_err(|e| CertmeshError::Crypto(e.to_string()))?
220    } else {
221        // ── Legacy path: direct passphrase encryption ──
222        // Decrypt, then auto-migrate to envelope encryption.
223        let plaintext = keys::decrypt_bytes(&encrypted, passphrase).map_err(|e| match e {
224            CryptoError::Decryption(_) => {
225                CertmeshError::Crypto("wrong passphrase or corrupted key file".into())
226            }
227            other => CertmeshError::Crypto(other.to_string()),
228        })?;
229
230        tracing::info!("Migrating CA key from legacy encryption to envelope encryption");
231        let (new_encrypted, slot_table, _master_key) =
232            unlock_slots::migrate_to_envelope(&encrypted, passphrase)
233                .map_err(|e| CertmeshError::Crypto(e.to_string()))?;
234
235        keys::save_encrypted_key(&key_path, &new_encrypted)?;
236        slot_table
237            .save(&slot_path)
238            .map_err(|e| CertmeshError::Crypto(e.to_string()))?;
239        tracing::info!("CA key migrated to envelope encryption");
240
241        plaintext
242    };
243
244    build_ca_state_from_der(&ca_key_der, paths)
245}
246
247/// Load an existing CA using a pre-unwrapped master key.
248///
249/// Used when the master key was obtained via TOTP or auto-unlock
250/// rather than passphrase.
251pub fn load_ca_with_master_key(
252    master_key: &[u8; 32],
253    paths: &crate::CertmeshPaths,
254) -> Result<CaState, CertmeshError> {
255    let key_path = paths.ca_key_path();
256
257    if !key_path.exists() {
258        return Err(CertmeshError::CaNotInitialized);
259    }
260
261    let encrypted = keys::load_encrypted_key(&key_path)?;
262    let ca_key_der = unlock_slots::decrypt_with_master_key(&encrypted, master_key)
263        .map_err(|e| CertmeshError::Crypto(e.to_string()))?;
264
265    build_ca_state_from_der(&ca_key_der, paths)
266}
267
268/// Reconstruct `CaState` from decrypted PKCS#8 DER key bytes.
269fn build_ca_state_from_der(
270    ca_key_der: &[u8],
271    paths: &crate::CertmeshPaths,
272) -> Result<CaState, CertmeshError> {
273    let ca_key =
274        keys::ca_keypair_from_der(ca_key_der).map_err(|e| CertmeshError::Crypto(e.to_string()))?;
275
276    let cert_path = paths.ca_cert_path();
277    let cert_pem = std::fs::read_to_string(&cert_path)?;
278
279    // Parse the cert PEM to get DER for fingerprinting
280    let parsed = pem::parse(&cert_pem).map_err(|e| CertmeshError::Certificate(e.to_string()))?;
281    let cert_der = parsed.contents().to_vec();
282
283    // Rebuild rcgen KeyPair for signing operations
284    let key_pem_str = ca_key
285        .private_key_pem()
286        .map_err(|e| CertmeshError::Crypto(e.to_string()))?;
287    let rcgen_key =
288        KeyPair::from_pem(&key_pem_str).map_err(|e| CertmeshError::Certificate(e.to_string()))?;
289
290    // Re-create the self-signed CA cert for use as issuer in signed_by()
291    let ca_params = build_ca_params()?;
292    let ca_cert = ca_params
293        .self_signed(&rcgen_key)
294        .map_err(|e| CertmeshError::Certificate(e.to_string()))?;
295
296    Ok(CaState {
297        key: ca_key,
298        rcgen_key,
299        ca_cert,
300        cert_pem,
301        cert_der,
302    })
303}
304
305/// Issue a service certificate **for the CA's own identity**, signed by this CA.
306///
307/// This is the one issuance path that **generates a keypair on the CA**, and it
308/// survives ADR-017 P3 only for the CA's own `self_enroll` (create + restart):
309/// the CA legitimately holds its own private key. **Member** certificates are
310/// never minted here — they come from [`crate::csr::sign_csr`] over a
311/// member-supplied CSR, so a member private key never exists on the CA.
312///
313/// `sans` should include: hostname, hostname.local, any IPs. `validity_days`
314/// sets the leaf lifetime (the CA-held [`crate::roster::CertPolicy`]
315/// `leaf_lifetime_days`); pass `0` to use [`DEFAULT_LEAF_LIFETIME_DAYS`].
316pub fn issue_certificate(
317    ca: &CaState,
318    hostname: &str,
319    sans: &[String],
320    validity_days: u32,
321) -> Result<IssuedCert, CertmeshError> {
322    // Generate a new keypair for the member
323    let member_key = KeyPair::generate().map_err(|e| CertmeshError::Certificate(e.to_string()))?;
324
325    // Build params with DNS SANs
326    let dns_sans: Vec<String> = sans
327        .iter()
328        .filter(|s| s.parse::<std::net::IpAddr>().is_err())
329        .cloned()
330        .collect();
331
332    let mut cert_params =
333        CertificateParams::new(dns_sans).map_err(|e| CertmeshError::Certificate(e.to_string()))?;
334
335    cert_params
336        .distinguished_name
337        .push(DnType::CommonName, hostname);
338
339    // Add IP SANs
340    for san in sans {
341        if let Ok(ip) = san.parse::<std::net::IpAddr>() {
342            cert_params.subject_alt_names.push(SanType::IpAddress(ip));
343        }
344    }
345
346    // Least-privilege leaf profile (ADR-017 F10).
347    apply_leaf_profile(&mut cert_params);
348
349    let days = if validity_days == 0 {
350        DEFAULT_LEAF_LIFETIME_DAYS
351    } else {
352        validity_days
353    };
354    let not_before = Utc::now();
355    let not_after = not_before + Duration::days(i64::from(days));
356    cert_params.not_before = time::OffsetDateTime::from_unix_timestamp(not_before.timestamp())
357        .unwrap_or(time::OffsetDateTime::now_utc());
358    cert_params.not_after = time::OffsetDateTime::from_unix_timestamp(not_after.timestamp())
359        .unwrap_or(time::OffsetDateTime::now_utc());
360
361    // Sign with the CA (rcgen 0.13: params.signed_by(&member_key, &ca_cert, &ca_key))
362    let member_cert = cert_params
363        .signed_by(&member_key, &ca.ca_cert, &ca.rcgen_key)
364        .map_err(|e| CertmeshError::Certificate(e.to_string()))?;
365
366    let cert_pem = member_cert.pem();
367    let key_pem = member_key.serialize_pem();
368    let ca_pem = ca.cert_pem.clone();
369    let fullchain_pem = format!("{cert_pem}{ca_pem}");
370
371    let fingerprint = pinning::fingerprint_sha256(member_cert.der());
372
373    Ok(IssuedCert {
374        cert_pem,
375        key_pem,
376        ca_pem,
377        fullchain_pem,
378        fingerprint,
379        expires: not_after,
380    })
381}
382
383/// Get the SHA-256 fingerprint of the CA certificate.
384pub fn ca_fingerprint(ca: &CaState) -> String {
385    pinning::fingerprint_sha256(&ca.cert_der)
386}
387
388/// Get the SHA-256 fingerprint of the CA certificate on disk.
389pub fn ca_fingerprint_from_disk(paths: &crate::CertmeshPaths) -> Result<String, CertmeshError> {
390    let cert_path = paths.ca_cert_path();
391    if !cert_path.exists() {
392        return Err(CertmeshError::CaNotInitialized);
393    }
394
395    let cert_pem = std::fs::read_to_string(&cert_path)?;
396    let parsed = pem::parse(&cert_pem).map_err(|e| CertmeshError::Certificate(e.to_string()))?;
397    Ok(pinning::fingerprint_sha256(parsed.contents()))
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    fn test_entropy() -> Vec<u8> {
405        let _ = koi_common::test::ensure_data_dir("koi-certmesh-ca-tests");
406        vec![42u8; 32]
407    }
408
409    #[test]
410    fn create_ca_produces_valid_state() {
411        let ca_key = keys::generate_ca_keypair(&test_entropy()).unwrap();
412        let pem = ca_key.public_key_pem().unwrap();
413        assert!(pem.contains("BEGIN PUBLIC KEY"));
414    }
415
416    #[test]
417    fn ca_fingerprint_is_deterministic() {
418        let cert_der = b"test certificate data for fingerprint";
419        let fp1 = pinning::fingerprint_sha256(cert_der);
420        let fp2 = pinning::fingerprint_sha256(cert_der);
421        assert_eq!(fp1, fp2);
422        assert_eq!(fp1.len(), 64); // SHA-256 = 32 bytes = 64 hex chars
423    }
424
425    #[test]
426    fn is_ca_initialized_false_by_default() {
427        let paths = crate::CertmeshPaths::with_data_dir(std::path::PathBuf::from("/nonexistent"));
428        assert!(!paths.is_ca_initialized());
429    }
430
431    #[test]
432    fn full_ca_and_issue_round_trip() {
433        let entropy = test_entropy();
434        let paths = crate::CertmeshPaths::with_data_dir(koi_common::test::ensure_data_dir(
435            "koi-certmesh-ca-tests",
436        ));
437        let (ca, _master_key) = create_ca("test-pass", &entropy, &paths).unwrap();
438        assert!(ca.cert_pem.contains("BEGIN CERTIFICATE"));
439        assert!(!ca.cert_der.is_empty());
440
441        let issued = issue_certificate(
442            &ca,
443            "node-05",
444            &["node-05".to_string(), "node-05.local".to_string()],
445            0,
446        )
447        .unwrap();
448
449        assert!(issued.cert_pem.contains("BEGIN CERTIFICATE"));
450        assert!(issued.key_pem.contains("BEGIN PRIVATE KEY"));
451        assert!(issued.fullchain_pem.contains(&issued.cert_pem));
452        assert!(issued.fullchain_pem.contains(&issued.ca_pem));
453        assert_eq!(issued.fingerprint.len(), 64);
454        // Default leaf lifetime is the CA policy default (90 days, ADR-017).
455        let days = (issued.expires - chrono::Utc::now()).num_days();
456        assert!(
457            (89..=90).contains(&days),
458            "expected ~90-day leaf, got {days}"
459        );
460    }
461}