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;
pub const DEFAULT_LEAF_LIFETIME_DAYS: u32 = 90;
const CA_VALIDITY_YEARS: i64 = 10;
pub struct CaState {
pub(crate) key: CaKeyPair,
pub(crate) rcgen_key: KeyPair,
pub(crate) ca_cert: rcgen::Certificate,
pub cert_pem: String,
pub(crate) cert_der: Vec<u8>,
}
#[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>,
}
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))
}
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(())
}
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,
];
}
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");
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)
}
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()))?;
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()))?;
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();
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()))?;
std::fs::write(paths.ca_cert_path(), &cert_pem)?;
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,
))
}
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() {
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 {
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)
}
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)
}
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)?;
let parsed = pem::parse(&cert_pem).map_err(|e| CertmeshError::Certificate(e.to_string()))?;
let cert_der = parsed.contents().to_vec();
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()))?;
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,
})
}
pub fn issue_certificate(
ca: &CaState,
hostname: &str,
sans: &[String],
validity_days: u32,
) -> Result<IssuedCert, CertmeshError> {
let member_key = KeyPair::generate().map_err(|e| CertmeshError::Certificate(e.to_string()))?;
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);
for san in sans {
if let Ok(ip) = san.parse::<std::net::IpAddr>() {
cert_params.subject_alt_names.push(SanType::IpAddress(ip));
}
}
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());
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,
})
}
pub fn ca_fingerprint(ca: &CaState) -> String {
pinning::fingerprint_sha256(&ca.cert_der)
}
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); }
#[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);
let days = (issued.expires - chrono::Utc::now()).num_days();
assert!(
(89..=90).contains(&days),
"expected ~90-day leaf, got {days}"
);
}
}