use async_trait::async_trait;
use base64::prelude::*;
use serde_json::json;
use tracing::{info, warn};
use super::{IssueOutcome, RenewalWindow, RequestedValidity, SignerBackend, SignerError};
use crate::config::CustomSignerConfig;
use crate::script_hook::{ScriptHook, ScriptOutcome, ScriptStdin};
use crate::sqlite::order::Identifier;
const BAD_CSR_EXIT_CODE: i32 = 3;
#[derive(Debug)]
pub struct CustomScriptSigner {
hook: ScriptHook,
supports_crl: bool,
supports_renewal_info: bool,
}
impl CustomScriptSigner {
pub fn from_config(cfg: &CustomSignerConfig) -> anyhow::Result<Self> {
let Some(hook) = ScriptHook::new(&cfg.script_path, &cfg.args, cfg.timeout_ms) else {
anyhow::bail!(
"signer.backend is \"custom\" but signer.custom.script_path is empty; \
provide a path to an executable script"
);
};
info!(
event = "signer_custom_loaded",
outcome = "success",
script_path = %hook.path().display(),
timeout_ms = cfg.timeout_ms,
supports_crl = cfg.supports_crl,
supports_renewal_info = cfg.supports_renewal_info,
args = ?cfg.args,
);
Ok(Self {
hook,
supports_crl: cfg.supports_crl,
supports_renewal_info: cfg.supports_renewal_info,
})
}
async fn run_script(
&self,
envs: &[(&str, &str)],
payload: &serde_json::Value,
) -> Result<ScriptOutcome, SignerError> {
self.hook
.run(envs, ScriptStdin::Json(payload))
.await
.map_err(|error| SignerError::Internal(format!("custom signer {error}")))
}
fn detail_from(outcome: &ScriptOutcome) -> String {
ScriptHook::detail(outcome, "custom signer script")
}
}
#[async_trait]
impl SignerBackend for CustomScriptSigner {
async fn issue(
&self,
order_id: &str,
csr_der: &[u8],
identifiers: &[Identifier],
validity: RequestedValidity,
) -> Result<IssueOutcome, SignerError> {
let _ = validity;
let identifiers_str = identifiers
.iter()
.map(|i| i.value.as_str())
.collect::<Vec<_>>()
.join(",");
let envs = [
("ACME_SIGNER_HOOK", "issue"),
("ACME_SIGNER_ORDER_ID", order_id),
("ACME_SIGNER_IDENTIFIERS", identifiers_str.as_str()),
];
let payload = json!({
"hook": "issue",
"order_id": order_id,
"identifiers": identifiers,
"csr_der_base64": BASE64_STANDARD.encode(csr_der),
});
let outcome = self.run_script(&envs, &payload).await?;
let output = &outcome.output;
if output.status.success() {
let chain = String::from_utf8_lossy(&output.stdout)
.trim_end()
.to_string()
+ "\n";
return Ok(IssueOutcome::Issued(chain));
}
if output.status.code() == Some(BAD_CSR_EXIT_CODE) {
return Err(SignerError::BadCsr);
}
Err(SignerError::Internal(Self::detail_from(&outcome)))
}
async fn revoke(&self, cert_der: &[u8], reason: Option<u32>) -> Result<(), SignerError> {
let reason_str = reason.map(|r| r.to_string()).unwrap_or_default();
let envs = [
("ACME_SIGNER_HOOK", "revoke"),
("ACME_SIGNER_REASON", reason_str.as_str()),
];
let payload = json!({
"hook": "revoke",
"cert_der_base64": BASE64_STANDARD.encode(cert_der),
"reason": reason,
});
let outcome = self.run_script(&envs, &payload).await?;
let output = &outcome.output;
if output.status.success() {
Ok(())
} else {
Err(SignerError::Internal(Self::detail_from(&outcome)))
}
}
async fn crl_der(&self) -> Option<Vec<u8>> {
if !self.supports_crl {
return None;
}
let envs = [("ACME_SIGNER_HOOK", "crl")];
let payload = json!({ "hook": "crl" });
match self.run_script(&envs, &payload).await {
Ok(outcome) if outcome.output.status.success() => {
if outcome.output.stdout.is_empty() {
None
} else {
Some(outcome.output.stdout)
}
}
Ok(outcome) => {
warn!(
event = "signer_custom_crl_rejected",
outcome = "failure",
detail = %Self::detail_from(&outcome),
);
None
}
Err(err) => {
warn!(event = "signer_custom_crl_failed", outcome = "failure", detail = %err);
None
}
}
}
async fn renewal_info(&self, cert_der: &[u8]) -> Result<Option<RenewalWindow>, SignerError> {
if !self.supports_renewal_info {
return Ok(None);
}
let envs = [("ACME_SIGNER_HOOK", "renewal_info")];
let payload = json!({
"hook": "renewal_info",
"cert_der_base64": BASE64_STANDARD.encode(cert_der),
});
let outcome = self.run_script(&envs, &payload).await?;
let output = &outcome.output;
if !output.status.success() {
return Err(SignerError::Internal(Self::detail_from(&outcome)));
}
let text = String::from_utf8_lossy(&output.stdout);
let tokens = text.split_whitespace().collect::<Vec<_>>();
let (start, end, explanation_url) = match tokens.as_slice() {
[] => return Ok(None),
[start, end] => (*start, *end, None),
[start, end, url] => (*start, *end, Some((*url).to_string())),
other => {
return Err(SignerError::Internal(format!(
"custom signer renewal_info: expected \"<start> <end> [explanationURL]\" or empty stdout, got {} token(s)",
other.len()
)));
}
};
let start = start.parse::<i64>().map_err(|_| {
SignerError::Internal(format!(
"custom signer renewal_info: non-integer start `{start}`"
))
})?;
let end = end.parse::<i64>().map_err(|_| {
SignerError::Internal(format!(
"custom signer renewal_info: non-integer end `{end}`"
))
})?;
Ok(Some(RenewalWindow {
start,
end,
explanation_url,
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::TempDir;
use std::time::Duration;
fn write_script(dir: &TempDir, name: &str, body: &str) -> CustomSignerConfig {
let script_path = crate::testutil::write_script(dir, name, body);
CustomSignerConfig {
script_path: script_path.to_str().unwrap().to_string(),
..Default::default()
}
}
fn identifiers() -> Vec<Identifier> {
vec![Identifier::dns("example.com")]
}
#[test]
fn missing_script_path_bails() {
let cfg = CustomSignerConfig {
script_path: " ".to_string(),
..Default::default()
};
assert!(CustomScriptSigner::from_config(&cfg).is_err());
}
#[tokio::test]
async fn a_script_that_cannot_be_spawned_is_internal() {
let signer = CustomScriptSigner::from_config(&CustomSignerConfig {
script_path: "/nonexistent/sign.sh".to_string(),
supports_crl: true,
supports_renewal_info: true,
..Default::default()
})
.unwrap();
match signer
.issue(
"ord-1",
&[0x30, 0x00],
&identifiers(),
RequestedValidity::default(),
)
.await
{
Err(SignerError::Internal(detail)) => {
assert!(
detail.contains("failed to spawn script") && detail.contains("/nonexistent"),
"{detail}"
)
}
other => panic!("expected an Internal error, got {other:?}"),
}
assert!(matches!(
signer.revoke(&[0x30, 0x00], None).await,
Err(SignerError::Internal(_))
));
assert!(signer.crl_der().await.is_none());
assert!(matches!(
signer.renewal_info(&[0x30, 0x00]).await,
Err(SignerError::Internal(_))
));
}
#[tokio::test]
async fn issue_success_returns_the_chain() {
let dir = TempDir::new("signer-custom");
let cfg = write_script(
&dir,
"issue.sh",
"#!/bin/sh\ncat > /dev/null\necho '-----BEGIN CERTIFICATE-----leaf-----END CERTIFICATE-----'\nexit 0\n",
);
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
let outcome = signer
.issue(
"ord-1",
&[0x30, 0x00],
&identifiers(),
RequestedValidity::default(),
)
.await
.unwrap();
match outcome {
IssueOutcome::Issued(chain) => {
assert!(chain.contains("-----BEGIN CERTIFICATE-----leaf"))
}
IssueOutcome::Processing => panic!("custom signer must always issue synchronously"),
}
}
#[tokio::test]
async fn issue_chain_always_ends_with_exactly_one_trailing_newline() {
let dir = TempDir::new("signer-custom");
let cfg = write_script(
&dir,
"issue.sh",
"#!/bin/sh\ncat > /dev/null\nprintf -- '-----BEGIN CERTIFICATE-----\\nleaf\\n-----END CERTIFICATE-----\\n-----BEGIN CERTIFICATE-----\\nca\\n-----END CERTIFICATE-----'\nexit 0\n",
);
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
let outcome = signer
.issue(
"ord-1",
&[0x30, 0x00],
&identifiers(),
RequestedValidity::default(),
)
.await
.unwrap();
let chain = match outcome {
IssueOutcome::Issued(chain) => chain,
IssueOutcome::Processing => panic!("custom signer must always issue synchronously"),
};
assert!(
chain.ends_with("-----END CERTIFICATE-----\n") && !chain.ends_with("-----\n\n"),
"chain must end with exactly one trailing newline, got {chain:?}"
);
}
#[tokio::test]
async fn issue_bad_csr_exit_code_maps_to_bad_csr() {
let dir = TempDir::new("signer-custom");
let cfg = write_script(
&dir,
"bad_csr.sh",
"#!/bin/sh\ncat > /dev/null\necho 'csr does not match order'\nexit 3\n",
);
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
let result = signer
.issue(
"ord-1",
&[0x30, 0x00],
&identifiers(),
RequestedValidity::default(),
)
.await;
assert!(matches!(result, Err(SignerError::BadCsr)));
}
#[tokio::test]
async fn issue_other_failure_maps_to_internal_with_detail() {
let dir = TempDir::new("signer-custom");
let cfg = write_script(
&dir,
"fail.sh",
"#!/bin/sh\ncat > /dev/null\necho 'signing backend unreachable'\nexit 1\n",
);
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
let result = signer
.issue(
"ord-1",
&[0x30, 0x00],
&identifiers(),
RequestedValidity::default(),
)
.await;
match result {
Err(SignerError::Internal(detail)) => {
assert_eq!(detail, "signing backend unreachable")
}
other => panic!("expected Internal, got {other:?}"),
}
}
#[tokio::test]
async fn issue_receives_env_and_stdin() {
let dir = TempDir::new("signer-custom");
let script = r#"#!/bin/sh
if [ "$ACME_SIGNER_HOOK" != "issue" ]; then echo "wrong hook: $ACME_SIGNER_HOOK"; exit 1; fi
if [ "$ACME_SIGNER_ORDER_ID" != "ord-42" ]; then echo "wrong order id"; exit 1; fi
if [ "$ACME_SIGNER_IDENTIFIERS" != "example.com" ]; then echo "wrong identifiers: $ACME_SIGNER_IDENTIFIERS"; exit 1; fi
STDIN=$(cat)
case "$STDIN" in
*csr_der_base64*) ;;
*) echo "stdin missing csr_der_base64"; exit 1 ;;
esac
echo '-----BEGIN CERTIFICATE-----leaf-----END CERTIFICATE-----'
exit 0
"#;
let cfg = write_script(&dir, "check_env.sh", script);
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
let outcome = signer
.issue(
"ord-42",
&[0x30, 0x00],
&identifiers(),
RequestedValidity::default(),
)
.await
.unwrap();
assert!(matches!(outcome, IssueOutcome::Issued(_)));
}
#[tokio::test]
async fn revoke_success() {
let dir = TempDir::new("signer-custom");
let cfg = write_script(&dir, "revoke.sh", "#!/bin/sh\ncat > /dev/null\nexit 0\n");
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
assert!(signer.revoke(&[0x30, 0x00], Some(1)).await.is_ok());
}
#[tokio::test]
async fn revoke_receives_reason() {
let dir = TempDir::new("signer-custom");
let script = r#"#!/bin/sh
cat > /dev/null
if [ "$ACME_SIGNER_REASON" != "4" ]; then echo "wrong reason: $ACME_SIGNER_REASON"; exit 1; fi
exit 0
"#;
let cfg = write_script(&dir, "revoke_reason.sh", script);
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
assert!(signer.revoke(&[0x30, 0x00], Some(4)).await.is_ok());
}
#[tokio::test]
async fn revoke_failure_maps_to_internal() {
let dir = TempDir::new("signer-custom");
let cfg = write_script(
&dir,
"revoke_fail.sh",
"#!/bin/sh\ncat > /dev/null\necho 'already gone'\nexit 1\n",
);
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
match signer.revoke(&[0x30, 0x00], None).await {
Err(SignerError::Internal(detail)) => assert_eq!(detail, "already gone"),
other => panic!("expected Internal, got {other:?}"),
}
}
#[tokio::test]
async fn crl_der_disabled_by_default_never_spawns() {
let dir = TempDir::new("signer-custom");
let marker = dir.path().join("ran");
let cfg = write_script(
&dir,
"crl.sh",
&format!("#!/bin/sh\ntouch {}\nexit 0\n", marker.to_str().unwrap()),
);
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
assert!(signer.crl_der().await.is_none());
assert!(!marker.exists(), "crl hook must not run when disabled");
}
#[tokio::test]
async fn crl_der_enabled_returns_raw_bytes() {
let dir = TempDir::new("signer-custom");
let mut cfg = write_script(
&dir,
"crl.sh",
"#!/bin/sh\ncat > /dev/null\nprintf 'fake-der-bytes'\nexit 0\n",
);
cfg.supports_crl = true;
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
assert_eq!(signer.crl_der().await, Some(b"fake-der-bytes".to_vec()));
}
#[tokio::test]
async fn crl_der_empty_stdout_is_none() {
let dir = TempDir::new("signer-custom");
let mut cfg = write_script(&dir, "crl.sh", "#!/bin/sh\ncat > /dev/null\nexit 0\n");
cfg.supports_crl = true;
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
assert!(signer.crl_der().await.is_none());
}
#[tokio::test]
async fn crl_der_script_failure_degrades_to_none() {
let dir = TempDir::new("signer-custom");
let mut cfg = write_script(&dir, "crl.sh", "#!/bin/sh\ncat > /dev/null\nexit 1\n");
cfg.supports_crl = true;
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
assert!(signer.crl_der().await.is_none());
}
#[tokio::test]
async fn renewal_info_disabled_by_default_never_spawns() {
let dir = TempDir::new("signer-custom");
let marker = dir.path().join("ran");
let cfg = write_script(
&dir,
"ari.sh",
&format!("#!/bin/sh\ntouch {}\nexit 0\n", marker.to_str().unwrap()),
);
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
assert_eq!(signer.renewal_info(&[0x30, 0x00]).await.unwrap(), None);
assert!(
!marker.exists(),
"renewal_info hook must not run when disabled"
);
}
#[tokio::test]
async fn renewal_info_enabled_parses_window() {
let dir = TempDir::new("signer-custom");
let mut cfg = write_script(
&dir,
"ari.sh",
"#!/bin/sh\ncat > /dev/null\necho '1000 2000'\nexit 0\n",
);
cfg.supports_renewal_info = true;
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
assert_eq!(
signer.renewal_info(&[0x30, 0x00]).await.unwrap(),
Some(RenewalWindow::new(1000, 2000))
);
}
#[tokio::test]
async fn renewal_info_parses_an_optional_explanation_url() {
let dir = TempDir::new("signer-custom");
let mut cfg = write_script(
&dir,
"ari.sh",
"#!/bin/sh\ncat > /dev/null\necho '1000 2000 https://ca.example/why'\nexit 0\n",
);
cfg.supports_renewal_info = true;
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
assert_eq!(
signer.renewal_info(&[0x30, 0x00]).await.unwrap(),
Some(RenewalWindow {
start: 1000,
end: 2000,
explanation_url: Some("https://ca.example/why".to_string()),
})
);
}
#[tokio::test]
async fn renewal_info_rejects_more_than_three_tokens() {
let dir = TempDir::new("signer-custom");
let mut cfg = write_script(
&dir,
"ari.sh",
"#!/bin/sh\ncat > /dev/null\necho '1000 2000 a b'\nexit 0\n",
);
cfg.supports_renewal_info = true;
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
assert!(matches!(
signer.renewal_info(&[0x30, 0x00]).await,
Err(SignerError::Internal(_))
));
}
#[tokio::test]
async fn renewal_info_enabled_blank_stdout_is_no_opinion() {
let dir = TempDir::new("signer-custom");
let mut cfg = write_script(&dir, "ari.sh", "#!/bin/sh\ncat > /dev/null\nexit 0\n");
cfg.supports_renewal_info = true;
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
assert_eq!(signer.renewal_info(&[0x30, 0x00]).await.unwrap(), None);
}
#[tokio::test]
async fn renewal_info_enabled_garbage_stdout_is_internal_error() {
let dir = TempDir::new("signer-custom");
let mut cfg = write_script(
&dir,
"ari.sh",
"#!/bin/sh\ncat > /dev/null\necho 'nonsense'\nexit 0\n",
);
cfg.supports_renewal_info = true;
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
assert!(signer.renewal_info(&[0x30, 0x00]).await.is_err());
}
#[tokio::test]
async fn renewal_info_rejects_a_non_integer_timestamp() {
for (script, expected) in [
(
"echo '2026-01-01T00:00:00Z 1800000000'",
"non-integer start",
),
("echo '1700000000 2026-01-01T00:00:00Z'", "non-integer end"),
] {
let dir = TempDir::new("signer-custom");
let mut cfg = write_script(
&dir,
"ari.sh",
&format!("#!/bin/sh\ncat > /dev/null\n{script}\nexit 0\n"),
);
cfg.supports_renewal_info = true;
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
match signer.renewal_info(&[0x30, 0x00]).await {
Err(SignerError::Internal(detail)) => {
assert!(detail.contains(expected), "expected {expected:?}: {detail}")
}
other => panic!("expected an Internal error, got {other:?}"),
}
}
}
#[tokio::test]
async fn the_script_does_not_inherit_the_server_environment() {
assert!(
std::env::var_os("CARGO_MANIFEST_DIR").is_some(),
"the canary must exist in the parent, otherwise the test proves nothing"
);
let dir = TempDir::new("signer-custom");
let cfg = write_script(
&dir,
"env_leak.sh",
r#"#!/bin/sh
cat > /dev/null
if [ -n "$CARGO_MANIFEST_DIR" ]; then
echo "inherited CARGO_MANIFEST_DIR=$CARGO_MANIFEST_DIR"
exit 1
fi
if [ -z "$PATH" ]; then
echo "no PATH"
exit 1
fi
if [ "$ACME_SIGNER_HOOK" != "issue" ]; then
echo "missing ACME_SIGNER_HOOK"
exit 1
fi
echo '-----BEGIN CERTIFICATE-----leaf-----END CERTIFICATE-----'
exit 0
"#,
);
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
let outcome = signer
.issue(
"ord-1",
&[0x30, 0x00],
&identifiers(),
RequestedValidity::default(),
)
.await
.unwrap();
assert!(matches!(outcome, IssueOutcome::Issued(_)));
}
#[tokio::test]
async fn script_timeout_returns_internal() {
let dir = TempDir::new("signer-custom");
let cfg = CustomSignerConfig {
timeout_ms: 100,
..write_script(
&dir,
"sleep.sh",
"#!/bin/sh\ncat > /dev/null\nsleep 2\nexit 0\n",
)
};
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
match signer
.issue(
"ord-1",
&[0x30, 0x00],
&identifiers(),
RequestedValidity::default(),
)
.await
{
Err(SignerError::Internal(detail)) => assert!(detail.contains("timed out")),
other => panic!("expected Internal error on timeout, got {other:?}"),
}
}
#[tokio::test]
async fn a_timed_out_script_is_killed_rather_than_left_running() {
let dir = TempDir::new("signer-custom");
let marker = dir.path().join("survived");
let cfg = CustomSignerConfig {
timeout_ms: 50,
..write_script(
&dir,
"slow.sh",
&format!(
"#!/bin/sh\ncat > /dev/null\nsleep 1\ntouch {}\n",
marker.to_str().unwrap()
),
)
};
let signer = CustomScriptSigner::from_config(&cfg).unwrap();
match signer
.issue(
"ord-1",
&[0x30, 0x00],
&identifiers(),
RequestedValidity::default(),
)
.await
{
Err(SignerError::Internal(detail)) => assert!(detail.contains("timed out")),
other => panic!("expected Internal error on timeout, got {other:?}"),
}
tokio::time::sleep(Duration::from_millis(1_800)).await;
assert!(
!marker.exists(),
"the script survived the timeout and continued executing"
);
}
}