use std::sync::Arc;
use chrono::Utc;
use koi_common::integration::AcmeDnsSolver;
use crate::acme::account::AccountStore;
use crate::acme::challenge;
use crate::acme::nonce::NonceStore;
use crate::acme::order::OrderStore;
use crate::error::CertmeshError;
use crate::roster::{MemberRole, MemberStatus, RosterMember};
use crate::CertmeshState;
pub const ACME_CERT_VALIDITY_DAYS: u32 = 30;
pub struct AcmeStateConfig {
pub base_url: String,
pub zone: String,
pub dns: Arc<dyn AcmeDnsSolver>,
}
pub struct AcmeState {
certmesh: Arc<CertmeshState>,
base_url: String,
zone: String,
dns: Arc<dyn AcmeDnsSolver>,
accounts: AccountStore,
nonces: NonceStore,
orders: OrderStore,
}
impl AcmeState {
pub(crate) fn new(certmesh: Arc<CertmeshState>, cfg: AcmeStateConfig) -> Arc<Self> {
let accounts = AccountStore::load(&certmesh.paths.acme_accounts_path());
Arc::new(Self {
certmesh,
base_url: cfg.base_url.trim_end_matches('/').to_string(),
zone: cfg.zone,
dns: cfg.dns,
accounts,
nonces: NonceStore::new(),
orders: OrderStore::new(),
})
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn zone(&self) -> &str {
&self.zone
}
pub fn accounts(&self) -> &AccountStore {
&self.accounts
}
pub fn nonces(&self) -> &NonceStore {
&self.nonces
}
pub fn orders(&self) -> &OrderStore {
&self.orders
}
pub fn dns(&self) -> &Arc<dyn AcmeDnsSolver> {
&self.dns
}
pub fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
pub async fn enrollment_open(&self) -> bool {
self.certmesh.roster.lock().await.metadata.enrollment_open
}
pub fn is_issuable(&self, identifier: &str) -> bool {
challenge::is_in_zone(identifier, &self.zone)
}
pub async fn sign_finalize_csr(
&self,
account_id: &str,
authorized_names: &[String],
csr_der: &[u8],
) -> Result<String, CertmeshError> {
let csr_pem = der_to_csr_pem(csr_der);
let csr_sans = csr_requested_sans(&csr_pem)?;
for san in &csr_sans {
if !authorized_names.iter().any(|n| names_match(n, san)) {
return Err(CertmeshError::InvalidPayload(format!(
"CSR requests unauthorized identifier '{san}' not in the order"
)));
}
}
let ca_guard = self.certmesh.ca.lock().await;
let ca = ca_guard.as_ref().ok_or_else(|| {
if self.certmesh.paths.is_ca_initialized() {
CertmeshError::CaLocked
} else {
CertmeshError::CaNotInitialized
}
})?;
let leaf_pem = crate::sign_csr(ca, &csr_pem, authorized_names, ACME_CERT_VALIDITY_DAYS)?;
let chain_pem = format!("{leaf_pem}{}", ca.cert_pem);
let fingerprint = {
let parsed =
pem::parse(&leaf_pem).map_err(|e| CertmeshError::Certificate(e.to_string()))?;
koi_crypto::pinning::fingerprint_sha256(parsed.contents())
};
let expires = Utc::now() + chrono::Duration::days(i64::from(ACME_CERT_VALIDITY_DAYS));
drop(ca_guard);
self.record_acme_member(account_id, authorized_names, &fingerprint, expires)
.await;
Ok(chain_pem)
}
async fn record_acme_member(
&self,
account_id: &str,
names: &[String],
fingerprint: &str,
expires: chrono::DateTime<Utc>,
) {
let Some(primary) = names.first() else {
return;
};
let committed = self
.certmesh
.commit_roster(|roster| {
if let Some(existing) = roster.find_member_mut(primary) {
existing.cert_fingerprint = fingerprint.to_string();
existing.cert_expires = expires;
existing.cert_sans = names.to_vec();
existing.last_seen = Some(Utc::now());
existing.status = MemberStatus::Active;
} else {
roster.members.push(RosterMember {
hostname: primary.clone(),
role: MemberRole::Client,
enrolled_at: Utc::now(),
enrolled_by: Some(format!("acme:{account_id}")),
cert_fingerprint: fingerprint.to_string(),
cert_expires: expires,
cert_sans: names.to_vec(),
cert_path: String::new(),
status: MemberStatus::Active,
reload_hook: None,
last_seen: Some(Utc::now()),
pinned_ca_fingerprint: None,
proxy_entries: Vec::new(),
});
}
Ok(())
})
.await;
if let Err(e) = committed {
tracing::warn!(error = %e, "Failed to persist roster after ACME issuance");
}
}
pub async fn revoke_by_fingerprint(&self, fingerprint: &str) -> bool {
let fingerprint = fingerprint.to_string();
let outcome = self
.certmesh
.commit_roster(move |roster| {
let hostname = roster
.members
.iter()
.find(|m| m.cert_fingerprint == fingerprint && m.status == MemberStatus::Active)
.map(|m| m.hostname.clone());
let Some(hostname) = hostname else {
return Err(crate::error::CertmeshError::NotFound(fingerprint.clone()));
};
let _ =
roster.revoke_member(&hostname, Some("acme".into()), Some("revokeCert".into()));
Ok(true)
})
.await;
match outcome {
Ok(revoked) => revoked,
Err(crate::error::CertmeshError::NotFound(_)) => false,
Err(e) => {
tracing::warn!(error = %e, "Failed to persist roster after ACME revoke");
false
}
}
}
pub async fn ca_pem(&self) -> Option<String> {
self.certmesh
.ca
.lock()
.await
.as_ref()
.map(|ca| ca.cert_pem.clone())
}
pub async fn ca_ready(&self) -> Result<(), CertmeshError> {
let guard = self.certmesh.ca.lock().await;
if guard.is_some() {
Ok(())
} else if self.certmesh.paths.is_ca_initialized() {
Err(CertmeshError::CaLocked)
} else {
Err(CertmeshError::CaNotInitialized)
}
}
}
fn der_to_csr_pem(csr_der: &[u8]) -> String {
pem::encode(&pem::Pem::new("CERTIFICATE REQUEST", csr_der.to_vec()))
}
fn csr_requested_sans(csr_pem: &str) -> Result<Vec<String>, CertmeshError> {
use x509_parser::prelude::*;
let parsed_pem =
::pem::parse(csr_pem).map_err(|e| CertmeshError::InvalidPayload(e.to_string()))?;
let (_, csr) = X509CertificationRequest::from_der(parsed_pem.contents())
.map_err(|e| CertmeshError::InvalidPayload(format!("CSR parse: {e}")))?;
let mut names = Vec::new();
for cn in csr.certification_request_info.subject.iter_common_name() {
if let Ok(s) = cn.as_str() {
names.push(s.to_lowercase());
}
}
if let Some(exts) = csr.requested_extensions() {
for ext in exts {
if let ParsedExtension::SubjectAlternativeName(san) = ext {
for gn in &san.general_names {
if let GeneralName::DNSName(dns) = gn {
names.push(dns.to_lowercase());
}
}
}
}
}
names.sort();
names.dedup();
Ok(names)
}
fn names_match(authorized: &str, requested: &str) -> bool {
let authorized = authorized.trim_end_matches('.').to_lowercase();
let requested = requested.trim_end_matches('.').to_lowercase();
if authorized == requested {
return true;
}
if let Some(base) = authorized.strip_prefix("*.") {
if let Some(prefix) = requested.strip_suffix(&format!(".{base}")) {
return !prefix.is_empty() && !prefix.contains('.');
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_match_exact() {
assert!(names_match("grafana.lan", "grafana.lan"));
assert!(names_match("grafana.lan", "Grafana.LAN."));
assert!(!names_match("grafana.lan", "evil.lan"));
}
#[test]
fn names_match_wildcard() {
assert!(names_match("*.lan", "host.lan"));
assert!(!names_match("*.lan", "a.b.lan"), "wildcard is single-label");
assert!(!names_match("*.lan", "lan"));
}
#[test]
fn der_to_csr_pem_round_trips() {
let der = b"not a real csr";
let pem_str = der_to_csr_pem(der);
assert!(pem_str.contains("BEGIN CERTIFICATE REQUEST"));
let parsed = pem::parse(&pem_str).unwrap();
assert_eq!(parsed.contents(), der);
}
}