pub mod acme;
pub mod audit;
pub mod backup;
pub mod bundle;
pub mod ca;
pub mod certfiles;
pub mod certmesh_paths;
pub mod client;
#[cfg(test)]
mod conformance;
pub mod csr;
pub mod diagnosis;
pub mod enrollment;
pub mod entropy;
pub mod envelope;
pub mod error;
pub mod failover;
pub mod health;
pub mod http;
pub mod init_ceremony;
pub mod invite;
pub mod lifecycle;
pub mod member;
pub mod mtls;
pub mod profiles;
pub mod protocol;
pub mod roster;
pub mod sealed;
pub mod serve;
pub mod wordlist;
pub use certmesh_paths::CertmeshPaths;
use std::sync::Arc;
use axum::Router;
use koi_common::capability::{Capability, CapabilityStatus};
use koi_common::posture::Posture;
use koi_crypto::auth::AuthState;
use koi_crypto::totp::RateLimiter;
use tokio::sync::{broadcast, mpsc, oneshot, watch};
use zeroize::Zeroizing;
pub use client::PeerClient;
pub use csr::sign_csr;
pub use error::CertmeshError;
use roster::Roster;
pub const CERTMESH_SERVICE_TYPE: &str = "_certmesh._tcp";
#[derive(Debug, Clone)]
pub enum CertmeshEvent {
MemberJoined {
hostname: String,
fingerprint: String,
},
MemberRevoked { hostname: String },
Destroyed,
CertRenewed {
expires_at: chrono::DateTime<chrono::Utc>,
},
CertExpiringSoon {
days_left: i64,
},
CertRenewalFailed {
reason: String,
consecutive_failures: u32,
},
BundleUpdated {
self_revoked: bool,
},
}
pub(crate) struct CertmeshState {
pub(crate) paths: CertmeshPaths,
pub(crate) ca: tokio::sync::Mutex<Option<ca::CaState>>,
pub(crate) roster: tokio::sync::Mutex<Roster>,
pub(crate) auth: tokio::sync::Mutex<Option<AuthState>>,
pub(crate) pending_challenge: tokio::sync::Mutex<Option<koi_crypto::auth::AuthChallenge>>,
pub(crate) rate_limiter: tokio::sync::Mutex<RateLimiter>,
pub(crate) approval_tx: tokio::sync::Mutex<Option<mpsc::Sender<ApprovalRequest>>>,
pub(crate) event_tx: broadcast::Sender<CertmeshEvent>,
pub(crate) posture_tx: watch::Sender<Posture>,
pub(crate) renewal_failure_count: std::sync::atomic::AtomicU32,
}
#[derive(Debug)]
pub struct ApprovalRequest {
pub hostname: String,
pub requires_approval: bool,
pub respond_to: oneshot::Sender<ApprovalDecision>,
}
#[derive(Debug)]
pub enum ApprovalDecision {
Approved { operator: Option<String> },
Denied,
}
const APPROVAL_TIMEOUT_SECS: u64 = 300;
const RENEWAL_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
#[derive(Clone)]
pub struct SelfEnrollment {
pub cert_pem: String,
pub key_pem: String,
pub ca_cert_pem: String,
}
#[derive(Clone)]
pub struct Identity {
pub hostname: String,
pub cert_pem: String,
pub key_pem: String,
pub ca_cert_pem: String,
pub ca_fingerprint: String,
pub renewal: RenewalHealth,
}
impl std::fmt::Debug for Identity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Identity")
.field("hostname", &self.hostname)
.field("ca_fingerprint", &self.ca_fingerprint)
.field("renewal", &self.renewal)
.field("cert_pem", &"<redacted>")
.field("key_pem", &"<redacted>")
.field("ca_cert_pem", &"<redacted>")
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
pub struct RenewalHealth {
pub expires_at: chrono::DateTime<chrono::Utc>,
pub next_renewal_at: chrono::DateTime<chrono::Utc>,
pub expires_in_days: i64,
pub renew_overdue: bool,
pub expired: bool,
}
impl RenewalHealth {
fn from_leaf(cert_pem: &str, policy: &roster::CertPolicy) -> Option<Self> {
let expires_at = leaf_not_after_utc(cert_pem)?;
let next_renewal_at =
expires_at - chrono::Duration::days(i64::from(policy.renew_threshold_days));
let now = chrono::Utc::now();
Some(Self {
expires_at,
next_renewal_at,
expires_in_days: (expires_at - now).num_days(),
renew_overdue: now >= next_renewal_at,
expired: now >= expires_at,
})
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
pub struct IdentityInfo {
pub hostname: String,
pub ca_fingerprint: String,
pub renewal: RenewalHealth,
}
impl From<&Identity> for IdentityInfo {
fn from(id: &Identity) -> Self {
Self {
hostname: id.hostname.clone(),
ca_fingerprint: id.ca_fingerprint.clone(),
renewal: id.renewal.clone(),
}
}
}
fn initial_posture_tx(paths: &CertmeshPaths) -> watch::Sender<Posture> {
watch::channel(Posture {
signed: node_has_identity(paths),
encrypted: false,
})
.0
}
impl CertmeshState {
pub(crate) fn republish_posture(&self) {
let next = Posture {
signed: node_has_identity(&self.paths),
encrypted: false,
};
self.posture_tx.send_if_modified(|cur| {
if *cur != next {
*cur = next;
true
} else {
false
}
});
}
pub(crate) async fn destroy(&self) -> Result<(), CertmeshError> {
*self.ca.lock().await = None;
*self.auth.lock().await = None;
*self.pending_challenge.lock().await = None;
*self.roster.lock().await = Roster::empty();
if let Err(e) = koi_crypto::tpm::delete_key_material("koi-certmesh-ca") {
tracing::debug!(error = %e, "No platform-sealed key material to clean up");
}
let certmesh_dir = self.paths.certmesh_dir();
let certs_dir = self.paths.certs_dir();
let audit_path = self.paths.audit_log_path();
tokio::task::spawn_blocking(move || {
if certmesh_dir.exists() {
if let Err(e) = std::fs::remove_dir_all(&certmesh_dir) {
tracing::warn!(error = %e, "Failed to remove certmesh directory");
} else {
tracing::info!(path = %certmesh_dir.display(), "Certmesh data directory removed");
}
}
if certs_dir.exists() {
if let Err(e) = std::fs::remove_dir_all(&certs_dir) {
tracing::warn!(error = %e, "Failed to remove certificate files");
} else {
tracing::info!(path = %certs_dir.display(), "Certificate files removed");
}
}
if audit_path.exists() {
if let Err(e) = std::fs::remove_file(&audit_path) {
tracing::warn!(error = %e, "Failed to remove audit log");
} else {
tracing::info!(path = %audit_path.display(), "Audit log removed");
}
}
})
.await
.map_err(|e| CertmeshError::Internal(format!("destroy task: {e}")))?;
tracing::info!("Certmesh state destroyed");
self.republish_posture();
Ok(())
}
pub(crate) async fn commit_roster<F, R>(&self, mutate: F) -> Result<R, CertmeshError>
where
F: FnOnce(&mut Roster) -> Result<R, CertmeshError>,
{
self.commit_inner(true, mutate).await
}
pub(crate) async fn touch_roster<F, R>(&self, mutate: F) -> Result<R, CertmeshError>
where
F: FnOnce(&mut Roster) -> Result<R, CertmeshError>,
{
self.commit_inner(false, mutate).await
}
async fn commit_inner<F, R>(&self, bump_seq: bool, mutate: F) -> Result<R, CertmeshError>
where
F: FnOnce(&mut Roster) -> Result<R, CertmeshError>,
{
let mut roster = self.roster.lock().await;
let out = mutate(&mut roster)?;
if bump_seq {
roster.metadata.seq = roster.metadata.seq.saturating_add(1);
}
let snapshot = roster.clone();
let path = self.paths.roster_path();
let saved = tokio::task::spawn_blocking(move || roster::save_roster(&snapshot, &path))
.await
.map_err(|e| std::io::Error::other(format!("roster save task: {e}")))
.and_then(|r| r)
.map_err(CertmeshError::Io);
if let Err(e) = saved {
let _ = audit::append_entry_to(
&self.paths.audit_log_path(),
"roster_persist_failed",
&[("error", &e.to_string())],
);
return Err(e);
}
Ok(out)
}
}
#[derive(Clone)]
pub struct CertmeshCore {
state: Arc<CertmeshState>,
}
mod core_admin;
mod core_auth;
mod core_enroll;
mod core_identity;
mod core_lifecycle;
mod core_member;
mod core_renewal;
mod core_setup;
const HOOK_FORBIDDEN: &[char] = &[
';', '|', '&', '$', '`', '>', '<', '(', ')', '\n', '\r', '\0', '*', '?', '[', ']', '{', '}',
'~', '%', '!',
];
pub(crate) fn validate_reload_hook(hook: &str) -> Result<(), CertmeshError> {
if hook.contains(HOOK_FORBIDDEN) {
return Err(CertmeshError::InvalidPayload(
"reload hook contains forbidden characters".into(),
));
}
#[cfg(unix)]
if !hook.starts_with('/') {
return Err(CertmeshError::InvalidPayload(
"reload hook must be an absolute path".into(),
));
}
#[cfg(windows)]
{
let bytes = hook.as_bytes();
let drive_letter = bytes.len() >= 3 && bytes[1] == b':';
let unc = hook.starts_with("\\\\");
if !(drive_letter || unc) {
return Err(CertmeshError::InvalidPayload(
"reload hook must be an absolute path".into(),
));
}
}
Ok(())
}
#[derive(Debug)]
pub enum BundleOutcome {
NotApplicable,
NoChange { seq: u64 },
Updated { seq: u64, self_revoked: bool },
}
#[derive(Debug)]
pub enum RenewOutcome {
NotApplicable,
NotDue {
not_after: chrono::DateTime<chrono::Utc>,
},
Renewed {
expires: String,
hook: Option<protocol::HookResult>,
},
}
pub(crate) fn node_has_identity(paths: &CertmeshPaths) -> bool {
let Some(hostname) = CertmeshCore::local_hostname() else {
return false;
};
let leaf = paths.certs_dir().join(&hostname);
let leaf_present = leaf.join("cert.pem").exists() && leaf.join("key.pem").exists();
let anchored = paths.is_ca_initialized() || paths.member_state_path().exists();
leaf_present && anchored
}
fn leaf_not_after_utc(cert_pem: &str) -> Option<chrono::DateTime<chrono::Utc>> {
use x509_parser::prelude::FromDer;
let der = pem::parse(cert_pem).ok()?;
let (_, cert) = x509_parser::certificate::X509Certificate::from_der(der.contents()).ok()?;
chrono::DateTime::from_timestamp(cert.validity().not_after.timestamp(), 0)
}
fn write_file_atomic(path: &std::path::Path, bytes: &[u8], private: bool) -> std::io::Result<()> {
let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
std::fs::write(&tmp, bytes)?;
#[cfg(unix)]
if private {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
}
#[cfg(not(unix))]
let _ = private;
std::fs::rename(&tmp, path)?;
Ok(())
}
pub fn machine_binding_ok(paths: &CertmeshPaths) -> bool {
let recorded = match std::fs::read_to_string(paths.machine_bind_path()) {
Ok(s) => s.trim().to_string(),
Err(_) => return true, };
match koi_crypto::vault::machine_fingerprint() {
Some(current) => koi_crypto::pinning::fingerprints_match(¤t, &recorded),
None => false, }
}
fn write_machine_binding(path: &std::path::Path, fingerprint: &str) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
write_file_atomic(path, fingerprint.as_bytes(), true)
}
fn load_rate_limiter(paths: &CertmeshPaths) -> RateLimiter {
match std::fs::read(paths.rate_limiter_path()) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
tracing::warn!(error = %e, "Could not parse persisted rate-limiter; starting fresh");
RateLimiter::new()
}),
Err(_) => RateLimiter::new(),
}
}
pub(crate) fn persist_rate_limiter(
paths: &CertmeshPaths,
limiter: &RateLimiter,
) -> std::io::Result<()> {
let path = paths.rate_limiter_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_vec(limiter).map_err(std::io::Error::other)?;
write_file_atomic(&path, &json, true)
}
pub(crate) fn validate_hostname(hostname: &str) -> Result<(), CertmeshError> {
let reject = |msg: String| Err(CertmeshError::InvalidPayload(msg));
if hostname.is_empty() || hostname.len() > 253 {
return reject(format!(
"hostname length must be 1..=253 characters: {hostname:?}"
));
}
for label in hostname.split('.') {
if label.is_empty() || label.len() > 63 {
return reject(format!(
"hostname label length must be 1..=63 characters: {hostname:?}"
));
}
if !label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
{
return reject(format!(
"hostname has invalid characters (RFC 1123 allows alphanumerics + hyphen): {hostname:?}"
));
}
if label.starts_with('-') || label.ends_with('-') {
return reject(format!(
"hostname label must not start or end with a hyphen: {hostname:?}"
));
}
}
Ok(())
}
fn decode_hex(hex: &str) -> Option<Vec<u8>> {
if !hex.len().is_multiple_of(2) {
return None;
}
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
.collect()
}
async fn request_approval(
state: &CertmeshState,
hostname: &str,
requires_approval: bool,
) -> Result<Option<String>, CertmeshError> {
let tx = state
.approval_tx
.lock()
.await
.clone()
.ok_or(CertmeshError::ApprovalUnavailable)?;
let (respond_to, response_rx) = oneshot::channel();
let request = ApprovalRequest {
hostname: hostname.to_string(),
requires_approval,
respond_to,
};
if tx.send(request).await.is_err() {
return Err(CertmeshError::ApprovalUnavailable);
}
let decision = match tokio::time::timeout(
std::time::Duration::from_secs(APPROVAL_TIMEOUT_SECS),
response_rx,
)
.await
{
Ok(Ok(decision)) => decision,
Ok(Err(_)) => return Err(CertmeshError::ApprovalUnavailable),
Err(_) => return Err(CertmeshError::ApprovalTimeout),
};
match decision {
ApprovalDecision::Approved { operator } => {
if requires_approval && operator.as_deref().unwrap_or("").is_empty() {
return Err(CertmeshError::ApprovalDenied);
}
Ok(operator)
}
ApprovalDecision::Denied => Err(CertmeshError::ApprovalDenied),
}
}
#[async_trait::async_trait]
impl Capability for CertmeshCore {
fn name(&self) -> &str {
"certmesh"
}
async fn status(&self) -> CapabilityStatus {
let ca_initialized = self.state.paths.is_ca_initialized();
let ca_locked = self
.state
.ca
.try_lock()
.map(|guard| guard.is_none())
.unwrap_or(true);
let member_count = self
.state
.roster
.try_lock()
.map(|guard| guard.active_count())
.unwrap_or(0);
let (summary, healthy) = if !ca_initialized {
("ready \u{2014} run certmesh create".to_string(), true)
} else if ca_locked {
("CA locked".to_string(), false)
} else {
(
format!(
"active ({} member{})",
member_count,
if member_count == 1 { "" } else { "s" }
),
true,
)
};
CapabilityStatus {
name: "certmesh".to_string(),
summary,
healthy,
}
}
}
pub(crate) fn build_status(
paths: &CertmeshPaths,
ca_guard: &Option<ca::CaState>,
roster: &Roster,
auth_method: Option<&str>,
) -> protocol::CertmeshStatus {
let ca_fingerprint = match ca_guard {
Some(ca) => Some(ca::ca_fingerprint(ca)),
None => ca::ca_fingerprint_from_disk(paths).ok(),
};
protocol::CertmeshStatus {
ca_initialized: paths.is_ca_initialized(),
ca_locked: ca_guard.is_none(),
ca_fingerprint,
enrollment_open: roster.metadata.enrollment_open,
requires_approval: roster.metadata.requires_approval,
enrollment_state: roster.enrollment_state(),
auth_method: auth_method.map(|s| s.to_string()),
member_count: roster.active_count(),
seq: roster.metadata.seq,
policy: roster.metadata.policy.clone(),
members: roster
.members
.iter()
.map(|m| protocol::MemberSummary {
hostname: m.hostname.clone(),
role: format!("{:?}", m.role).to_lowercase(),
status: format!("{:?}", m.status).to_lowercase(),
cert_fingerprint: m.cert_fingerprint.clone(),
cert_expires: m.cert_expires.to_rfc3339(),
})
.collect(),
}
}
#[cfg(test)]
mod core_tests;