koi-certmesh 0.5.1

Zero-config private CA, certificate enrollment, and mesh trust for the local network
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
//! Certificate Authority creation and certificate issuance.
//!
//! Creates ECDSA P-256 root CA certificates using `rcgen` and issues
//! service certificates for mesh members signed by the CA.

use chrono::{DateTime, Duration, Utc};
use koi_crypto::keys::{self, CaKeyPair, CryptoError};
use koi_crypto::pinning;
use koi_crypto::unlock_slots::{self, SlotTable};
use rcgen::{
    BasicConstraints, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair,
    KeyUsagePurpose, SanType,
};
use zeroize::Zeroizing;

use crate::error::CertmeshError;

/// Default leaf lifetime (days) for a CA-issued certificate when the caller
/// passes `0`. Matches the [`crate::roster::CertPolicy`] default
/// (`leaf_lifetime_days = 90`, ADR-017) so the CA's own self-enrolled leaf and a
/// member-signed leaf age on the same schedule.
pub const DEFAULT_LEAF_LIFETIME_DAYS: u32 = 90;

/// CA certificate validity period.
const CA_VALIDITY_YEARS: i64 = 10;

/// Holds the decrypted CA state in memory.
pub struct CaState {
    /// The CA's cryptographic key pair (koi-crypto type, zeroized on drop).
    /// Used to sign the trust bundle (ADR-017 P1) and kept alive for its Drop impl.
    pub(crate) key: CaKeyPair,
    /// The CA's rcgen KeyPair for signing operations.
    pub(crate) rcgen_key: KeyPair,
    /// The self-signed CA certificate.
    pub(crate) ca_cert: rcgen::Certificate,
    /// CA certificate in PEM format.
    pub cert_pem: String,
    /// CA certificate in DER format (for fingerprinting).
    pub(crate) cert_der: Vec<u8>,
}

/// Result of issuing a certificate to a member.
#[derive(Debug, Clone)]
pub struct IssuedCert {
    pub cert_pem: String,
    pub key_pem: String,
    pub ca_pem: String,
    pub fullchain_pem: String,
    pub fingerprint: String,
    pub expires: DateTime<Utc>,
}

/// Load the slot table from disk. Returns `None` if no slot table exists
/// (legacy passphrase-direct encryption).
pub fn load_slot_table(path: &std::path::Path) -> Result<Option<SlotTable>, CertmeshError> {
    if !path.exists() {
        return Ok(None);
    }
    let table = SlotTable::load(path).map_err(|e| CertmeshError::Crypto(e.to_string()))?;
    Ok(Some(table))
}

/// Save the slot table to disk.
pub fn save_slot_table(table: &SlotTable, path: &std::path::Path) -> Result<(), CertmeshError> {
    table
        .save(path)
        .map_err(|e| CertmeshError::Crypto(e.to_string()))?;
    Ok(())
}

/// Apply the least-privilege **leaf** certificate profile (ADR-017 F10).
///
/// Every member/service certificate the CA issues — whether key-generated by
/// [`issue_certificate`] (the CA's own self-enroll) or CSR-signed by
/// [`crate::csr::sign_csr`] (members) — carries the same profile:
/// - `BasicConstraints: CA:FALSE` (a leaf can never act as a CA),
/// - `KeyUsage = digitalSignature, keyEncipherment`,
/// - `ExtendedKeyUsage = serverAuth, clientAuth` (mesh peers act as both).
pub(crate) fn apply_leaf_profile(params: &mut CertificateParams) {
    params.is_ca = IsCa::ExplicitNoCa;
    params.key_usages = vec![
        KeyUsagePurpose::DigitalSignature,
        KeyUsagePurpose::KeyEncipherment,
    ];
    params.extended_key_usages = vec![
        ExtendedKeyUsagePurpose::ServerAuth,
        ExtendedKeyUsagePurpose::ClientAuth,
    ];
}

/// Build the CA's CertificateParams (without key - rcgen 0.13 style).
fn build_ca_params() -> Result<CertificateParams, CertmeshError> {
    let mut ca_params = CertificateParams::default();
    ca_params
        .distinguished_name
        .push(DnType::CommonName, "Koi Certmesh CA");
    ca_params
        .distinguished_name
        .push(DnType::OrganizationName, "Koi");

    // The CA may sign leaves but NOT sub-CAs (path length 0). (ADR-017 F10)
    ca_params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
    ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];

    let not_before = Utc::now();
    let not_after = not_before + Duration::days(CA_VALIDITY_YEARS * 365);
    ca_params.not_before = time::OffsetDateTime::from_unix_timestamp(not_before.timestamp())
        .unwrap_or(time::OffsetDateTime::now_utc());
    ca_params.not_after = time::OffsetDateTime::from_unix_timestamp(not_after.timestamp())
        .unwrap_or(time::OffsetDateTime::now_utc());

    Ok(ca_params)
}

/// Create a new CA with envelope encryption.
///
/// Generates a keypair, creates a self-signed root CA certificate,
/// encrypts the key with a random master key, creates a slot table
/// with a passphrase slot, and writes everything to disk.
///
/// Returns the CA state and the master key (so callers can add
/// additional unlock slots before discarding it).
pub fn create_ca(
    passphrase: &str,
    entropy_seed: &[u8],
    paths: &crate::CertmeshPaths,
) -> Result<(CaState, Zeroizing<[u8; 32]>), CertmeshError> {
    let ca_key = keys::generate_ca_keypair(entropy_seed)
        .map_err(|e| CertmeshError::Crypto(e.to_string()))?;

    // Build rcgen KeyPair from our P-256 key
    let key_pem = ca_key
        .private_key_pem()
        .map_err(|e| CertmeshError::Crypto(e.to_string()))?;
    let rcgen_key =
        KeyPair::from_pem(&key_pem).map_err(|e| CertmeshError::Certificate(e.to_string()))?;

    // Build CA params and self-sign (rcgen 0.13: params consumed, key by ref)
    let ca_params = build_ca_params()?;
    let ca_cert = ca_params
        .self_signed(&rcgen_key)
        .map_err(|e| CertmeshError::Certificate(e.to_string()))?;

    let cert_pem = ca_cert.pem();
    let cert_der = ca_cert.der().to_vec();

    // Envelope encryption: master key wraps CA key, passphrase wraps master key
    let dir = paths.ca_dir();
    std::fs::create_dir_all(&dir)?;

    let ca_key_der =
        keys::ca_keypair_to_der(&ca_key).map_err(|e| CertmeshError::Crypto(e.to_string()))?;
    let (encrypted_key, slot_table, master_key) =
        unlock_slots::envelope_encrypt_new(&ca_key_der, passphrase)
            .map_err(|e| CertmeshError::Crypto(e.to_string()))?;

    keys::save_encrypted_key(&paths.ca_key_path(), &encrypted_key)?;
    slot_table
        .save(&paths.slot_table_path())
        .map_err(|e| CertmeshError::Crypto(e.to_string()))?;

    // Save CA certificate
    std::fs::write(paths.ca_cert_path(), &cert_pem)?;

    // Platform credential binding - seal the ciphertext in the OS
    // credential store so the key blob is machine-bound.
    if koi_crypto::tpm::is_available() {
        if let Err(e) =
            koi_crypto::tpm::seal_key_material("koi-certmesh-ca", &encrypted_key.ciphertext)
        {
            tracing::warn!(error = %e, "Platform credential sealing failed; falling back to software-only protection");
        } else {
            tracing::info!("CA key material sealed in platform credential store");
        }
    }

    tracing::info!("CA created with envelope encryption");

    Ok((
        CaState {
            key: ca_key,
            rcgen_key,
            ca_cert,
            cert_pem,
            cert_der,
        },
        master_key,
    ))
}

/// Load an existing CA by decrypting the key with the passphrase.
///
/// Supports both legacy (direct passphrase encryption) and envelope
/// encryption (slot table). Legacy keys are auto-migrated to envelope
/// encryption on load.
pub fn load_ca(passphrase: &str, paths: &crate::CertmeshPaths) -> Result<CaState, CertmeshError> {
    let key_path = paths.ca_key_path();
    let slot_path = paths.slot_table_path();

    if !key_path.exists() {
        return Err(CertmeshError::CaNotInitialized);
    }

    let encrypted = keys::load_encrypted_key(&key_path)?;

    let ca_key_der = if slot_path.exists() {
        // ── Envelope encryption path ──
        let slot_table =
            SlotTable::load(&slot_path).map_err(|e| CertmeshError::Crypto(e.to_string()))?;
        let master_key = slot_table
            .unwrap_with_passphrase(passphrase)
            .map_err(|e| match e {
                CryptoError::Decryption(_) => {
                    CertmeshError::Crypto("wrong passphrase or corrupted key file".into())
                }
                other => CertmeshError::Crypto(other.to_string()),
            })?;
        unlock_slots::decrypt_with_master_key(&encrypted, &master_key)
            .map_err(|e| CertmeshError::Crypto(e.to_string()))?
    } else {
        // ── Legacy path: direct passphrase encryption ──
        // Decrypt, then auto-migrate to envelope encryption.
        let plaintext = keys::decrypt_bytes(&encrypted, passphrase).map_err(|e| match e {
            CryptoError::Decryption(_) => {
                CertmeshError::Crypto("wrong passphrase or corrupted key file".into())
            }
            other => CertmeshError::Crypto(other.to_string()),
        })?;

        tracing::info!("Migrating CA key from legacy encryption to envelope encryption");
        let (new_encrypted, slot_table, _master_key) =
            unlock_slots::migrate_to_envelope(&encrypted, passphrase)
                .map_err(|e| CertmeshError::Crypto(e.to_string()))?;

        keys::save_encrypted_key(&key_path, &new_encrypted)?;
        slot_table
            .save(&slot_path)
            .map_err(|e| CertmeshError::Crypto(e.to_string()))?;
        tracing::info!("CA key migrated to envelope encryption");

        plaintext
    };

    build_ca_state_from_der(&ca_key_der, paths)
}

/// Load an existing CA using a pre-unwrapped master key.
///
/// Used when the master key was obtained via TOTP or auto-unlock
/// rather than passphrase.
pub fn load_ca_with_master_key(
    master_key: &[u8; 32],
    paths: &crate::CertmeshPaths,
) -> Result<CaState, CertmeshError> {
    let key_path = paths.ca_key_path();

    if !key_path.exists() {
        return Err(CertmeshError::CaNotInitialized);
    }

    let encrypted = keys::load_encrypted_key(&key_path)?;
    let ca_key_der = unlock_slots::decrypt_with_master_key(&encrypted, master_key)
        .map_err(|e| CertmeshError::Crypto(e.to_string()))?;

    build_ca_state_from_der(&ca_key_der, paths)
}

/// Reconstruct `CaState` from decrypted PKCS#8 DER key bytes.
fn build_ca_state_from_der(
    ca_key_der: &[u8],
    paths: &crate::CertmeshPaths,
) -> Result<CaState, CertmeshError> {
    let ca_key =
        keys::ca_keypair_from_der(ca_key_der).map_err(|e| CertmeshError::Crypto(e.to_string()))?;

    let cert_path = paths.ca_cert_path();
    let cert_pem = std::fs::read_to_string(&cert_path)?;

    // Parse the cert PEM to get DER for fingerprinting
    let parsed = pem::parse(&cert_pem).map_err(|e| CertmeshError::Certificate(e.to_string()))?;
    let cert_der = parsed.contents().to_vec();

    // Rebuild rcgen KeyPair for signing operations
    let key_pem_str = ca_key
        .private_key_pem()
        .map_err(|e| CertmeshError::Crypto(e.to_string()))?;
    let rcgen_key =
        KeyPair::from_pem(&key_pem_str).map_err(|e| CertmeshError::Certificate(e.to_string()))?;

    // Re-create the self-signed CA cert for use as issuer in signed_by()
    let ca_params = build_ca_params()?;
    let ca_cert = ca_params
        .self_signed(&rcgen_key)
        .map_err(|e| CertmeshError::Certificate(e.to_string()))?;

    Ok(CaState {
        key: ca_key,
        rcgen_key,
        ca_cert,
        cert_pem,
        cert_der,
    })
}

/// Issue a service certificate **for the CA's own identity**, signed by this CA.
///
/// This is the one issuance path that **generates a keypair on the CA**, and it
/// survives ADR-017 P3 only for the CA's own `self_enroll` (create + restart):
/// the CA legitimately holds its own private key. **Member** certificates are
/// never minted here — they come from [`crate::csr::sign_csr`] over a
/// member-supplied CSR, so a member private key never exists on the CA.
///
/// `sans` should include: hostname, hostname.local, any IPs. `validity_days`
/// sets the leaf lifetime (the CA-held [`crate::roster::CertPolicy`]
/// `leaf_lifetime_days`); pass `0` to use [`DEFAULT_LEAF_LIFETIME_DAYS`].
pub fn issue_certificate(
    ca: &CaState,
    hostname: &str,
    sans: &[String],
    validity_days: u32,
) -> Result<IssuedCert, CertmeshError> {
    // Generate a new keypair for the member
    let member_key = KeyPair::generate().map_err(|e| CertmeshError::Certificate(e.to_string()))?;

    // Build params with DNS SANs
    let dns_sans: Vec<String> = sans
        .iter()
        .filter(|s| s.parse::<std::net::IpAddr>().is_err())
        .cloned()
        .collect();

    let mut cert_params =
        CertificateParams::new(dns_sans).map_err(|e| CertmeshError::Certificate(e.to_string()))?;

    cert_params
        .distinguished_name
        .push(DnType::CommonName, hostname);

    // Add IP SANs
    for san in sans {
        if let Ok(ip) = san.parse::<std::net::IpAddr>() {
            cert_params.subject_alt_names.push(SanType::IpAddress(ip));
        }
    }

    // Least-privilege leaf profile (ADR-017 F10).
    apply_leaf_profile(&mut cert_params);

    let days = if validity_days == 0 {
        DEFAULT_LEAF_LIFETIME_DAYS
    } else {
        validity_days
    };
    let not_before = Utc::now();
    let not_after = not_before + Duration::days(i64::from(days));
    cert_params.not_before = time::OffsetDateTime::from_unix_timestamp(not_before.timestamp())
        .unwrap_or(time::OffsetDateTime::now_utc());
    cert_params.not_after = time::OffsetDateTime::from_unix_timestamp(not_after.timestamp())
        .unwrap_or(time::OffsetDateTime::now_utc());

    // Sign with the CA (rcgen 0.13: params.signed_by(&member_key, &ca_cert, &ca_key))
    let member_cert = cert_params
        .signed_by(&member_key, &ca.ca_cert, &ca.rcgen_key)
        .map_err(|e| CertmeshError::Certificate(e.to_string()))?;

    let cert_pem = member_cert.pem();
    let key_pem = member_key.serialize_pem();
    let ca_pem = ca.cert_pem.clone();
    let fullchain_pem = format!("{cert_pem}{ca_pem}");

    let fingerprint = pinning::fingerprint_sha256(member_cert.der());

    Ok(IssuedCert {
        cert_pem,
        key_pem,
        ca_pem,
        fullchain_pem,
        fingerprint,
        expires: not_after,
    })
}

/// Get the SHA-256 fingerprint of the CA certificate.
pub fn ca_fingerprint(ca: &CaState) -> String {
    pinning::fingerprint_sha256(&ca.cert_der)
}

/// Get the SHA-256 fingerprint of the CA certificate on disk.
pub fn ca_fingerprint_from_disk(paths: &crate::CertmeshPaths) -> Result<String, CertmeshError> {
    let cert_path = paths.ca_cert_path();
    if !cert_path.exists() {
        return Err(CertmeshError::CaNotInitialized);
    }

    let cert_pem = std::fs::read_to_string(&cert_path)?;
    let parsed = pem::parse(&cert_pem).map_err(|e| CertmeshError::Certificate(e.to_string()))?;
    Ok(pinning::fingerprint_sha256(parsed.contents()))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn test_entropy() -> Vec<u8> {
        let _ = koi_common::test::ensure_data_dir("koi-certmesh-ca-tests");
        vec![42u8; 32]
    }

    #[test]
    fn create_ca_produces_valid_state() {
        let ca_key = keys::generate_ca_keypair(&test_entropy()).unwrap();
        let pem = ca_key.public_key_pem().unwrap();
        assert!(pem.contains("BEGIN PUBLIC KEY"));
    }

    #[test]
    fn ca_fingerprint_is_deterministic() {
        let cert_der = b"test certificate data for fingerprint";
        let fp1 = pinning::fingerprint_sha256(cert_der);
        let fp2 = pinning::fingerprint_sha256(cert_der);
        assert_eq!(fp1, fp2);
        assert_eq!(fp1.len(), 64); // SHA-256 = 32 bytes = 64 hex chars
    }

    #[test]
    fn is_ca_initialized_false_by_default() {
        let paths = crate::CertmeshPaths::with_data_dir(std::path::PathBuf::from("/nonexistent"));
        assert!(!paths.is_ca_initialized());
    }

    #[test]
    fn full_ca_and_issue_round_trip() {
        let entropy = test_entropy();
        let paths = crate::CertmeshPaths::with_data_dir(koi_common::test::ensure_data_dir(
            "koi-certmesh-ca-tests",
        ));
        let (ca, _master_key) = create_ca("test-pass", &entropy, &paths).unwrap();
        assert!(ca.cert_pem.contains("BEGIN CERTIFICATE"));
        assert!(!ca.cert_der.is_empty());

        let issued = issue_certificate(
            &ca,
            "node-05",
            &["node-05".to_string(), "node-05.local".to_string()],
            0,
        )
        .unwrap();

        assert!(issued.cert_pem.contains("BEGIN CERTIFICATE"));
        assert!(issued.key_pem.contains("BEGIN PRIVATE KEY"));
        assert!(issued.fullchain_pem.contains(&issued.cert_pem));
        assert!(issued.fullchain_pem.contains(&issued.ca_pem));
        assert_eq!(issued.fingerprint.len(), 64);
        // Default leaf lifetime is the CA policy default (90 days, ADR-017).
        let days = (issued.expires - chrono::Utc::now()).num_days();
        assert!(
            (89..=90).contains(&days),
            "expected ~90-day leaf, got {days}"
        );
    }
}