use std::any::Any;
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use tracing::debug;
use crate::config::SignerConfig;
use crate::sqlite::db::Database;
use crate::sqlite::order::Identifier;
pub mod custom;
pub mod local_ca;
pub mod relay;
pub use relay::http01::TokenStore as Http01TokenStore;
#[derive(Debug)]
pub enum IssueOutcome {
Issued(String),
Processing,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenewalWindow {
pub start: i64,
pub end: i64,
pub explanation_url: Option<String>,
}
impl RenewalWindow {
#[must_use]
pub fn new(start: i64, end: i64) -> Self {
Self {
start,
end,
explanation_url: None,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RequestedValidity {
pub not_before: Option<i64>,
pub not_after: Option<i64>,
}
impl RequestedValidity {
#[must_use]
pub fn is_empty(&self) -> bool {
self.not_before.is_none() && self.not_after.is_none()
}
}
#[derive(Default)]
pub struct CarriedState(HashMap<String, Arc<dyn Any + Send + Sync>>);
impl CarriedState {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn insert<T: Any + Send + Sync>(&mut self, resource: String, value: Arc<T>) {
self.0.insert(resource, value);
}
#[must_use]
pub fn get<T: Any + Send + Sync>(&self, resource: &str) -> Option<Arc<T>> {
self.0.get(resource)?.clone().downcast::<T>().ok()
}
pub fn absorb(&mut self, other: Self) {
self.0.extend(other.0);
}
#[must_use]
pub fn resources(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.0.keys().map(String::as_str).collect();
names.sort_unstable();
names
}
}
#[async_trait]
pub trait SignerBackend: Send + Sync {
async fn issue(
&self,
order_id: &str,
csr_der: &[u8],
identifiers: &[Identifier],
validity: RequestedValidity,
) -> Result<IssueOutcome, SignerError>;
async fn revoke(&self, cert_der: &[u8], reason: Option<u32>) -> Result<(), SignerError>;
async fn crl_der(&self) -> Option<Vec<u8>> {
None
}
async fn ca_chain_pem(&self) -> Option<String> {
None
}
async fn renewal_info(&self, _cert_der: &[u8]) -> Result<Option<RenewalWindow>, SignerError> {
Ok(None)
}
fn jobs(&self) -> Vec<Arc<dyn crate::jobs::JobHandler>> {
Vec::new()
}
fn http01_tokens(&self) -> Option<Arc<dyn Http01TokenStore>> {
None
}
fn crl_pruner(&self) -> Option<Arc<dyn CrlPruner>> {
None
}
fn carried_state(&self) -> CarriedState {
CarriedState::default()
}
}
#[async_trait]
pub trait CrlPruner: Send + Sync {
fn state_key(&self) -> String;
async fn prune_expired(&self) -> Result<usize, SignerError>;
}
#[derive(Debug, thiserror::Error)]
pub enum SignerError {
#[error("Bad CSR")]
BadCsr,
#[error("Internal signer error: {0}")]
Internal(String),
}
#[derive(Clone)]
pub struct SignerParts {
pub database: Arc<Database>,
pub notifiers: crate::notify::Notifiers,
pub metrics: Arc<crate::metrics::Metrics>,
pub egress: Arc<crate::Egress>,
pub jobs: crate::jobs::JobQueue,
}
pub fn from_config(
cfg: &SignerConfig,
profiles: Vec<String>,
parts: &SignerParts,
carried: &CarriedState,
) -> anyhow::Result<Arc<dyn SignerBackend>> {
match cfg.backend.as_str() {
"local_ca" => Ok(Arc::new(local_ca::LocalCa::load_or_generate(
&cfg.local_ca,
carried,
)?)),
"relay" => Ok(Arc::new(relay::RelaySigner::from_config(
&cfg.relay, profiles, parts, carried,
)?)),
"custom" => Ok(Arc::new(custom::CustomScriptSigner::from_config(
&cfg.custom,
)?)),
"acme_proxy" => anyhow::bail!(
"unknown signer backend: acme_proxy — renamed to `relay`. Set \
signer.backend = \"relay\" and rename the [signer.acme_proxy] table to \
[signer.relay] (environment: ACME_PROXY_SIGNER__ACME_PROXY__* becomes \
ACME_PROXY_SIGNER__RELAY__*)"
),
other => anyhow::bail!("unknown signer backend: {other}"),
}
}
#[derive(Default, Clone)]
pub struct SignerSet {
by_profile: HashMap<String, Arc<dyn SignerBackend>>,
by_identity: HashMap<String, Arc<dyn SignerBackend>>,
}
impl SignerSet {
#[must_use]
pub fn get(&self, profile: &str) -> Option<&Arc<dyn SignerBackend>> {
self.by_profile.get(profile)
}
#[must_use]
pub fn len(&self) -> usize {
self.by_identity.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_identity.is_empty()
}
#[must_use]
pub fn carried(&self) -> CarriedState {
let mut carried = CarriedState::new();
for backend in self.by_identity.values() {
carried.absorb(backend.carried_state());
}
carried
}
}
pub fn build_backends(
profiles: &[crate::config::ProfileConfig],
parts: &SignerParts,
previous: &SignerSet,
) -> anyhow::Result<SignerSet> {
let key_of = |cfg: &SignerConfig| format!("{cfg:?}|{}", parts.egress.identity);
let mut owners: HashMap<String, String> = HashMap::new();
for profile in profiles {
let key = key_of(&profile.sections.signer);
for path in signer_paths(&profile.sections.signer) {
match owners.get(&path) {
Some(existing) if *existing != key => anyhow::bail!(
"profile `{}` reuses `{path}` with a different signer configuration: \
two backends over one file would overwrite each other's state \
(give each profile its own paths, or make their [signer] sections identical)",
profile.name
),
_ => {
owners.insert(path, key.clone());
}
}
}
}
let mut served: HashMap<String, Vec<String>> = HashMap::new();
for profile in profiles {
served
.entry(key_of(&profile.sections.signer))
.or_default()
.push(profile.name.clone());
}
let carried = previous.carried();
let mut set = SignerSet::default();
for profile in profiles {
let key = key_of(&profile.sections.signer);
let backend = match (set.by_identity.get(&key), previous.by_identity.get(&key)) {
(Some(backend), _) => backend.clone(),
(None, Some(backend)) => {
debug!(
event = "signer_backend_reused",
outcome = "success",
profile = %profile.name,
"the configuration did not move, so the running backend is carried \
whole rather than rebuilt"
);
let backend = backend.clone();
set.by_identity.insert(key, backend.clone());
backend
}
(None, None) => {
let backend = from_config(
&profile.sections.signer,
served.get(&key).cloned().unwrap_or_default(),
parts,
&carried,
)
.map_err(|error| anyhow::anyhow!("profile `{}`: {error}", profile.name))?;
set.by_identity.insert(key, backend.clone());
backend
}
};
set.by_profile.insert(profile.name.clone(), backend);
}
Ok(set)
}
fn signer_paths(cfg: &SignerConfig) -> Vec<String> {
match cfg.backend.as_str() {
"local_ca" => {
let mut paths = vec![
cfg.local_ca.cert_path.clone(),
cfg.local_ca.key_path.clone(),
cfg.local_ca.crl_path.clone(),
];
if cfg.local_ca.key_source == "pkcs11" {
paths.push(format!(
"pkcs11:{}#{}#{}#{}",
cfg.local_ca.pkcs11.module_path,
cfg.local_ca.pkcs11.token_label,
cfg.local_ca.pkcs11.key_label,
cfg.local_ca.pkcs11.key_id,
));
}
paths
}
"relay" => vec![cfg.relay.account_key_path.clone()],
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_resolver() -> std::sync::Arc<dyn crate::dns::Resolver> {
std::sync::Arc::new(crate::dns::HickoryResolver::from_system_uncached().unwrap())
}
use crate::config::LocalCaConfig;
fn config(backend: &str) -> (SignerConfig, crate::testutil::TempDir) {
let dir = crate::testutil::TempDir::new("signer");
let cfg = SignerConfig {
backend: backend.to_string(),
local_ca: LocalCaConfig {
cert_path: dir.join("ca.pem").to_string_lossy().into_owned(),
key_path: dir.join("ca.key").to_string_lossy().into_owned(),
crl_path: dir.join("ca.crl").to_string_lossy().into_owned(),
..LocalCaConfig::default()
},
..SignerConfig::default()
};
(cfg, dir)
}
async fn database() -> Arc<Database> {
Arc::new(Database::connect_in_memory().await.unwrap())
}
async fn parts() -> SignerParts {
crate::testutil::signer_parts(database().await, test_resolver())
}
async fn parts_with_egress(identity: &str) -> SignerParts {
let mut parts = parts().await;
parts.egress = Arc::new(crate::Egress {
resolver: test_resolver(),
proxies: crate::testutil::no_proxies(),
identity: identity.to_string(),
});
parts
}
fn profile(name: &str, signer: SignerConfig) -> crate::config::ProfileConfig {
crate::config::ProfileConfig {
name: name.to_string(),
sections: crate::config::ProfileSections {
signer,
..crate::config::ProfileSections::default()
},
}
}
#[tokio::test]
async fn a_configuration_that_did_not_move_is_reused_rather_than_rebuilt() {
let (cfg, _dir) = config("local_ca");
let profiles = vec![profile("le", cfg)];
let parts = parts().await;
let first = build_backends(&profiles, &parts, &SignerSet::default()).unwrap();
let second = build_backends(&profiles, &parts, &first).unwrap();
assert!(
Arc::ptr_eq(first.get("le").unwrap(), second.get("le").unwrap()),
"an unchanged `[signer]` must hand back the running instance"
);
}
#[tokio::test]
async fn an_edited_configuration_is_rebuilt_over_the_running_ledger() {
let (cfg, _dir) = config("local_ca");
let parts = parts().await;
let running =
build_backends(&[profile("le", cfg.clone())], &parts, &SignerSet::default()).unwrap();
let outgoing = running.get("le").unwrap().clone();
let key_pair = rcgen::KeyPair::generate().unwrap();
let params = rcgen::CertificateParams::new(vec!["example.com".to_string()]).unwrap();
let csr = params.serialize_request(&key_pair).unwrap();
let chain = match outgoing
.issue(
"ord-1",
csr.der(),
&[Identifier::dns("example.com")],
RequestedValidity::default(),
)
.await
.unwrap()
{
IssueOutcome::Issued(chain) => chain,
IssueOutcome::Processing => panic!("local_ca issues synchronously"),
};
let leaf = crate::cert::leaf_der_from_chain(&chain).unwrap();
outgoing.revoke(&leaf, Some(1)).await.unwrap();
let before = outgoing.crl_der().await.expect("a local CA has a CRL");
let mut edited = cfg;
edited.local_ca.leaf_validity_days = 30;
let reloaded = build_backends(&[profile("le", edited)], &parts, &running).unwrap();
let incoming = reloaded.get("le").unwrap();
assert!(
!Arc::ptr_eq(&outgoing, incoming),
"an edited `[signer]` must really be rebuilt, or the edit did nothing"
);
assert_eq!(
incoming.crl_der().await.expect("a local CA has a CRL"),
before,
"the rebuilt CA must serve the same CRL, ledger and all",
);
let second = params.serialize_request(&key_pair).unwrap();
let chain = match outgoing
.issue(
"ord-2",
second.der(),
&[Identifier::dns("example.com")],
RequestedValidity::default(),
)
.await
.unwrap()
{
IssueOutcome::Issued(chain) => chain,
IssueOutcome::Processing => unreachable!(),
};
let leaf = crate::cert::leaf_der_from_chain(&chain).unwrap();
outgoing.revoke(&leaf, None).await.unwrap();
assert_ne!(
incoming.crl_der().await.unwrap(),
before,
"a revocation landing on the outgoing instance mid-reload must reach \
the incoming one — that is the case reading the sidecar back cannot cover",
);
}
#[tokio::test]
async fn a_changed_egress_rebuilds_a_backend_whose_signer_section_did_not_move() {
let (cfg, _dir) = config("local_ca");
let profiles = vec![profile("le", cfg)];
let first = build_backends(
&profiles,
&parts_with_egress("before").await,
&SignerSet::default(),
)
.unwrap();
let second = build_backends(&profiles, &parts_with_egress("after").await, &first).unwrap();
assert!(
!Arc::ptr_eq(first.get("le").unwrap(), second.get("le").unwrap()),
"a moved `[dns]`/`[proxy]` must reach the signers, which cache it",
);
}
#[tokio::test]
async fn mounting_and_unmounting_a_profile_adds_and_drops_its_backend() {
let (first_cfg, _first_dir) = config("local_ca");
let (second_cfg, _second_dir) = config("local_ca");
let parts = parts().await;
let one = build_backends(
&[profile("le", first_cfg.clone())],
&parts,
&SignerSet::default(),
)
.unwrap();
assert_eq!(one.len(), 1);
let two = build_backends(
&[
profile("le", first_cfg),
profile("staging", second_cfg.clone()),
],
&parts,
&one,
)
.unwrap();
assert_eq!(two.len(), 2, "the new endpoint got a backend of its own");
assert!(
Arc::ptr_eq(one.get("le").unwrap(), two.get("le").unwrap()),
"and the endpoint that was already running kept its instance"
);
let back_to_one = build_backends(&[profile("staging", second_cfg)], &parts, &two).unwrap();
assert_eq!(back_to_one.len(), 1);
assert!(back_to_one.get("le").is_none(), "the endpoint is unmounted");
assert!(
Arc::ptr_eq(
two.get("staging").unwrap(),
back_to_one.get("staging").unwrap()
),
"the survivor is untouched by its neighbour going away"
);
}
#[tokio::test]
async fn a_backend_rebuilt_over_different_files_adopts_nothing() {
let (cfg, _dir) = config("local_ca");
let parts = parts().await;
let running = build_backends(&[profile("le", cfg)], &parts, &SignerSet::default()).unwrap();
let outgoing = running.get("le").unwrap().clone();
let key_pair = rcgen::KeyPair::generate().unwrap();
let params = rcgen::CertificateParams::new(vec!["example.com".to_string()]).unwrap();
let csr = params.serialize_request(&key_pair).unwrap();
let IssueOutcome::Issued(chain) = outgoing
.issue(
"ord-1",
csr.der(),
&[Identifier::dns("example.com")],
RequestedValidity::default(),
)
.await
.unwrap()
else {
panic!("local_ca issues synchronously")
};
outgoing
.revoke(&crate::cert::leaf_der_from_chain(&chain).unwrap(), None)
.await
.unwrap();
let (elsewhere, _other_dir) = config("local_ca");
let reloaded = build_backends(&[profile("le", elsewhere)], &parts, &running).unwrap();
assert_ne!(
reloaded.get("le").unwrap().crl_der().await.unwrap(),
outgoing.crl_der().await.unwrap(),
"a different `crl_path` is a different CA and starts from its own sidecar",
);
}
#[tokio::test]
async fn identical_signer_configuration_yields_one_shared_backend() {
let (cfg, _dir) = config("local_ca");
let profiles = vec![profile("a", cfg.clone()), profile("b", cfg)];
let backends = build_backends(&profiles, &parts().await, &SignerSet::default()).unwrap();
assert_eq!(backends.len(), 1);
assert!(
Arc::ptr_eq(backends.get("a").unwrap(), backends.get("b").unwrap()),
"one configuration must mean one instance"
);
}
#[tokio::test]
async fn differing_signer_configuration_yields_separate_backends() {
let (first, dir_a) = config("local_ca");
let (second, dir_b) = config("local_ca");
let profiles = vec![profile("a", first), profile("b", second)];
let backends = build_backends(&profiles, &parts().await, &SignerSet::default()).unwrap();
assert!(
!Arc::ptr_eq(backends.get("a").unwrap(), backends.get("b").unwrap()),
"different CA material must mean different CAs"
);
std::fs::remove_dir_all(dir_a).ok();
std::fs::remove_dir_all(dir_b).ok();
}
#[tokio::test]
async fn sharing_ca_files_with_a_different_configuration_is_a_startup_error() {
let (first, _dir) = config("local_ca");
let mut second = first.clone();
second.local_ca.leaf_validity_days = 7;
let profiles = vec![profile("a", first), profile("b", second)];
let error = match build_backends(&profiles, &parts().await, &SignerSet::default()) {
Err(error) => error.to_string(),
Ok(_) => panic!("two backends over one key file must not both be built"),
};
assert!(error.contains("different signer configuration"), "{error}");
assert!(
error.contains("ca.key") || error.contains("ca.pem"),
"{error}"
);
}
#[tokio::test]
async fn a_backend_failure_names_the_profile_it_came_from() {
let profiles = vec![profile(
"le",
SignerConfig {
backend: "nope".to_string(),
..SignerConfig::default()
},
)];
let error = match build_backends(&profiles, &parts().await, &SignerSet::default()) {
Err(error) => error.to_string(),
Ok(_) => panic!("an unknown backend is a startup error"),
};
assert!(error.contains("profile `le`"), "{error}");
}
#[tokio::test]
async fn builds_the_local_ca_backend_and_it_can_issue() {
let (cfg, _dir) = config("local_ca");
let signer = from_config(
&cfg,
vec!["default".to_string()],
&parts().await,
&CarriedState::new(),
)
.expect("local_ca is a known backend");
let key_pair = rcgen::KeyPair::generate().unwrap();
let params = rcgen::CertificateParams::new(vec!["example.com".to_string()]).unwrap();
let csr = params.serialize_request(&key_pair).unwrap();
let outcome = signer
.issue(
"ord-1",
csr.der(),
&[Identifier::dns("example.com")],
RequestedValidity::default(),
)
.await
.unwrap();
let chain = match outcome {
IssueOutcome::Issued(chain) => chain,
IssueOutcome::Processing => panic!("local_ca must issue synchronously"),
};
assert_eq!(chain.matches("-----BEGIN CERTIFICATE-----").count(), 2);
}
#[tokio::test]
async fn builds_the_custom_backend_and_it_can_issue() {
let dir = crate::testutil::TempDir::new("signer");
let script_path = dir.join("issue.sh");
std::fs::write(
&script_path,
"#!/bin/sh\ncat > /dev/null\necho '-----BEGIN CERTIFICATE-----leaf-----END CERTIFICATE-----'\nexit 0\n",
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let cfg = SignerConfig {
backend: "custom".to_string(),
custom: crate::config::CustomSignerConfig {
script_path: script_path.to_string_lossy().into_owned(),
..Default::default()
},
..SignerConfig::default()
};
let signer = from_config(
&cfg,
vec!["default".to_string()],
&parts().await,
&CarriedState::new(),
)
.expect("custom is a known backend");
let outcome = signer
.issue(
"ord-1",
&[0x30, 0x00],
&[Identifier::dns("example.com")],
RequestedValidity::default(),
)
.await
.unwrap();
assert!(matches!(outcome, IssueOutcome::Issued(chain) if chain.contains("leaf")));
}
#[tokio::test]
async fn the_local_ca_backend_has_no_renewal_info_opinion() {
let (cfg, _dir) = config("local_ca");
let signer = from_config(
&cfg,
vec!["default".to_string()],
&parts().await,
&CarriedState::new(),
)
.unwrap();
assert!(matches!(signer.renewal_info(&[0x30, 0x00]).await, Ok(None)));
}
#[tokio::test]
async fn an_unknown_backend_is_a_startup_error() {
let (cfg, _dir) = config("hashicorp-vault");
let error = match from_config(
&cfg,
vec!["default".to_string()],
&parts().await,
&CarriedState::new(),
) {
Err(error) => error.to_string(),
Ok(_) => panic!("an unknown backend must not build"),
};
assert!(
error.contains("unknown signer backend") && error.contains("hashicorp-vault"),
"{error}"
);
}
#[tokio::test]
async fn the_old_acme_proxy_backend_name_is_refused_by_its_new_one() {
let (cfg, _dir) = config("acme_proxy");
let error = match from_config(
&cfg,
vec!["default".to_string()],
&parts().await,
&CarriedState::new(),
) {
Err(error) => error.to_string(),
Ok(_) => panic!("the old backend name must not build"),
};
for expected in [
"acme_proxy",
"`relay`",
"[signer.relay]",
"ACME_PROXY_SIGNER__RELAY__",
] {
assert!(error.contains(expected), "{expected} missing from: {error}");
}
}
#[test]
fn signer_errors_render_their_kind() {
assert_eq!(SignerError::BadCsr.to_string(), "Bad CSR");
assert_eq!(
SignerError::Internal("ca offline".to_string()).to_string(),
"Internal signer error: ca offline"
);
}
}