1use 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
18pub const DEFAULT_LEAF_LIFETIME_DAYS: u32 = 90;
23
24const CA_VALIDITY_YEARS: i64 = 10;
26
27pub struct CaState {
29 pub(crate) key: CaKeyPair,
32 pub(crate) rcgen_key: KeyPair,
34 pub(crate) ca_cert: rcgen::Certificate,
36 pub cert_pem: String,
38 pub(crate) cert_der: Vec<u8>,
40}
41
42#[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
53pub 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
63pub 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
71pub(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
91fn 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 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
115pub 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 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 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 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 std::fs::write(paths.ca_cert_path(), &cert_pem)?;
164
165 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
191pub 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 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 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
247pub 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
268fn 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 let parsed = pem::parse(&cert_pem).map_err(|e| CertmeshError::Certificate(e.to_string()))?;
281 let cert_der = parsed.contents().to_vec();
282
283 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 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
305pub fn issue_certificate(
317 ca: &CaState,
318 hostname: &str,
319 sans: &[String],
320 validity_days: u32,
321) -> Result<IssuedCert, CertmeshError> {
322 let member_key = KeyPair::generate().map_err(|e| CertmeshError::Certificate(e.to_string()))?;
324
325 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 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 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 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
383pub fn ca_fingerprint(ca: &CaState) -> String {
385 pinning::fingerprint_sha256(&ca.cert_der)
386}
387
388pub 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); }
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 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}