pub mod audit;
pub mod backup;
pub mod ca;
pub mod certfiles;
pub mod certmesh_paths;
pub mod enrollment;
pub mod entropy;
pub mod error;
pub mod failover;
pub mod health;
pub mod http;
pub mod lifecycle;
pub mod pond_ceremony;
pub mod profiles;
pub mod protocol;
pub mod roster;
pub mod wordlist;
pub use certmesh_paths::CertmeshPaths;
use std::sync::Arc;
use axum::Router;
use koi_common::capability::{Capability, CapabilityStatus};
use koi_crypto::auth::AuthState;
use koi_crypto::totp::RateLimiter;
use tokio::sync::{broadcast, mpsc, oneshot};
use zeroize::Zeroizing;
pub use error::CertmeshError;
use profiles::TrustProfile;
use roster::Roster;
pub const CERTMESH_SERVICE_TYPE: &str = "_certmesh._tcp";
const BROADCAST_CHANNEL_CAPACITY: usize = 256;
#[derive(Debug, Clone)]
pub enum CertmeshEvent {
MemberJoined {
hostname: String,
fingerprint: String,
},
MemberRevoked { hostname: String },
Destroyed,
}
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) profile: tokio::sync::Mutex<TrustProfile>,
pub(crate) approval_tx: tokio::sync::Mutex<Option<mpsc::Sender<ApprovalRequest>>>,
pub(crate) event_tx: broadcast::Sender<CertmeshEvent>,
}
#[derive(Debug)]
pub struct ApprovalRequest {
pub hostname: String,
pub profile: TrustProfile,
pub respond_to: oneshot::Sender<ApprovalDecision>,
}
#[derive(Debug)]
pub enum ApprovalDecision {
Approved { operator: Option<String> },
Denied,
}
const APPROVAL_TIMEOUT_SECS: u64 = 300;
pub struct SelfEnrollment {
pub cert_pem: String,
pub key_pem: String,
pub ca_cert_pem: String,
}
impl CertmeshState {
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();
*self.profile.lock().await = TrustProfile::default();
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");
Ok(())
}
}
pub struct CertmeshCore {
state: Arc<CertmeshState>,
}
impl CertmeshCore {
pub(crate) fn from_state(state: Arc<CertmeshState>) -> Self {
Self { state }
}
pub fn paths(&self) -> &CertmeshPaths {
&self.state.paths
}
pub fn new_with_paths(
ca: ca::CaState,
roster: Roster,
auth_state: Option<AuthState>,
profile: TrustProfile,
paths: CertmeshPaths,
) -> Self {
Self {
state: Arc::new(CertmeshState {
paths,
ca: tokio::sync::Mutex::new(Some(ca)),
roster: tokio::sync::Mutex::new(roster),
auth: tokio::sync::Mutex::new(auth_state),
pending_challenge: tokio::sync::Mutex::new(None),
rate_limiter: tokio::sync::Mutex::new(RateLimiter::new()),
profile: tokio::sync::Mutex::new(profile),
approval_tx: tokio::sync::Mutex::new(None),
event_tx: broadcast::channel(BROADCAST_CHANNEL_CAPACITY).0,
}),
}
}
pub fn locked_with_paths(roster: Roster, profile: TrustProfile, paths: CertmeshPaths) -> Self {
Self {
state: Arc::new(CertmeshState {
paths,
ca: tokio::sync::Mutex::new(None),
roster: tokio::sync::Mutex::new(roster),
auth: tokio::sync::Mutex::new(None),
pending_challenge: tokio::sync::Mutex::new(None),
rate_limiter: tokio::sync::Mutex::new(RateLimiter::new()),
profile: tokio::sync::Mutex::new(profile),
approval_tx: tokio::sync::Mutex::new(None),
event_tx: broadcast::channel(BROADCAST_CHANNEL_CAPACITY).0,
}),
}
}
pub fn uninitialized_with_paths(paths: CertmeshPaths) -> Self {
Self {
state: Arc::new(CertmeshState {
paths,
ca: tokio::sync::Mutex::new(None),
roster: tokio::sync::Mutex::new(Roster::empty()),
auth: tokio::sync::Mutex::new(None),
pending_challenge: tokio::sync::Mutex::new(None),
rate_limiter: tokio::sync::Mutex::new(RateLimiter::new()),
profile: tokio::sync::Mutex::new(TrustProfile::default()),
approval_tx: tokio::sync::Mutex::new(None),
event_tx: broadcast::channel(BROADCAST_CHANNEL_CAPACITY).0,
}),
}
}
pub fn routes(&self) -> Router {
http::routes(Arc::clone(&self.state))
}
pub fn http_routes(&self) -> Router {
http::routes(Arc::clone(&self.state))
}
pub fn inter_node_routes(&self) -> Router {
http::inter_node_routes(Arc::clone(&self.state))
}
pub async fn set_approval_channel(&self, tx: mpsc::Sender<ApprovalRequest>) {
*self.state.approval_tx.lock().await = Some(tx);
}
pub fn subscribe(&self) -> broadcast::Receiver<CertmeshEvent> {
self.state.event_tx.subscribe()
}
pub fn read_audit_log(&self) -> Result<String, CertmeshError> {
audit::read_log_from(&self.state.paths.audit_log_path()).map_err(CertmeshError::Io)
}
pub async fn destroy(&self) -> Result<(), CertmeshError> {
self.state.destroy().await?;
let _ = self.state.event_tx.send(CertmeshEvent::Destroyed);
Ok(())
}
pub async fn enroll(
&self,
request: &protocol::JoinRequest,
) -> Result<protocol::JoinResponse, CertmeshError> {
let hostname = &request.hostname;
if hostname.is_empty()
|| hostname.len() > 253
|| hostname.contains('\0')
|| hostname.contains(' ')
{
return Err(CertmeshError::Internal(
"invalid hostname for certificate SAN".to_string(),
));
}
let mut sans = vec![hostname.clone(), format!("{hostname}.local")];
for extra in &request.sans {
if !sans.contains(extra) {
sans.push(extra.clone());
}
}
let ca_guard = self.state.ca.lock().await;
let ca = ca_guard.as_ref().ok_or_else(|| {
if self.state.paths.is_ca_initialized() {
CertmeshError::CaLocked
} else {
CertmeshError::CaNotInitialized
}
})?;
let roster = self.state.roster.lock().await;
let auth_guard = self.state.auth.lock().await;
let auth_state = auth_guard.as_ref().ok_or(CertmeshError::CaLocked)?;
let challenge_guard = self.state.pending_challenge.lock().await;
let challenge = challenge_guard
.as_ref()
.cloned()
.unwrap_or(koi_crypto::auth::AuthChallenge::Totp);
let mut rate_limiter = self.state.rate_limiter.lock().await;
let profile = roster.metadata.trust_profile;
let requires_approval = roster.requires_approval();
let fallback_operator = roster.metadata.operator.clone();
drop(roster);
let approved_by = if requires_approval {
request_approval(&self.state, hostname, profile).await?
} else {
fallback_operator
};
let mut roster = self.state.roster.lock().await;
let (response, _issued) = enrollment::process_enrollment(
ca,
&mut roster,
auth_state,
&challenge,
&mut rate_limiter,
request,
hostname,
&sans,
approved_by,
&self.state.paths,
)?;
let roster_clone = roster.clone();
let roster_path = self.state.paths.roster_path();
drop(roster);
if let Err(e) =
tokio::task::spawn_blocking(move || roster::save_roster(&roster_clone, &roster_path))
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))
.and_then(|r| r.map_err(CertmeshError::Io))
{
tracing::warn!(error = %e, "Failed to save roster after enrollment");
}
let _ = self.state.event_tx.send(CertmeshEvent::MemberJoined {
hostname: response.hostname.clone(),
fingerprint: response.ca_fingerprint.clone(),
});
Ok(response)
}
pub async fn self_enroll(&self) -> Result<SelfEnrollment, CertmeshError> {
let hostname = hostname::get()
.ok()
.and_then(|os| os.into_string().ok())
.unwrap_or_else(|| "unknown".to_string());
if hostname.len() > 253 || hostname.contains('\0') || hostname.contains(' ') {
return Err(CertmeshError::Internal(format!(
"hostname '{}' is not valid for use in certificate SANs",
&hostname[..hostname.len().min(64)],
)));
}
let sans = vec![
hostname.clone(),
format!("{hostname}.local"),
"localhost".to_string(),
"127.0.0.1".to_string(),
];
let existing_cert_dir = {
let roster = self.state.roster.lock().await;
roster
.members
.iter()
.find(|m| m.hostname == hostname)
.map(|m| std::path::PathBuf::from(&m.cert_path))
};
if let Some(cert_dir) = existing_cert_dir {
let expected_prefix = self.state.paths.certs_dir();
if !cert_dir.starts_with(&expected_prefix) {
return Err(CertmeshError::Internal(format!(
"cert_path '{}' is outside expected directory '{}'",
cert_dir.display(),
expected_prefix.display(),
)));
}
tracing::debug!(hostname = %hostname, "already self-enrolled, reading existing cert");
let ca_guard = self.state.ca.lock().await;
let ca = ca_guard.as_ref().ok_or_else(|| {
if self.state.paths.is_ca_initialized() {
CertmeshError::CaLocked
} else {
CertmeshError::CaNotInitialized
}
})?;
let ca_cert_pem = ca.cert_pem.clone();
drop(ca_guard);
let cert_pem = std::fs::read_to_string(cert_dir.join("cert.pem"))
.map_err(|e| CertmeshError::Internal(format!("read existing cert: {e}")))?;
let key_pem = std::fs::read_to_string(cert_dir.join("key.pem"))
.map_err(|e| CertmeshError::Internal(format!("read existing key: {e}")))?;
return Ok(SelfEnrollment {
cert_pem,
key_pem,
ca_cert_pem,
});
}
let ca_guard = self.state.ca.lock().await;
let ca = ca_guard.as_ref().ok_or_else(|| {
if self.state.paths.is_ca_initialized() {
CertmeshError::CaLocked
} else {
CertmeshError::CaNotInitialized
}
})?;
let issued = ca::issue_certificate(ca, &hostname, &sans)?;
let ca_cert_pem = ca.cert_pem.clone();
let cert_path = self.state.paths.certs_dir().join(&hostname);
let issued_clone = issued.clone();
let cert_dir = tokio::task::spawn_blocking(move || {
certfiles::write_cert_files_to(&cert_path, &issued_clone)
})
.await
.map_err(|e| CertmeshError::Internal(format!("cert write task: {e}")))??;
drop(ca_guard);
let mut roster = self.state.roster.lock().await;
if roster.members.iter().any(|m| m.hostname == hostname) {
tracing::debug!(hostname = %hostname, "concurrent self-enroll resolved, skipping duplicate");
return Ok(SelfEnrollment {
cert_pem: issued.cert_pem,
key_pem: issued.key_pem,
ca_cert_pem,
});
}
roster.members.push(roster::RosterMember {
hostname: hostname.clone(),
role: roster::MemberRole::Primary,
enrolled_at: chrono::Utc::now(),
enrolled_by: Some("self-enrollment".to_string()),
cert_fingerprint: issued.fingerprint.clone(),
cert_expires: issued.expires,
cert_sans: sans,
cert_path: cert_dir.display().to_string(),
status: roster::MemberStatus::Active,
reload_hook: None,
last_seen: Some(chrono::Utc::now()),
pinned_ca_fingerprint: None,
proxy_entries: Vec::new(),
});
let roster_clone = roster.clone();
let roster_path = self.state.paths.roster_path();
drop(roster);
if let Err(e) =
tokio::task::spawn_blocking(move || roster::save_roster(&roster_clone, &roster_path))
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))
.and_then(|r| r.map_err(CertmeshError::Io))
{
tracing::warn!(error = %e, "Failed to save roster after self-enrollment");
}
tracing::info!(hostname = %hostname, "Daemon self-enrolled as certmesh member");
let _ = self.state.event_tx.send(CertmeshEvent::MemberJoined {
hostname,
fingerprint: issued.fingerprint,
});
Ok(SelfEnrollment {
cert_pem: issued.cert_pem,
key_pem: issued.key_pem,
ca_cert_pem,
})
}
pub async fn certmesh_status(&self) -> protocol::CertmeshStatus {
let ca_guard = self.state.ca.lock().await;
let roster = self.state.roster.lock().await;
let profile = self.state.profile.lock().await;
let auth_guard = self.state.auth.lock().await;
let auth_method = auth_guard.as_ref().map(|a| a.method_name());
build_status(self.paths(), &ca_guard, &roster, &profile, auth_method)
}
pub async fn ca_announcement(&self, http_port: u16) -> Option<protocol::CaAnnouncement> {
let ca_guard = self.state.ca.lock().await;
let ca = ca_guard.as_ref()?;
let roster = self.state.roster.lock().await;
let primary = roster.primary()?;
let mut txt = std::collections::HashMap::new();
txt.insert("role".to_string(), "primary".to_string());
txt.insert("fingerprint".to_string(), ca::ca_fingerprint(ca));
let profile = self.state.profile.lock().await;
txt.insert("profile".to_string(), profile.to_string());
txt.insert("auth".to_string(), "totp".to_string());
Some(protocol::CaAnnouncement {
name: format!("koi-ca-{}", primary.hostname),
port: http_port,
txt,
})
}
const HOOK_FORBIDDEN: &'static [char] = &[
';', '|', '&', '$', '`', '>', '<', '(', ')', '\n', '\r', '\0', '*', '?', '[', ']', '{',
'}', '~', '%', '!',
];
pub async fn set_reload_hook(&self, hostname: &str, hook: &str) -> Result<(), CertmeshError> {
if hook.contains(Self::HOOK_FORBIDDEN) {
return Err(CertmeshError::Internal(
"reload hook contains forbidden characters".into(),
));
}
let mut roster = self.state.roster.lock().await;
let member = roster
.find_member_mut(hostname)
.ok_or_else(|| CertmeshError::Internal("member not found".into()))?;
member.reload_hook = Some(hook.to_string());
let roster_clone = roster.clone();
let roster_path = self.state.paths.roster_path();
drop(roster);
tokio::task::spawn_blocking(move || roster::save_roster(&roster_clone, &roster_path))
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))?
.map_err(CertmeshError::Io)?;
tracing::info!(hostname, hook, "Reload hook set");
Ok(())
}
pub async fn set_member_role(
&self,
hostname: &str,
role: roster::MemberRole,
) -> Result<(), CertmeshError> {
let mut roster = self.state.roster.lock().await;
let member = roster
.find_member_mut(hostname)
.ok_or_else(|| CertmeshError::Internal(format!("member not found: {hostname}")))?;
member.role = role.clone();
let roster_clone = roster.clone();
let roster_path = self.state.paths.roster_path();
drop(roster);
tokio::task::spawn_blocking(move || roster::save_roster(&roster_clone, &roster_path))
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))?
.map_err(CertmeshError::Io)?;
tracing::info!(hostname, role = ?role, "Member role updated");
Ok(())
}
pub async fn unlock(&self, passphrase: &str) -> Result<(), CertmeshError> {
let ca_state = ca::load_ca(passphrase, &self.state.paths)?;
let auth_path = self.state.paths.auth_path();
if auth_path.exists() {
let json = std::fs::read_to_string(&auth_path)?;
let stored: koi_crypto::auth::StoredAuth = serde_json::from_str(&json)
.map_err(|e| CertmeshError::Internal(format!("auth.json parse error: {e}")))?;
let auth_state = stored
.unlock(passphrase)
.map_err(|e| CertmeshError::Internal(format!("auth unlock failed: {e}")))?;
*self.state.auth.lock().await = Some(auth_state);
}
*self.state.ca.lock().await = Some(ca_state);
tracing::info!("CA unlocked");
Ok(())
}
pub async fn unlock_with_master_key(&self, master_key: &[u8; 32]) -> Result<(), CertmeshError> {
let ca_state = ca::load_ca_with_master_key(master_key, &self.state.paths)?;
*self.state.ca.lock().await = Some(ca_state);
tracing::info!("CA unlocked via master key (non-passphrase slot)");
Ok(())
}
pub async fn unlock_with_totp(&self, code: &str) -> Result<(), CertmeshError> {
let slot_table =
ca::load_slot_table(&self.state.paths.slot_table_path())?.ok_or_else(|| {
CertmeshError::NoSlotFound(
"no slot table found - pond may use legacy passphrase format".into(),
)
})?;
if !slot_table.has_totp_slot() {
return Err(CertmeshError::NoSlotFound(
"TOTP unlock is not configured for this pond".into(),
));
}
let master_key = slot_table.unwrap_with_totp(code).map_err(|e| {
let msg = e.to_string();
if msg.contains("invalid TOTP code") {
CertmeshError::InvalidAuth
} else {
CertmeshError::Crypto(msg)
}
})?;
self.unlock_with_master_key(&master_key).await
}
pub async fn unlock_with_fido2(&self, credential_id: &[u8]) -> Result<(), CertmeshError> {
let slot_table =
ca::load_slot_table(&self.state.paths.slot_table_path())?.ok_or_else(|| {
CertmeshError::NoSlotFound(
"no slot table found - pond may use legacy passphrase format".into(),
)
})?;
if slot_table.fido2_credential().is_none() {
return Err(CertmeshError::NoSlotFound(
"FIDO2 unlock is not configured for this pond".into(),
));
}
let master_key = slot_table
.unwrap_with_fido2(credential_id)
.map_err(|e| CertmeshError::Crypto(e.to_string()))?;
self.unlock_with_master_key(&master_key).await
}
const VAULT_AUTO_UNLOCK_KEY: &'static str = "certmesh-auto-unlock";
pub fn save_auto_unlock_key_at(
paths: &CertmeshPaths,
passphrase: &str,
) -> Result<(), CertmeshError> {
let vault = koi_crypto::vault::Vault::open(paths.data_dir())?;
vault.store(Self::VAULT_AUTO_UNLOCK_KEY, passphrase)?;
tracing::info!(
backend = vault.backend_name(),
"Auto-unlock key saved to vault"
);
let _ = std::fs::remove_file(paths.auto_unlock_key_path());
let _ = koi_crypto::tpm::delete_key_material("koi-auto-unlock");
Ok(())
}
pub fn read_auto_unlock_key(
paths: &CertmeshPaths,
) -> Result<Option<Zeroizing<String>>, CertmeshError> {
let vault = koi_crypto::vault::Vault::open(paths.data_dir())?;
Ok(match vault.retrieve(Self::VAULT_AUTO_UNLOCK_KEY)? {
Some(pp) if !pp.is_empty() => Some(Zeroizing::new(pp)),
_ => None,
})
}
pub async fn try_auto_unlock(&self) -> Result<bool, CertmeshError> {
let passphrase = match Self::read_auto_unlock_key(&self.state.paths)? {
Some(pp) => pp,
None => return Ok(false),
};
self.unlock(&passphrase).await?;
tracing::info!("Pond auto-unlocked via vault");
Ok(true)
}
pub fn configure_auto_unlock_for_profile(
&self,
profile: profiles::TrustProfile,
passphrase: &str,
) -> Result<(), CertmeshError> {
if profile.should_auto_unlock() && !passphrase.is_empty() {
let paths = self.paths();
Self::save_auto_unlock_key_at(paths, passphrase)?;
let slot_path = paths.slot_table_path();
if let Some(mut table) = ca::load_slot_table(&slot_path)? {
table.add_auto_unlock();
ca::save_slot_table(&table, &slot_path)?;
}
}
Ok(())
}
pub async fn open_enrollment(
&self,
deadline: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), CertmeshError> {
let mut roster = self.state.roster.lock().await;
roster.open_enrollment(deadline);
let roster_clone = roster.clone();
let roster_path = self.state.paths.roster_path();
drop(roster);
tokio::task::spawn_blocking(move || roster::save_roster(&roster_clone, &roster_path))
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))?
.map_err(CertmeshError::Io)?;
if let Some(d) = deadline {
tracing::info!(deadline = %d, "Enrollment window opened with deadline");
} else {
tracing::info!("Enrollment window opened (no deadline)");
}
let _ = audit::append_entry_to(
&self.state.paths.audit_log_path(),
"enrollment_opened",
&[(
"deadline",
&deadline
.map(|d| d.to_rfc3339())
.unwrap_or_else(|| "none".to_string()),
)],
);
Ok(())
}
pub async fn close_enrollment(&self) -> Result<(), CertmeshError> {
let mut roster = self.state.roster.lock().await;
roster.close_enrollment();
let roster_clone = roster.clone();
let roster_path = self.state.paths.roster_path();
drop(roster);
tokio::task::spawn_blocking(move || roster::save_roster(&roster_clone, &roster_path))
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))?
.map_err(CertmeshError::Io)?;
tracing::info!("Enrollment window closed");
let _ =
audit::append_entry_to(&self.state.paths.audit_log_path(), "enrollment_closed", &[]);
Ok(())
}
pub async fn set_policy(
&self,
allowed_domain: Option<String>,
allowed_subnet: Option<String>,
) -> Result<(), CertmeshError> {
if let Some(ref cidr) = allowed_subnet {
if let Some((net_str, prefix_str)) = cidr.split_once('/') {
net_str.parse::<std::net::IpAddr>().map_err(|_| {
CertmeshError::ScopeViolation(format!("invalid subnet CIDR: {cidr}"))
})?;
prefix_str.parse::<u32>().map_err(|_| {
CertmeshError::ScopeViolation(format!("invalid prefix length in CIDR: {cidr}"))
})?;
} else {
return Err(CertmeshError::ScopeViolation(format!(
"invalid CIDR format (expected x.x.x.x/N): {cidr}"
)));
}
}
let mut roster = self.state.roster.lock().await;
roster.metadata.allowed_domain = allowed_domain.clone();
roster.metadata.allowed_subnet = allowed_subnet.clone();
let roster_clone = roster.clone();
let roster_path = self.state.paths.roster_path();
drop(roster);
tokio::task::spawn_blocking(move || roster::save_roster(&roster_clone, &roster_path))
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))?
.map_err(CertmeshError::Io)?;
tracing::info!(
domain = ?allowed_domain,
subnet = ?allowed_subnet,
"Enrollment policy updated"
);
let _ = audit::append_entry_to(
&self.state.paths.audit_log_path(),
"policy_updated",
&[
(
"allowed_domain",
allowed_domain.as_deref().unwrap_or("none"),
),
(
"allowed_subnet",
allowed_subnet.as_deref().unwrap_or("none"),
),
],
);
Ok(())
}
pub async fn rotate_auth(
&self,
passphrase: &str,
method: Option<&str>,
) -> Result<koi_crypto::auth::AuthSetup, CertmeshError> {
let ca_guard = self.state.ca.lock().await;
if ca_guard.is_none() {
return Err(if self.state.paths.is_ca_initialized() {
CertmeshError::CaLocked
} else {
CertmeshError::CaNotInitialized
});
}
drop(ca_guard);
let current_method = self
.state
.auth
.lock()
.await
.as_ref()
.map(|a| a.method_name())
.unwrap_or("totp");
let target = method.unwrap_or(current_method);
let (new_state, stored, setup) = match target {
"totp" => {
let new_secret = koi_crypto::totp::generate_secret();
let stored = koi_crypto::auth::store_totp(&new_secret, passphrase)?;
let uri =
koi_crypto::totp::build_totp_uri(&new_secret, "Koi Certmesh", "enrollment");
let setup = koi_crypto::auth::AuthSetup::Totp { totp_uri: uri };
(AuthState::Totp(new_secret), stored, setup)
}
"fido2" => {
return Err(CertmeshError::Internal(
"FIDO2 rotation requires re-registration via CLI".into(),
));
}
other => {
return Err(CertmeshError::Internal(format!(
"unknown auth method: {other}"
)));
}
};
let json = serde_json::to_string_pretty(&stored)
.map_err(|e| CertmeshError::Internal(format!("auth serialize: {e}")))?;
let auth_path = self.state.paths.auth_path();
tokio::task::spawn_blocking(move || std::fs::write(&auth_path, &json))
.await
.map_err(|e| CertmeshError::Internal(format!("file I/O: {e}")))?
.map_err(CertmeshError::Io)?;
*self.state.auth.lock().await = Some(new_state);
tracing::info!(method = target, "auth credential rotated");
let _ = audit::append_entry_to(
&self.state.paths.audit_log_path(),
"auth_rotated",
&[("method", target)],
);
Ok(setup)
}
pub async fn backup(
&self,
ca_passphrase: &str,
backup_passphrase: &str,
) -> Result<Vec<u8>, CertmeshError> {
if !self.state.paths.is_ca_initialized() {
return Err(CertmeshError::CaNotInitialized);
}
let ca_state = ca::load_ca(ca_passphrase, &self.state.paths)?;
let auth_path = self.state.paths.auth_path();
let json = std::fs::read_to_string(&auth_path)
.map_err(|e| CertmeshError::Internal(format!("cannot read auth.json: {e}")))?;
let stored: koi_crypto::auth::StoredAuth = serde_json::from_str(&json)
.map_err(|e| CertmeshError::Internal(format!("auth.json parse error: {e}")))?;
let auth_state = stored
.unlock(ca_passphrase)
.map_err(|e| CertmeshError::Internal(format!("auth unlock failed: {e}")))?;
let roster = self.state.roster.lock().await;
let roster_json = serde_json::to_string(&*roster)
.map_err(|e| CertmeshError::Internal(format!("roster serialization failed: {e}")))?;
let audit_log =
audit::read_log_from(&self.state.paths.audit_log_path()).map_err(CertmeshError::Io)?;
let ca_key_pem = ca_state
.key
.private_key_pem()
.map_err(|e| CertmeshError::Crypto(e.to_string()))?
.to_string();
let payload = backup::BackupPayload::new(
ca_key_pem,
ca_state.cert_pem.clone(),
auth_state.method_name().to_string(),
auth_state.to_backup_bytes(),
roster_json,
audit_log,
);
let bundle = backup::encode_backup(&payload, backup_passphrase)?;
let _ = audit::append_entry_to(&self.state.paths.audit_log_path(), "backup_created", &[]);
Ok(bundle)
}
pub async fn restore(
&self,
backup_bytes: &[u8],
backup_passphrase: &str,
new_passphrase: &str,
) -> Result<(), CertmeshError> {
let payload = backup::decode_backup(backup_bytes, backup_passphrase)?;
let ca_key = koi_crypto::keys::ca_keypair_from_pem(&payload.ca_key_pem)?;
let ca_key_der = koi_crypto::keys::ca_keypair_to_der(&ca_key)?;
let (encrypted_key, slot_table, _master_key) =
koi_crypto::unlock_slots::envelope_encrypt_new(&ca_key_der, new_passphrase)?;
std::fs::create_dir_all(self.state.paths.ca_dir())?;
koi_crypto::keys::save_encrypted_key(&self.state.paths.ca_key_path(), &encrypted_key)?;
slot_table.save(&self.state.paths.slot_table_path())?;
std::fs::write(self.state.paths.ca_cert_path(), &payload.ca_cert_pem)?;
let auth_state = AuthState::from_backup(&payload.auth_method, payload.auth_data)
.map_err(|e| CertmeshError::Internal(format!("auth restore failed: {e}")))?;
let stored = match &auth_state {
AuthState::Totp(secret) => koi_crypto::auth::store_totp(secret, new_passphrase)?,
AuthState::Fido2(cred) => koi_crypto::auth::store_fido2(cred.clone()),
};
let auth_json = serde_json::to_string_pretty(&stored)
.map_err(|e| CertmeshError::Internal(format!("auth serialize: {e}")))?;
std::fs::write(self.state.paths.auth_path(), auth_json)?;
if let Some(parent) = self.state.paths.roster_path().parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(self.state.paths.roster_path(), &payload.roster_json)?;
if let Some(parent) = self.state.paths.audit_log_path().parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(self.state.paths.audit_log_path(), &payload.audit_log)?;
let restored_roster: Roster = serde_json::from_str(&payload.roster_json)
.map_err(|e| CertmeshError::Internal(format!("roster deserialization failed: {e}")))?;
let ca_state = ca::load_ca(new_passphrase, &self.state.paths)?;
*self.state.ca.lock().await = Some(ca_state);
*self.state.auth.lock().await = Some(auth_state);
*self.state.profile.lock().await = restored_roster.metadata.trust_profile;
*self.state.roster.lock().await = restored_roster;
let _ = audit::append_entry_to(&self.state.paths.audit_log_path(), "backup_restored", &[]);
Ok(())
}
pub async fn revoke_member(
&self,
hostname: &str,
operator: Option<String>,
reason: Option<String>,
) -> Result<(), CertmeshError> {
let mut roster = self.state.roster.lock().await;
roster
.revoke_member(hostname, operator.clone(), reason.clone())
.map_err(CertmeshError::NotFound)?;
let roster_clone = roster.clone();
let roster_path = self.state.paths.roster_path();
drop(roster);
tokio::task::spawn_blocking(move || roster::save_roster(&roster_clone, &roster_path))
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))?
.map_err(CertmeshError::Io)?;
let _ = self.state.event_tx.send(CertmeshEvent::MemberRevoked {
hostname: hostname.to_string(),
});
let _ = audit::append_entry_to(
&self.state.paths.audit_log_path(),
"member_revoked",
&[
("hostname", hostname),
("operator", operator.as_deref().unwrap_or("unknown")),
("reason", reason.as_deref().unwrap_or("none")),
],
);
Ok(())
}
pub async fn renew_all_due(
&self,
) -> Vec<(String, Result<Option<protocol::HookResult>, CertmeshError>)> {
let ca_guard = self.state.ca.lock().await;
let ca = match ca_guard.as_ref() {
Some(ca) => ca,
None => return Vec::new(),
};
let mut roster = self.state.roster.lock().await;
let hostnames: Vec<String> = lifecycle::members_needing_renewal(&roster)
.iter()
.map(|m| m.hostname.clone())
.collect();
let mut results = Vec::new();
for hostname in &hostnames {
let result =
lifecycle::renew_and_update_member(ca, &mut roster, hostname, &self.state.paths);
results.push((hostname.clone(), result));
}
if !hostnames.is_empty() {
let roster_clone = roster.clone();
let roster_path = self.state.paths.roster_path();
drop(roster);
if let Err(e) = tokio::task::spawn_blocking(move || {
roster::save_roster(&roster_clone, &roster_path)
})
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))
.and_then(|r| r.map_err(CertmeshError::Io))
{
tracing::warn!(error = %e, "Failed to save roster after batch renewal");
}
}
results
}
pub async fn receive_renewal(
&self,
request: &protocol::RenewRequest,
) -> Result<protocol::RenewResponse, CertmeshError> {
let issued = ca::IssuedCert {
cert_pem: request.cert_pem.clone(),
key_pem: request.key_pem.clone(),
ca_pem: request.ca_pem.clone(),
fullchain_pem: request.fullchain_pem.clone(),
fingerprint: request.fingerprint.clone(),
expires: chrono::DateTime::parse_from_rfc3339(&request.expires)
.map(|dt| dt.with_timezone(&chrono::Utc))
.unwrap_or_else(|_| chrono::Utc::now()),
};
certfiles::write_cert_files_to(
&self.state.paths.certs_dir().join(&request.hostname),
&issued,
)?;
let hook_cmd = {
let mut roster = self.state.roster.lock().await;
if roster.is_revoked(&request.hostname) {
return Err(CertmeshError::Revoked(request.hostname.clone()));
}
if let Some(member) = roster.find_member_mut(&request.hostname) {
member.cert_fingerprint = issued.fingerprint.clone();
member.cert_expires = issued.expires;
}
roster
.find_member(&request.hostname)
.and_then(|m| m.reload_hook.clone())
};
let hook_result = hook_cmd.map(|hook| lifecycle::execute_reload_hook(&hook));
Ok(protocol::RenewResponse {
hostname: request.hostname.clone(),
renewed: true,
hook_result,
})
}
pub async fn health_check(
&self,
request: &protocol::HealthRequest,
) -> Result<protocol::HealthResponse, CertmeshError> {
let ca_guard = self.state.ca.lock().await;
let ca = ca_guard.as_ref().ok_or_else(|| {
if self.state.paths.is_ca_initialized() {
CertmeshError::CaLocked
} else {
CertmeshError::CaNotInitialized
}
})?;
let current_fp = ca::ca_fingerprint(ca);
let valid =
health::validate_pinned_fingerprint(¤t_fp, &request.pinned_ca_fingerprint);
let mut roster = self.state.roster.lock().await;
if roster.is_revoked(&request.hostname) {
return Err(CertmeshError::Revoked(request.hostname.clone()));
}
roster.touch_member(&request.hostname);
let roster_clone = roster.clone();
let roster_path = self.state.paths.roster_path();
drop(roster);
if let Err(e) =
tokio::task::spawn_blocking(move || roster::save_roster(&roster_clone, &roster_path))
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))
.and_then(|r| r.map_err(CertmeshError::Io))
{
tracing::warn!(error = %e, "Failed to save roster after health heartbeat");
}
Ok(protocol::HealthResponse {
valid,
ca_fingerprint: current_fp,
})
}
pub async fn roster_manifest(&self) -> Result<protocol::RosterManifest, CertmeshError> {
let ca_guard = self.state.ca.lock().await;
let ca = ca_guard.as_ref().ok_or_else(|| {
if self.state.paths.is_ca_initialized() {
CertmeshError::CaLocked
} else {
CertmeshError::CaNotInitialized
}
})?;
let roster = self.state.roster.lock().await;
failover::build_signed_manifest(ca, &roster)
}
pub async fn accept_roster_sync(
&self,
manifest: &protocol::RosterManifest,
) -> Result<(), CertmeshError> {
let verified_roster = failover::verify_manifest(manifest)?;
let mut roster = self.state.roster.lock().await;
*roster = verified_roster;
let roster_clone = roster.clone();
let roster_path = self.state.paths.roster_path();
drop(roster);
tokio::task::spawn_blocking(move || roster::save_roster(&roster_clone, &roster_path))
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))?
.map_err(CertmeshError::Io)?;
tracing::info!("Roster synced from primary");
Ok(())
}
pub async fn node_role(&self) -> Option<roster::MemberRole> {
let hostname = hostname::get()
.map(|h| h.to_string_lossy().to_string())
.ok()?;
let roster = self.state.roster.lock().await;
roster.find_member(&hostname).map(|m| m.role.clone())
}
pub async fn standby_hostnames(&self) -> Vec<String> {
let roster = self.state.roster.lock().await;
roster
.standbys()
.iter()
.map(|m| m.hostname.clone())
.collect()
}
pub async fn promote_self_to_primary(&self) -> Result<bool, CertmeshError> {
let hostname = hostname::get()
.map(|h| h.to_string_lossy().to_string())
.map_err(|_| CertmeshError::Internal("hostname unavailable".to_string()))?;
let mut roster = self.state.roster.lock().await;
let already_primary = roster
.find_member(&hostname)
.map(|m| m.role == roster::MemberRole::Primary)
.ok_or_else(|| CertmeshError::NotFound(hostname.clone()))?;
if already_primary {
return Ok(false);
}
for m in roster.members.iter_mut() {
if m.role == roster::MemberRole::Primary {
m.role = roster::MemberRole::Standby;
}
}
if let Some(member) = roster.find_member_mut(&hostname) {
member.role = roster::MemberRole::Primary;
} else {
return Err(CertmeshError::NotFound(hostname.clone()));
}
let roster_clone = roster.clone();
let roster_path = self.state.paths.roster_path();
drop(roster);
tokio::task::spawn_blocking(move || roster::save_roster(&roster_clone, &roster_path))
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))?
.map_err(CertmeshError::Io)?;
Ok(true)
}
pub async fn demote_self_to_standby(&self) -> Result<bool, CertmeshError> {
let hostname = hostname::get()
.map(|h| h.to_string_lossy().to_string())
.map_err(|_| CertmeshError::Internal("hostname unavailable".to_string()))?;
let mut roster = self.state.roster.lock().await;
let member = roster
.find_member_mut(&hostname)
.ok_or_else(|| CertmeshError::NotFound(hostname.clone()))?;
if member.role == roster::MemberRole::Standby {
return Ok(false);
}
member.role = roster::MemberRole::Standby;
let roster_clone = roster.clone();
let roster_path = self.state.paths.roster_path();
drop(roster);
tokio::task::spawn_blocking(move || roster::save_roster(&roster_clone, &roster_path))
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))?
.map_err(CertmeshError::Io)?;
Ok(true)
}
pub async fn add_alias_sans(
&self,
hostname: &str,
sans: &[String],
) -> Result<bool, CertmeshError> {
let mut roster = self.state.roster.lock().await;
let member = roster
.find_member_mut(hostname)
.ok_or_else(|| CertmeshError::NotFound(hostname.to_string()))?;
let mut changed = false;
for san in sans {
if !member.cert_sans.iter().any(|s| s == san) {
member.cert_sans.push(san.clone());
changed = true;
}
}
if changed {
let roster_clone = roster.clone();
let roster_path = self.state.paths.roster_path();
drop(roster);
tokio::task::spawn_blocking(move || roster::save_roster(&roster_clone, &roster_path))
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))?
.map_err(CertmeshError::Io)?;
}
Ok(changed)
}
pub fn local_hostname() -> Option<String> {
hostname::get()
.map(|h| h.to_string_lossy().to_string())
.ok()
}
pub async fn pinned_ca_fingerprint(&self) -> Option<String> {
let hostname = hostname::get()
.map(|h| h.to_string_lossy().to_string())
.ok()?;
let roster = self.state.roster.lock().await;
roster
.find_member(&hostname)
.and_then(|m| m.pinned_ca_fingerprint.clone())
}
pub async fn promote(
&self,
client_public_key: &[u8; 32],
) -> Result<protocol::PromoteResponse, CertmeshError> {
let ca_guard = self.state.ca.lock().await;
let ca = ca_guard.as_ref().ok_or_else(|| {
if self.state.paths.is_ca_initialized() {
CertmeshError::CaLocked
} else {
CertmeshError::CaNotInitialized
}
})?;
let auth_guard = self.state.auth.lock().await;
let auth_state = auth_guard.as_ref().ok_or(CertmeshError::CaLocked)?;
let roster = self.state.roster.lock().await;
failover::prepare_promotion(ca, auth_state, &roster, client_public_key)
}
}
async fn request_approval(
state: &CertmeshState,
hostname: &str,
profile: TrustProfile,
) -> 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(),
profile,
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 profile.requires_operator() && operator.as_deref().unwrap_or("").is_empty() {
return Err(CertmeshError::ApprovalDenied);
}
Ok(operator)
}
ApprovalDecision::Denied => Err(CertmeshError::ApprovalDenied),
}
}
impl Capability for CertmeshCore {
fn name(&self) -> &str {
"certmesh"
}
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 profile = self
.state
.profile
.try_lock()
.map(|p| *p)
.unwrap_or_default();
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!(
"{} ({} member{})",
profile,
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,
profile: &TrustProfile,
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,
profile: *profile,
enrollment_state: roster.metadata.enrollment_state.clone(),
enrollment_deadline: roster.metadata.enrollment_deadline.map(|d| d.to_rfc3339()),
allowed_domain: roster.metadata.allowed_domain.clone(),
allowed_subnet: roster.metadata.allowed_subnet.clone(),
auth_method: auth_method.map(|s| s.to_string()),
member_count: roster.active_count(),
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 tests {
use super::*;
use crate::roster::{MemberRole, MemberStatus, RosterMember};
use chrono::{Duration, Utc};
fn test_paths() -> CertmeshPaths {
CertmeshPaths::with_data_dir(koi_common::test::ensure_data_dir("koi-certmesh-core-tests"))
}
fn make_test_ca() -> ca::CaState {
ca::create_ca("test-pass", &[42u8; 32], &test_paths())
.unwrap()
.0
}
fn make_test_roster_with_member(hostname: &str, role: MemberRole) -> Roster {
let mut r = Roster::new(TrustProfile::JustMe, None);
r.members.push(RosterMember {
hostname: hostname.to_string(),
role,
enrolled_at: Utc::now(),
enrolled_by: None,
cert_fingerprint: "fp-test".to_string(),
cert_expires: Utc::now() + Duration::days(25),
cert_sans: vec![hostname.to_string(), format!("{hostname}.local")],
cert_path: String::new(),
status: MemberStatus::Active,
reload_hook: None,
last_seen: None,
pinned_ca_fingerprint: Some("pinned-fp".to_string()),
proxy_entries: Vec::new(),
});
r
}
fn make_unlocked_core(ca: ca::CaState, roster: Roster) -> CertmeshCore {
let totp = koi_crypto::totp::generate_secret();
let auth_state = koi_crypto::auth::AuthState::Totp(totp);
CertmeshCore::new_with_paths(
ca,
roster,
Some(auth_state),
TrustProfile::JustMe,
test_paths(),
)
}
fn make_locked_core(roster: Roster) -> CertmeshCore {
CertmeshCore::locked_with_paths(roster, TrustProfile::JustMe, test_paths())
}
#[test]
fn auto_unlock_key_round_trips_through_vault() {
let base = koi_common::test::ensure_data_dir("koi-certmesh-autounlock-tests");
let paths = CertmeshPaths::with_data_dir(base.join("autounlock-roundtrip"));
CertmeshCore::save_auto_unlock_key_at(&paths, "pond-secret-pass").unwrap();
assert!(
!paths.auto_unlock_key_path().exists(),
"save_auto_unlock_key_at must not leave a plaintext key file behind"
);
let recovered = CertmeshCore::read_auto_unlock_key(&paths).unwrap();
assert_eq!(
recovered.as_ref().map(|z| z.as_str()),
Some("pond-secret-pass"),
"the auto-unlock passphrase must round-trip through the vault"
);
let empty = CertmeshPaths::with_data_dir(base.join("autounlock-empty"));
assert!(CertmeshCore::read_auto_unlock_key(&empty)
.unwrap()
.is_none());
}
#[tokio::test]
async fn renew_all_due_returns_empty_when_ca_locked() {
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_locked_core(roster);
let results = core.renew_all_due().await;
assert!(results.is_empty());
}
#[tokio::test]
async fn renew_all_due_returns_empty_when_no_members_due() {
let ca = make_test_ca();
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_unlocked_core(ca, roster);
let results = core.renew_all_due().await;
assert!(results.is_empty());
}
#[tokio::test]
async fn renew_all_due_renews_expiring_members() {
let ca = make_test_ca();
let mut roster = Roster::new(TrustProfile::JustMe, None);
roster.members.push(RosterMember {
hostname: "expiring-host".to_string(),
role: MemberRole::Member,
enrolled_at: Utc::now(),
enrolled_by: None,
cert_fingerprint: "old-fp".to_string(),
cert_expires: Utc::now() + Duration::days(5),
cert_sans: vec!["expiring-host".to_string()],
cert_path: String::new(),
status: MemberStatus::Active,
reload_hook: None,
last_seen: None,
pinned_ca_fingerprint: None,
proxy_entries: Vec::new(),
});
let core = make_unlocked_core(ca, roster);
let results = core.renew_all_due().await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, "expiring-host");
assert!(results[0].1.is_ok());
}
#[tokio::test]
async fn renew_all_due_partial_failure_continues() {
let ca = make_test_ca();
let mut roster = Roster::new(TrustProfile::JustMe, None);
roster.members.push(RosterMember {
hostname: "good-host".to_string(),
role: MemberRole::Member,
enrolled_at: Utc::now(),
enrolled_by: None,
cert_fingerprint: "fp-1".to_string(),
cert_expires: Utc::now() + Duration::days(3),
cert_sans: vec!["good-host".to_string()],
cert_path: String::new(),
status: MemberStatus::Active,
reload_hook: None,
last_seen: None,
pinned_ca_fingerprint: None,
proxy_entries: Vec::new(),
});
roster.members.push(RosterMember {
hostname: "also-good".to_string(),
role: MemberRole::Member,
enrolled_at: Utc::now(),
enrolled_by: None,
cert_fingerprint: "fp-2".to_string(),
cert_expires: Utc::now() + Duration::days(2),
cert_sans: vec!["also-good".to_string()],
cert_path: String::new(),
status: MemberStatus::Active,
reload_hook: None,
last_seen: None,
pinned_ca_fingerprint: None,
proxy_entries: Vec::new(),
});
let core = make_unlocked_core(ca, roster);
let results = core.renew_all_due().await;
assert_eq!(results.len(), 2);
}
#[tokio::test]
async fn health_check_returns_error_when_ca_locked() {
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_locked_core(roster);
let request = protocol::HealthRequest {
hostname: "stone-01".to_string(),
pinned_ca_fingerprint: "some-fp".to_string(),
};
let result = core.health_check(&request).await;
assert!(result.is_err());
}
#[tokio::test]
async fn health_check_validates_matching_fingerprint() {
let ca = make_test_ca();
let ca_fp = ca::ca_fingerprint(&ca);
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_unlocked_core(ca, roster);
let request = protocol::HealthRequest {
hostname: "stone-01".to_string(),
pinned_ca_fingerprint: ca_fp,
};
let result = core.health_check(&request).await.unwrap();
assert!(result.valid);
assert!(!result.ca_fingerprint.is_empty());
}
#[tokio::test]
async fn health_check_rejects_mismatched_fingerprint() {
let ca = make_test_ca();
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_unlocked_core(ca, roster);
let request = protocol::HealthRequest {
hostname: "stone-01".to_string(),
pinned_ca_fingerprint: "wrong-fingerprint".to_string(),
};
let result = core.health_check(&request).await.unwrap();
assert!(!result.valid);
}
#[tokio::test]
async fn health_check_updates_last_seen() {
let ca = make_test_ca();
let ca_fp = ca::ca_fingerprint(&ca);
let mut roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
roster.members[0].last_seen = None;
let core = make_unlocked_core(ca, roster);
let request = protocol::HealthRequest {
hostname: "stone-01".to_string(),
pinned_ca_fingerprint: ca_fp,
};
core.health_check(&request).await.unwrap();
let roster = core.state.roster.lock().await;
assert!(roster.members[0].last_seen.is_some());
}
#[tokio::test]
async fn roster_manifest_returns_error_when_ca_locked() {
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_locked_core(roster);
let result = core.roster_manifest().await;
assert!(result.is_err());
}
#[tokio::test]
async fn roster_manifest_returns_signed_manifest() {
let ca = make_test_ca();
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_unlocked_core(ca, roster);
let manifest = core.roster_manifest().await.unwrap();
assert!(!manifest.roster_json.is_empty());
assert!(!manifest.signature.is_empty());
assert!(!manifest.ca_public_key.is_empty());
}
#[tokio::test]
async fn roster_manifest_is_verifiable() {
let ca = make_test_ca();
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_unlocked_core(ca, roster);
let manifest = core.roster_manifest().await.unwrap();
let verified = failover::verify_manifest(&manifest);
assert!(verified.is_ok());
assert_eq!(verified.unwrap().members.len(), 1);
}
#[tokio::test]
async fn accept_roster_sync_replaces_roster() {
let ca = make_test_ca();
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_unlocked_core(ca, roster);
let ca2 = make_test_ca();
let mut roster2 = make_test_roster_with_member("stone-01", MemberRole::Primary);
roster2.members.push(RosterMember {
hostname: "stone-02".to_string(),
role: MemberRole::Member,
enrolled_at: Utc::now(),
enrolled_by: None,
cert_fingerprint: "fp-2".to_string(),
cert_expires: Utc::now() + Duration::days(25),
cert_sans: vec!["stone-02".to_string()],
cert_path: String::new(),
status: MemberStatus::Active,
reload_hook: None,
last_seen: None,
pinned_ca_fingerprint: None,
proxy_entries: Vec::new(),
});
let manifest = failover::build_signed_manifest(&ca2, &roster2).unwrap();
core.accept_roster_sync(&manifest).await.unwrap();
let roster = core.state.roster.lock().await;
assert_eq!(roster.members.len(), 2);
}
#[tokio::test]
async fn accept_roster_sync_rejects_invalid_manifest() {
let ca = make_test_ca();
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_unlocked_core(ca, roster);
let manifest = protocol::RosterManifest {
roster_json: "{}".to_string(),
signature: vec![0u8; 64],
ca_public_key: "bad-pem".to_string(),
};
let result = core.accept_roster_sync(&manifest).await;
assert!(matches!(result, Err(CertmeshError::InvalidManifest)));
}
#[tokio::test]
async fn promote_returns_error_when_ca_locked() {
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_locked_core(roster);
let dummy_pk = [0u8; 32];
let result = core.promote(&dummy_pk).await;
assert!(matches!(result, Err(CertmeshError::CaLocked)));
}
#[tokio::test]
async fn promote_returns_encrypted_material() {
let ca = make_test_ca();
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_unlocked_core(ca, roster);
let client_kp = koi_crypto::key_agreement::EphemeralKeyPair::generate();
let client_pub = client_kp.public_key_bytes();
let response = core.promote(&client_pub).await.unwrap();
assert!(!response.encrypted_ca_key.ciphertext.is_empty());
assert!(!response.auth_data.is_null());
assert!(!response.roster_json.is_empty());
assert!(response.ca_cert_pem.contains("BEGIN CERTIFICATE"));
assert!(response.ephemeral_public.is_some());
}
#[tokio::test]
async fn promote_response_can_be_accepted_with_dh() {
let ca = make_test_ca();
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_unlocked_core(ca, roster);
let client_kp = koi_crypto::key_agreement::EphemeralKeyPair::generate();
let client_pub = client_kp.public_key_bytes();
let response = core.promote(&client_pub).await.unwrap();
assert!(response.ephemeral_public.is_some());
let (ca_key, accepted_auth, accepted_roster) =
failover::accept_promotion(&response, client_kp).unwrap();
assert!(!ca_key.public_key_pem().unwrap().is_empty());
assert_eq!(accepted_auth.method_name(), "totp");
assert_eq!(accepted_roster.members.len(), 1);
}
#[tokio::test]
async fn receive_renewal_updates_roster_member() {
let ca = make_test_ca();
let mut roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
roster.members[0].cert_fingerprint = "old-fp".to_string();
let core = make_unlocked_core(ca, roster);
let request = protocol::RenewRequest {
hostname: "stone-01".to_string(),
cert_pem: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n".to_string(),
key_pem: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n".to_string(),
ca_pem: "-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----\n".to_string(),
fullchain_pem: "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----\n"
.to_string(),
fingerprint: "new-fp-abc123".to_string(),
expires: "2026-04-01T00:00:00Z".to_string(),
};
let result = core.receive_renewal(&request).await.unwrap();
assert!(result.renewed);
assert_eq!(result.hostname, "stone-01");
let roster = core.state.roster.lock().await;
assert_eq!(roster.members[0].cert_fingerprint, "new-fp-abc123");
}
#[tokio::test]
async fn receive_renewal_handles_invalid_expires_gracefully() {
let ca = make_test_ca();
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_unlocked_core(ca, roster);
let request = protocol::RenewRequest {
hostname: "stone-01".to_string(),
cert_pem: "cert".to_string(),
key_pem: "key".to_string(),
ca_pem: "ca".to_string(),
fullchain_pem: "chain".to_string(),
fingerprint: "new-fp".to_string(),
expires: "not-a-date".to_string(),
};
let result = core.receive_renewal(&request).await.unwrap();
assert!(result.renewed);
}
#[tokio::test]
async fn receive_renewal_skips_roster_update_for_unknown_member() {
let ca = make_test_ca();
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_unlocked_core(ca, roster);
let request = protocol::RenewRequest {
hostname: "unknown-host".to_string(),
cert_pem: "cert".to_string(),
key_pem: "key".to_string(),
ca_pem: "ca".to_string(),
fullchain_pem: "chain".to_string(),
fingerprint: "fp".to_string(),
expires: "2026-04-01T00:00:00Z".to_string(),
};
let result = core.receive_renewal(&request).await.unwrap();
assert!(result.renewed);
assert!(result.hook_result.is_none());
let roster = core.state.roster.lock().await;
assert_eq!(roster.members[0].cert_fingerprint, "fp-test");
}
#[tokio::test]
async fn receive_renewal_executes_hook_if_set() {
let ca = make_test_ca();
let mut roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
#[cfg(unix)]
let cmd = "/bin/echo renewed";
#[cfg(windows)]
let cmd = "C:\\Windows\\System32\\cmd.exe /c echo renewed";
roster.members[0].reload_hook = Some(cmd.to_string());
let core = make_unlocked_core(ca, roster);
let request = protocol::RenewRequest {
hostname: "stone-01".to_string(),
cert_pem: "cert".to_string(),
key_pem: "key".to_string(),
ca_pem: "ca".to_string(),
fullchain_pem: "chain".to_string(),
fingerprint: "new-fp".to_string(),
expires: "2026-04-01T00:00:00Z".to_string(),
};
let result = core.receive_renewal(&request).await.unwrap();
assert!(result.hook_result.is_some());
let hook = result.hook_result.unwrap();
assert!(hook.success);
}
#[test]
fn local_hostname_returns_some() {
let hostname = CertmeshCore::local_hostname();
assert!(hostname.is_some());
assert!(!hostname.unwrap().is_empty());
}
#[test]
fn build_status_locked_ca() {
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let status = build_status(&test_paths(), &None, &roster, &TrustProfile::JustMe, None);
assert!(status.ca_locked);
assert_eq!(status.member_count, 1);
assert_eq!(status.members.len(), 1);
assert_eq!(status.members[0].hostname, "stone-01");
assert_eq!(status.members[0].role, "primary");
}
#[test]
fn build_status_unlocked_ca() {
let ca = make_test_ca();
let roster = Roster::new(TrustProfile::JustMe, None);
let status = build_status(
&test_paths(),
&Some(ca),
&roster,
&TrustProfile::JustMe,
None,
);
assert!(!status.ca_locked);
assert_eq!(status.member_count, 0);
}
#[test]
fn build_status_member_roles_lowercase() {
let mut roster = Roster::new(TrustProfile::JustMe, None);
roster.members.push(RosterMember {
hostname: "standby-01".to_string(),
role: MemberRole::Standby,
enrolled_at: Utc::now(),
enrolled_by: None,
cert_fingerprint: "fp".to_string(),
cert_expires: Utc::now(),
cert_sans: vec![],
cert_path: String::new(),
status: MemberStatus::Active,
reload_hook: None,
last_seen: None,
pinned_ca_fingerprint: None,
proxy_entries: Vec::new(),
});
let status = build_status(&test_paths(), &None, &roster, &TrustProfile::JustMe, None);
assert_eq!(status.members[0].role, "standby");
assert_eq!(status.members[0].status, "active");
}
#[tokio::test]
async fn open_enrollment_changes_state() {
let ca = make_test_ca();
let roster = Roster::new(TrustProfile::MyOrganization, Some("Admin".into()));
let core = make_unlocked_core(ca, roster);
let status = core.certmesh_status().await;
assert_eq!(status.enrollment_state, roster::EnrollmentState::Closed);
core.open_enrollment(None).await.unwrap();
let status = core.certmesh_status().await;
assert_eq!(status.enrollment_state, roster::EnrollmentState::Open);
assert!(status.enrollment_deadline.is_none());
}
#[tokio::test]
async fn open_enrollment_with_deadline() {
let ca = make_test_ca();
let roster = Roster::new(TrustProfile::MyOrganization, Some("Admin".into()));
let core = make_unlocked_core(ca, roster);
let deadline = Utc::now() + Duration::hours(2);
core.open_enrollment(Some(deadline)).await.unwrap();
let status = core.certmesh_status().await;
assert_eq!(status.enrollment_state, roster::EnrollmentState::Open);
assert!(status.enrollment_deadline.is_some());
}
#[tokio::test]
async fn close_enrollment_changes_state() {
let ca = make_test_ca();
let roster = Roster::new(TrustProfile::JustMe, None);
let core = make_unlocked_core(ca, roster);
let status = core.certmesh_status().await;
assert_eq!(status.enrollment_state, roster::EnrollmentState::Open);
core.close_enrollment().await.unwrap();
let status = core.certmesh_status().await;
assert_eq!(status.enrollment_state, roster::EnrollmentState::Closed);
}
#[tokio::test]
async fn set_policy_updates_constraints() {
let ca = make_test_ca();
let roster = Roster::new(TrustProfile::JustMe, None);
let core = make_unlocked_core(ca, roster);
core.set_policy(
Some("lab.local".to_string()),
Some("192.168.1.0/24".to_string()),
)
.await
.unwrap();
let status = core.certmesh_status().await;
assert_eq!(status.allowed_domain.as_deref(), Some("lab.local"));
assert_eq!(status.allowed_subnet.as_deref(), Some("192.168.1.0/24"));
}
#[tokio::test]
async fn set_policy_clears_constraints() {
let ca = make_test_ca();
let mut roster = Roster::new(TrustProfile::JustMe, None);
roster.metadata.allowed_domain = Some("old.local".to_string());
let core = make_unlocked_core(ca, roster);
core.set_policy(None, None).await.unwrap();
let status = core.certmesh_status().await;
assert!(status.allowed_domain.is_none());
assert!(status.allowed_subnet.is_none());
}
#[tokio::test]
async fn set_policy_rejects_invalid_cidr() {
let ca = make_test_ca();
let roster = Roster::new(TrustProfile::JustMe, None);
let core = make_unlocked_core(ca, roster);
let result = core.set_policy(None, Some("not-a-cidr".to_string())).await;
assert!(matches!(result, Err(CertmeshError::ScopeViolation(_))));
}
#[tokio::test]
async fn set_policy_rejects_cidr_with_bad_ip() {
let ca = make_test_ca();
let roster = Roster::new(TrustProfile::JustMe, None);
let core = make_unlocked_core(ca, roster);
let result = core.set_policy(None, Some("xyz.abc/24".to_string())).await;
assert!(matches!(result, Err(CertmeshError::ScopeViolation(_))));
}
#[tokio::test]
async fn rotate_auth_fails_when_ca_locked() {
let roster = Roster::new(TrustProfile::JustMe, None);
let core = make_locked_core(roster);
let result = core.rotate_auth("test-pass", None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn build_status_includes_policy_fields() {
let ca = make_test_ca();
let mut roster = Roster::new(TrustProfile::MyOrganization, Some("Admin".into()));
roster.metadata.allowed_domain = Some("school.local".to_string());
roster.metadata.allowed_subnet = Some("10.0.0.0/8".to_string());
roster.metadata.enrollment_deadline = Some(Utc::now() + Duration::hours(1));
let status = build_status(
&test_paths(),
&Some(ca),
&roster,
&TrustProfile::MyOrganization,
None,
);
assert_eq!(status.allowed_domain.as_deref(), Some("school.local"));
assert_eq!(status.allowed_subnet.as_deref(), Some("10.0.0.0/8"));
assert!(status.enrollment_deadline.is_some());
}
#[tokio::test]
async fn uninitialized_core_status_shows_empty_roster() {
let core = CertmeshCore::uninitialized_with_paths(test_paths());
let status = core.certmesh_status().await;
assert_eq!(status.member_count, 0);
assert!(status.members.is_empty());
assert!(status.ca_locked);
}
#[tokio::test]
async fn uninitialized_core_enroll_returns_error() {
let core = CertmeshCore::uninitialized_with_paths(test_paths());
let request = protocol::JoinRequest {
hostname: "stone-05".to_string(),
auth: koi_crypto::auth::AuthResponse::Totp {
code: "123456".to_string(),
},
sans: vec![],
};
let result = core.enroll(&request).await;
assert!(result.is_err());
}
#[tokio::test]
async fn uninitialized_core_promote_returns_error() {
let core = CertmeshCore::uninitialized_with_paths(test_paths());
let dummy_pk = [0u8; 32];
let result = core.promote(&dummy_pk).await;
assert!(result.is_err());
}
#[tokio::test]
async fn uninitialized_core_roster_manifest_returns_error() {
let core = CertmeshCore::uninitialized_with_paths(test_paths());
let result = core.roster_manifest().await;
assert!(result.is_err());
}
#[tokio::test]
async fn uninitialized_core_renew_all_due_returns_empty() {
let core = CertmeshCore::uninitialized_with_paths(test_paths());
let results = core.renew_all_due().await;
assert!(results.is_empty());
}
#[tokio::test]
async fn uninitialized_core_rotate_auth_returns_error() {
let core = CertmeshCore::uninitialized_with_paths(test_paths());
let result = core.rotate_auth("passphrase", None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn node_role_returns_none_for_empty_roster() {
let ca = make_test_ca();
let roster = Roster::new(TrustProfile::JustMe, None);
let core = make_unlocked_core(ca, roster);
let role = core.node_role().await;
assert!(role.is_none());
}
#[tokio::test]
async fn node_role_returns_role_for_matching_hostname() {
let ca = make_test_ca();
let hostname = CertmeshCore::local_hostname().unwrap();
let roster = make_test_roster_with_member(&hostname, MemberRole::Primary);
let core = make_unlocked_core(ca, roster);
let role = core.node_role().await;
assert_eq!(role, Some(MemberRole::Primary));
}
#[tokio::test]
async fn pinned_ca_fingerprint_returns_none_for_empty_roster() {
let ca = make_test_ca();
let roster = Roster::new(TrustProfile::JustMe, None);
let core = make_unlocked_core(ca, roster);
let fp = core.pinned_ca_fingerprint().await;
assert!(fp.is_none());
}
#[tokio::test]
async fn pinned_ca_fingerprint_returns_value_for_matching_member() {
let ca = make_test_ca();
let hostname = CertmeshCore::local_hostname().unwrap();
let mut roster = make_test_roster_with_member(&hostname, MemberRole::Primary);
roster.members[0].pinned_ca_fingerprint = Some("test-pinned-fp".to_string());
let core = make_unlocked_core(ca, roster);
let fp = core.pinned_ca_fingerprint().await;
assert_eq!(fp.as_deref(), Some("test-pinned-fp"));
}
#[tokio::test]
async fn ca_announcement_returns_none_when_ca_locked() {
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_locked_core(roster);
let ann = core.ca_announcement(5641).await;
assert!(ann.is_none());
}
#[tokio::test]
async fn ca_announcement_returns_none_when_no_primary() {
let ca = make_test_ca();
let roster = make_test_roster_with_member("stone-01", MemberRole::Member);
let core = make_unlocked_core(ca, roster);
let ann = core.ca_announcement(5641).await;
assert!(ann.is_none());
}
#[tokio::test]
async fn ca_announcement_returns_descriptor_for_primary() {
let ca = make_test_ca();
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_unlocked_core(ca, roster);
let ann = core.ca_announcement(5641).await.unwrap();
assert!(ann.name.contains("koi-ca-"));
assert_eq!(ann.port, 5641);
assert_eq!(ann.txt.get("role").unwrap(), "primary");
assert!(ann.txt.contains_key("fingerprint"));
assert!(ann.txt.contains_key("profile"));
}
#[test]
fn capability_status_uninitialised() {
let core = CertmeshCore::uninitialized_with_paths(test_paths());
let status = core.status();
assert_eq!(status.name, "certmesh");
if test_paths().is_ca_initialized() {
assert!(!status.healthy);
assert!(
status.summary.contains("locked"),
"unexpected summary: {}",
status.summary
);
} else {
assert!(status.healthy);
assert!(
status.summary.contains("ready"),
"unexpected summary: {}",
status.summary
);
}
}
#[test]
fn capability_status_locked() {
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_locked_core(roster);
let status = core.status();
assert_eq!(status.name, "certmesh");
assert!(!status.healthy);
}
#[test]
fn capability_status_unlocked() {
let ca = make_test_ca();
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_unlocked_core(ca, roster);
let status = core.status();
assert_eq!(status.name, "certmesh");
assert!(status.healthy);
assert!(
status.summary.contains("1 member"),
"summary: {}",
status.summary
);
}
#[tokio::test]
async fn certmesh_status_returns_profile() {
let ca = make_test_ca();
let roster = Roster::new(TrustProfile::MyOrganization, Some("ops".to_string()));
let totp = koi_crypto::totp::generate_secret();
let auth = koi_crypto::auth::AuthState::Totp(totp);
let core = CertmeshCore::new_with_paths(
ca,
roster,
Some(auth),
TrustProfile::MyOrganization,
test_paths(),
);
let status = core.certmesh_status().await;
assert_eq!(status.profile, TrustProfile::MyOrganization);
}
#[tokio::test]
async fn set_reload_hook_unknown_member_returns_error() {
let ca = make_test_ca();
let roster = Roster::new(TrustProfile::JustMe, None);
let core = make_unlocked_core(ca, roster);
let result = core.set_reload_hook("nonexistent", "echo hi").await;
assert!(result.is_err());
}
#[tokio::test]
async fn set_reload_hook_sets_hook_for_known_member() {
let ca = make_test_ca();
let roster = make_test_roster_with_member("stone-01", MemberRole::Primary);
let core = make_unlocked_core(ca, roster);
core.set_reload_hook("stone-01", "systemctl restart nginx")
.await
.unwrap();
let roster = core.state.roster.lock().await;
assert_eq!(
roster.members[0].reload_hook.as_deref(),
Some("systemctl restart nginx")
);
}
}