Skip to main content

acme_proxy/signer/
custom.rs

1//! The `custom` signer backend: delegates issuance and revocation to an
2//! external script.
3
4use async_trait::async_trait;
5use base64::prelude::*;
6use serde_json::json;
7use tracing::{info, warn};
8
9use super::{IssueOutcome, RenewalWindow, RequestedValidity, SignerBackend, SignerError};
10use crate::config::CustomSignerConfig;
11use crate::script_hook::{ScriptHook, ScriptOutcome, ScriptStdin};
12use crate::sqlite::order::Identifier;
13
14/// Exit code reserved for "the CSR is bad" on the `issue` hook — any other
15/// non-zero exit is treated as an internal signer failure. `1` is
16/// deliberately not used for this: it's the exit code a script under
17/// `set -e` produces for an unrelated bug, and mapping that to a 400 would
18/// hide a real 500 behind a client-facing error.
19const BAD_CSR_EXIT_CODE: i32 = 3;
20
21/// Delegates issuance/revocation to an external script.
22#[derive(Debug)]
23pub struct CustomScriptSigner {
24    hook: ScriptHook,
25    supports_crl: bool,
26    supports_renewal_info: bool,
27}
28
29impl CustomScriptSigner {
30    /// Validates the configuration and creates the signer.
31    pub fn from_config(cfg: &CustomSignerConfig) -> anyhow::Result<Self> {
32        let Some(hook) = ScriptHook::new(&cfg.script_path, &cfg.args, cfg.timeout_ms) else {
33            anyhow::bail!(
34                "signer.backend is \"custom\" but signer.custom.script_path is empty; \
35                 provide a path to an executable script"
36            );
37        };
38
39        info!(
40            event = "signer_custom_loaded",
41            outcome = "success",
42            script_path = %hook.path().display(),
43            timeout_ms = cfg.timeout_ms,
44            supports_crl = cfg.supports_crl,
45            supports_renewal_info = cfg.supports_renewal_info,
46            args = ?cfg.args,
47        );
48
49        Ok(Self {
50            hook,
51            supports_crl: cfg.supports_crl,
52            supports_renewal_info: cfg.supports_renewal_info,
53        })
54    }
55
56    /// Runs the script and returns its raw outcome.
57    ///
58    /// Everything that stopped it answering becomes `SignerError::Internal`; a
59    /// non-zero exit status is deliberately *not* an error here, because each
60    /// hook reads its own exit codes differently — `issue` reserves one for
61    /// "bad CSR", the others do not.
62    async fn run_script(
63        &self,
64        envs: &[(&str, &str)],
65        payload: &serde_json::Value,
66    ) -> Result<ScriptOutcome, SignerError> {
67        self.hook
68            .run(envs, ScriptStdin::Json(payload))
69            .await
70            .map_err(|error| SignerError::Internal(format!("custom signer {error}")))
71    }
72
73    /// A one-line reason for a non-zero exit; see [`ScriptHook::detail`].
74    fn detail_from(outcome: &ScriptOutcome) -> String {
75        ScriptHook::detail(outcome, "custom signer script")
76    }
77}
78
79#[async_trait]
80impl SignerBackend for CustomScriptSigner {
81    async fn issue(
82        &self,
83        order_id: &str,
84        csr_der: &[u8],
85        identifiers: &[Identifier],
86        validity: RequestedValidity,
87    ) -> Result<IssueOutcome, SignerError> {
88        // The script decides its own validity; there is no contract for passing
89        // a requested window to it, and inventing one would break every existing
90        // script. RFC 8555 §7.4 permits ignoring the request.
91        let _ = validity;
92        let identifiers_str = identifiers
93            .iter()
94            .map(|i| i.value.as_str())
95            .collect::<Vec<_>>()
96            .join(",");
97        let envs = [
98            ("ACME_SIGNER_HOOK", "issue"),
99            ("ACME_SIGNER_ORDER_ID", order_id),
100            ("ACME_SIGNER_IDENTIFIERS", identifiers_str.as_str()),
101        ];
102        let payload = json!({
103            "hook": "issue",
104            "order_id": order_id,
105            "identifiers": identifiers,
106            "csr_der_base64": BASE64_STANDARD.encode(csr_der),
107        });
108
109        let outcome = self.run_script(&envs, &payload).await?;
110        let output = &outcome.output;
111        if output.status.success() {
112            // Trim, then put back exactly one trailing newline: a PEM chain
113            // must end with one after its last "-----END CERTIFICATE-----"
114            // (RFC 7468) for a strict parser to recognize that block at all —
115            // certbot's own chain splitter is one such parser, and a bare
116            // `.trim()` here silently produced a chain it rejected as having
117            // "less than 2 certificates", even though the bytes were
118            // otherwise perfectly valid.
119            let chain = String::from_utf8_lossy(&output.stdout)
120                .trim_end()
121                .to_string()
122                + "\n";
123            return Ok(IssueOutcome::Issued(chain));
124        }
125        if output.status.code() == Some(BAD_CSR_EXIT_CODE) {
126            return Err(SignerError::BadCsr);
127        }
128        Err(SignerError::Internal(Self::detail_from(&outcome)))
129    }
130
131    async fn revoke(&self, cert_der: &[u8], reason: Option<u32>) -> Result<(), SignerError> {
132        let reason_str = reason.map(|r| r.to_string()).unwrap_or_default();
133        let envs = [
134            ("ACME_SIGNER_HOOK", "revoke"),
135            ("ACME_SIGNER_REASON", reason_str.as_str()),
136        ];
137        let payload = json!({
138            "hook": "revoke",
139            "cert_der_base64": BASE64_STANDARD.encode(cert_der),
140            "reason": reason,
141        });
142
143        let outcome = self.run_script(&envs, &payload).await?;
144        let output = &outcome.output;
145        if output.status.success() {
146            Ok(())
147        } else {
148            Err(SignerError::Internal(Self::detail_from(&outcome)))
149        }
150    }
151
152    async fn crl_der(&self) -> Option<Vec<u8>> {
153        if !self.supports_crl {
154            return None;
155        }
156        let envs = [("ACME_SIGNER_HOOK", "crl")];
157        let payload = json!({ "hook": "crl" });
158
159        match self.run_script(&envs, &payload).await {
160            Ok(outcome) if outcome.output.status.success() => {
161                if outcome.output.stdout.is_empty() {
162                    None
163                } else {
164                    Some(outcome.output.stdout)
165                }
166            }
167            // Two different failures: the script ran and refused (a non-zero
168            // exit), versus the script could not be run at all (spawn, timeout,
169            // a missing binary). Only the second is the operator's own
170            // configuration.
171            Ok(outcome) => {
172                warn!(
173                    event = "signer_custom_crl_rejected",
174                    outcome = "failure",
175                    detail = %Self::detail_from(&outcome),
176                );
177                None
178            }
179            Err(err) => {
180                warn!(event = "signer_custom_crl_failed", outcome = "failure", detail = %err);
181                None
182            }
183        }
184    }
185
186    /// Stdout contract: empty for "no opinion", `<start> <end>` as epoch
187    /// seconds, or `<start> <end> <explanationURL>` to also supply RFC 9773
188    /// §4.2's optional `explanationURL`. The URL is last and optional so an
189    /// existing two-token script keeps working unchanged.
190    async fn renewal_info(&self, cert_der: &[u8]) -> Result<Option<RenewalWindow>, SignerError> {
191        if !self.supports_renewal_info {
192            return Ok(None);
193        }
194        let envs = [("ACME_SIGNER_HOOK", "renewal_info")];
195        let payload = json!({
196            "hook": "renewal_info",
197            "cert_der_base64": BASE64_STANDARD.encode(cert_der),
198        });
199
200        let outcome = self.run_script(&envs, &payload).await?;
201        let output = &outcome.output;
202        if !output.status.success() {
203            return Err(SignerError::Internal(Self::detail_from(&outcome)));
204        }
205
206        let text = String::from_utf8_lossy(&output.stdout);
207        let tokens = text.split_whitespace().collect::<Vec<_>>();
208        let (start, end, explanation_url) = match tokens.as_slice() {
209            [] => return Ok(None),
210            [start, end] => (*start, *end, None),
211            [start, end, url] => (*start, *end, Some((*url).to_string())),
212            other => {
213                return Err(SignerError::Internal(format!(
214                    "custom signer renewal_info: expected \"<start> <end> [explanationURL]\" or empty stdout, got {} token(s)",
215                    other.len()
216                )));
217            }
218        };
219
220        let start = start.parse::<i64>().map_err(|_| {
221            SignerError::Internal(format!(
222                "custom signer renewal_info: non-integer start `{start}`"
223            ))
224        })?;
225        let end = end.parse::<i64>().map_err(|_| {
226            SignerError::Internal(format!(
227                "custom signer renewal_info: non-integer end `{end}`"
228            ))
229        })?;
230
231        Ok(Some(RenewalWindow {
232            start,
233            end,
234            explanation_url,
235        }))
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::testutil::TempDir;
243    use std::time::Duration;
244
245    /// Writes an executable script and returns the configuration pointing at it.
246    /// The `ETXTBSY` reasoning lives in `crate::testutil::write_script`.
247    fn write_script(dir: &TempDir, name: &str, body: &str) -> CustomSignerConfig {
248        let script_path = crate::testutil::write_script(dir, name, body);
249        CustomSignerConfig {
250            script_path: script_path.to_str().unwrap().to_string(),
251            ..Default::default()
252        }
253    }
254
255    fn identifiers() -> Vec<Identifier> {
256        vec![Identifier::dns("example.com")]
257    }
258
259    #[test]
260    fn missing_script_path_bails() {
261        let cfg = CustomSignerConfig {
262            script_path: "  ".to_string(),
263            ..Default::default()
264        };
265        assert!(CustomScriptSigner::from_config(&cfg).is_err());
266    }
267
268    /// A script path that exists at startup but not when issuance runs — an
269    /// operator repackaging the deployment, say. The spawn failure has to name
270    /// the script and map to `Internal`, not `BadCsr`: nothing is wrong with
271    /// the client's request.
272    #[tokio::test]
273    async fn a_script_that_cannot_be_spawned_is_internal() {
274        let signer = CustomScriptSigner::from_config(&CustomSignerConfig {
275            script_path: "/nonexistent/sign.sh".to_string(),
276            supports_crl: true,
277            supports_renewal_info: true,
278            ..Default::default()
279        })
280        .unwrap();
281
282        match signer
283            .issue(
284                "ord-1",
285                &[0x30, 0x00],
286                &identifiers(),
287                RequestedValidity::default(),
288            )
289            .await
290        {
291            Err(SignerError::Internal(detail)) => {
292                assert!(
293                    detail.contains("failed to spawn script") && detail.contains("/nonexistent"),
294                    "{detail}"
295                )
296            }
297            other => panic!("expected an Internal error, got {other:?}"),
298        }
299
300        // Every other hook goes through the same spawn, so each must report
301        // rather than pretend success — a silent `revoke` would be the worst of
302        // them, leaving a certificate trusted that an operator believes is not.
303        assert!(matches!(
304            signer.revoke(&[0x30, 0x00], None).await,
305            Err(SignerError::Internal(_))
306        ));
307        assert!(signer.crl_der().await.is_none());
308        assert!(matches!(
309            signer.renewal_info(&[0x30, 0x00]).await,
310            Err(SignerError::Internal(_))
311        ));
312    }
313
314    #[tokio::test]
315    async fn issue_success_returns_the_chain() {
316        let dir = TempDir::new("signer-custom");
317        let cfg = write_script(
318            &dir,
319            "issue.sh",
320            "#!/bin/sh\ncat > /dev/null\necho '-----BEGIN CERTIFICATE-----leaf-----END CERTIFICATE-----'\nexit 0\n",
321        );
322        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
323
324        let outcome = signer
325            .issue(
326                "ord-1",
327                &[0x30, 0x00],
328                &identifiers(),
329                RequestedValidity::default(),
330            )
331            .await
332            .unwrap();
333        match outcome {
334            IssueOutcome::Issued(chain) => {
335                assert!(chain.contains("-----BEGIN CERTIFICATE-----leaf"))
336            }
337            IssueOutcome::Processing => panic!("custom signer must always issue synchronously"),
338        }
339    }
340
341    /// RFC 7468 requires a trailing newline after each PEM block's
342    /// "-----END CERTIFICATE-----" line, and a strict parser (certbot's own
343    /// chain-splitting regex among them) silently sees one fewer certificate
344    /// without it. A script's stdout may or may not end with one; either way
345    /// the chain handed to `IssueOutcome::Issued` must end with exactly one.
346    #[tokio::test]
347    async fn issue_chain_always_ends_with_exactly_one_trailing_newline() {
348        let dir = TempDir::new("signer-custom");
349        // `printf` (unlike `echo`) adds no trailing newline of its own, so
350        // this script's raw stdout ends right after the second cert's
351        // "-----END CERTIFICATE-----" with no newline at all.
352        let cfg = write_script(
353            &dir,
354            "issue.sh",
355            "#!/bin/sh\ncat > /dev/null\nprintf -- '-----BEGIN CERTIFICATE-----\\nleaf\\n-----END CERTIFICATE-----\\n-----BEGIN CERTIFICATE-----\\nca\\n-----END CERTIFICATE-----'\nexit 0\n",
356        );
357        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
358
359        let outcome = signer
360            .issue(
361                "ord-1",
362                &[0x30, 0x00],
363                &identifiers(),
364                RequestedValidity::default(),
365            )
366            .await
367            .unwrap();
368        let chain = match outcome {
369            IssueOutcome::Issued(chain) => chain,
370            IssueOutcome::Processing => panic!("custom signer must always issue synchronously"),
371        };
372        assert!(
373            chain.ends_with("-----END CERTIFICATE-----\n") && !chain.ends_with("-----\n\n"),
374            "chain must end with exactly one trailing newline, got {chain:?}"
375        );
376    }
377
378    #[tokio::test]
379    async fn issue_bad_csr_exit_code_maps_to_bad_csr() {
380        let dir = TempDir::new("signer-custom");
381        let cfg = write_script(
382            &dir,
383            "bad_csr.sh",
384            "#!/bin/sh\ncat > /dev/null\necho 'csr does not match order'\nexit 3\n",
385        );
386        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
387
388        let result = signer
389            .issue(
390                "ord-1",
391                &[0x30, 0x00],
392                &identifiers(),
393                RequestedValidity::default(),
394            )
395            .await;
396        assert!(matches!(result, Err(SignerError::BadCsr)));
397    }
398
399    #[tokio::test]
400    async fn issue_other_failure_maps_to_internal_with_detail() {
401        let dir = TempDir::new("signer-custom");
402        let cfg = write_script(
403            &dir,
404            "fail.sh",
405            "#!/bin/sh\ncat > /dev/null\necho 'signing backend unreachable'\nexit 1\n",
406        );
407        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
408
409        let result = signer
410            .issue(
411                "ord-1",
412                &[0x30, 0x00],
413                &identifiers(),
414                RequestedValidity::default(),
415            )
416            .await;
417        match result {
418            Err(SignerError::Internal(detail)) => {
419                assert_eq!(detail, "signing backend unreachable")
420            }
421            other => panic!("expected Internal, got {other:?}"),
422        }
423    }
424
425    #[tokio::test]
426    async fn issue_receives_env_and_stdin() {
427        let dir = TempDir::new("signer-custom");
428        let script = r#"#!/bin/sh
429if [ "$ACME_SIGNER_HOOK" != "issue" ]; then echo "wrong hook: $ACME_SIGNER_HOOK"; exit 1; fi
430if [ "$ACME_SIGNER_ORDER_ID" != "ord-42" ]; then echo "wrong order id"; exit 1; fi
431if [ "$ACME_SIGNER_IDENTIFIERS" != "example.com" ]; then echo "wrong identifiers: $ACME_SIGNER_IDENTIFIERS"; exit 1; fi
432STDIN=$(cat)
433case "$STDIN" in
434  *csr_der_base64*) ;;
435  *) echo "stdin missing csr_der_base64"; exit 1 ;;
436esac
437echo '-----BEGIN CERTIFICATE-----leaf-----END CERTIFICATE-----'
438exit 0
439"#;
440        let cfg = write_script(&dir, "check_env.sh", script);
441        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
442
443        let outcome = signer
444            .issue(
445                "ord-42",
446                &[0x30, 0x00],
447                &identifiers(),
448                RequestedValidity::default(),
449            )
450            .await
451            .unwrap();
452        assert!(matches!(outcome, IssueOutcome::Issued(_)));
453    }
454
455    #[tokio::test]
456    async fn revoke_success() {
457        let dir = TempDir::new("signer-custom");
458        let cfg = write_script(&dir, "revoke.sh", "#!/bin/sh\ncat > /dev/null\nexit 0\n");
459        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
460        assert!(signer.revoke(&[0x30, 0x00], Some(1)).await.is_ok());
461    }
462
463    #[tokio::test]
464    async fn revoke_receives_reason() {
465        let dir = TempDir::new("signer-custom");
466        let script = r#"#!/bin/sh
467cat > /dev/null
468if [ "$ACME_SIGNER_REASON" != "4" ]; then echo "wrong reason: $ACME_SIGNER_REASON"; exit 1; fi
469exit 0
470"#;
471        let cfg = write_script(&dir, "revoke_reason.sh", script);
472        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
473        assert!(signer.revoke(&[0x30, 0x00], Some(4)).await.is_ok());
474    }
475
476    #[tokio::test]
477    async fn revoke_failure_maps_to_internal() {
478        let dir = TempDir::new("signer-custom");
479        let cfg = write_script(
480            &dir,
481            "revoke_fail.sh",
482            "#!/bin/sh\ncat > /dev/null\necho 'already gone'\nexit 1\n",
483        );
484        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
485        match signer.revoke(&[0x30, 0x00], None).await {
486            Err(SignerError::Internal(detail)) => assert_eq!(detail, "already gone"),
487            other => panic!("expected Internal, got {other:?}"),
488        }
489    }
490
491    #[tokio::test]
492    async fn crl_der_disabled_by_default_never_spawns() {
493        let dir = TempDir::new("signer-custom");
494        let marker = dir.path().join("ran");
495        let cfg = write_script(
496            &dir,
497            "crl.sh",
498            &format!("#!/bin/sh\ntouch {}\nexit 0\n", marker.to_str().unwrap()),
499        );
500        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
501        assert!(signer.crl_der().await.is_none());
502        assert!(!marker.exists(), "crl hook must not run when disabled");
503    }
504
505    #[tokio::test]
506    async fn crl_der_enabled_returns_raw_bytes() {
507        let dir = TempDir::new("signer-custom");
508        let mut cfg = write_script(
509            &dir,
510            "crl.sh",
511            "#!/bin/sh\ncat > /dev/null\nprintf 'fake-der-bytes'\nexit 0\n",
512        );
513        cfg.supports_crl = true;
514        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
515        assert_eq!(signer.crl_der().await, Some(b"fake-der-bytes".to_vec()));
516    }
517
518    #[tokio::test]
519    async fn crl_der_empty_stdout_is_none() {
520        let dir = TempDir::new("signer-custom");
521        let mut cfg = write_script(&dir, "crl.sh", "#!/bin/sh\ncat > /dev/null\nexit 0\n");
522        cfg.supports_crl = true;
523        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
524        assert!(signer.crl_der().await.is_none());
525    }
526
527    #[tokio::test]
528    async fn crl_der_script_failure_degrades_to_none() {
529        let dir = TempDir::new("signer-custom");
530        let mut cfg = write_script(&dir, "crl.sh", "#!/bin/sh\ncat > /dev/null\nexit 1\n");
531        cfg.supports_crl = true;
532        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
533        assert!(signer.crl_der().await.is_none());
534    }
535
536    #[tokio::test]
537    async fn renewal_info_disabled_by_default_never_spawns() {
538        let dir = TempDir::new("signer-custom");
539        let marker = dir.path().join("ran");
540        let cfg = write_script(
541            &dir,
542            "ari.sh",
543            &format!("#!/bin/sh\ntouch {}\nexit 0\n", marker.to_str().unwrap()),
544        );
545        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
546        assert_eq!(signer.renewal_info(&[0x30, 0x00]).await.unwrap(), None);
547        assert!(
548            !marker.exists(),
549            "renewal_info hook must not run when disabled"
550        );
551    }
552
553    #[tokio::test]
554    async fn renewal_info_enabled_parses_window() {
555        let dir = TempDir::new("signer-custom");
556        let mut cfg = write_script(
557            &dir,
558            "ari.sh",
559            "#!/bin/sh\ncat > /dev/null\necho '1000 2000'\nexit 0\n",
560        );
561        cfg.supports_renewal_info = true;
562        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
563        assert_eq!(
564            signer.renewal_info(&[0x30, 0x00]).await.unwrap(),
565            Some(RenewalWindow::new(1000, 2000))
566        );
567    }
568
569    /// The optional third token: RFC 9773 §4.2's `explanationURL`. Two tokens
570    /// must keep working unchanged, which the test above covers.
571    #[tokio::test]
572    async fn renewal_info_parses_an_optional_explanation_url() {
573        let dir = TempDir::new("signer-custom");
574        let mut cfg = write_script(
575            &dir,
576            "ari.sh",
577            "#!/bin/sh\ncat > /dev/null\necho '1000 2000 https://ca.example/why'\nexit 0\n",
578        );
579        cfg.supports_renewal_info = true;
580        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
581        assert_eq!(
582            signer.renewal_info(&[0x30, 0x00]).await.unwrap(),
583            Some(RenewalWindow {
584                start: 1000,
585                end: 2000,
586                explanation_url: Some("https://ca.example/why".to_string()),
587            })
588        );
589    }
590
591    /// A fourth token is not a URL with a space in it — it is a script bug, and
592    /// guessing which tokens were meant to be the URL would serve a wrong one.
593    #[tokio::test]
594    async fn renewal_info_rejects_more_than_three_tokens() {
595        let dir = TempDir::new("signer-custom");
596        let mut cfg = write_script(
597            &dir,
598            "ari.sh",
599            "#!/bin/sh\ncat > /dev/null\necho '1000 2000 a b'\nexit 0\n",
600        );
601        cfg.supports_renewal_info = true;
602        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
603        assert!(matches!(
604            signer.renewal_info(&[0x30, 0x00]).await,
605            Err(SignerError::Internal(_))
606        ));
607    }
608
609    #[tokio::test]
610    async fn renewal_info_enabled_blank_stdout_is_no_opinion() {
611        let dir = TempDir::new("signer-custom");
612        let mut cfg = write_script(&dir, "ari.sh", "#!/bin/sh\ncat > /dev/null\nexit 0\n");
613        cfg.supports_renewal_info = true;
614        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
615        assert_eq!(signer.renewal_info(&[0x30, 0x00]).await.unwrap(), None);
616    }
617
618    #[tokio::test]
619    async fn renewal_info_enabled_garbage_stdout_is_internal_error() {
620        let dir = TempDir::new("signer-custom");
621        let mut cfg = write_script(
622            &dir,
623            "ari.sh",
624            "#!/bin/sh\ncat > /dev/null\necho 'nonsense'\nexit 0\n",
625        );
626        cfg.supports_renewal_info = true;
627        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
628        assert!(signer.renewal_info(&[0x30, 0x00]).await.is_err());
629    }
630
631    /// Two tokens is the right *shape*, so the arity check passes and the
632    /// timestamps are parsed. A script emitting a date string instead of a
633    /// Unix timestamp is the likely mistake, and it has to name which end was
634    /// wrong rather than silently producing a window in 1970.
635    #[tokio::test]
636    async fn renewal_info_rejects_a_non_integer_timestamp() {
637        for (script, expected) in [
638            (
639                "echo '2026-01-01T00:00:00Z 1800000000'",
640                "non-integer start",
641            ),
642            ("echo '1700000000 2026-01-01T00:00:00Z'", "non-integer end"),
643        ] {
644            let dir = TempDir::new("signer-custom");
645            let mut cfg = write_script(
646                &dir,
647                "ari.sh",
648                &format!("#!/bin/sh\ncat > /dev/null\n{script}\nexit 0\n"),
649            );
650            cfg.supports_renewal_info = true;
651            let signer = CustomScriptSigner::from_config(&cfg).unwrap();
652
653            match signer.renewal_info(&[0x30, 0x00]).await {
654                Err(SignerError::Internal(detail)) => {
655                    assert!(detail.contains(expected), "expected {expected:?}: {detail}")
656                }
657                other => panic!("expected an Internal error, got {other:?}"),
658            }
659        }
660    }
661
662    /// The server carries secrets in its environment (e.g. the RFC 2136 TSIG
663    /// secret, which lives inside this very `[signer]` config tree), so a
664    /// script provided by the operator must not inherit anything.
665    #[tokio::test]
666    async fn the_script_does_not_inherit_the_server_environment() {
667        assert!(
668            std::env::var_os("CARGO_MANIFEST_DIR").is_some(),
669            "the canary must exist in the parent, otherwise the test proves nothing"
670        );
671
672        let dir = TempDir::new("signer-custom");
673        let cfg = write_script(
674            &dir,
675            "env_leak.sh",
676            r#"#!/bin/sh
677cat > /dev/null
678if [ -n "$CARGO_MANIFEST_DIR" ]; then
679    echo "inherited CARGO_MANIFEST_DIR=$CARGO_MANIFEST_DIR"
680    exit 1
681fi
682if [ -z "$PATH" ]; then
683    echo "no PATH"
684    exit 1
685fi
686if [ "$ACME_SIGNER_HOOK" != "issue" ]; then
687    echo "missing ACME_SIGNER_HOOK"
688    exit 1
689fi
690echo '-----BEGIN CERTIFICATE-----leaf-----END CERTIFICATE-----'
691exit 0
692"#,
693        );
694        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
695        let outcome = signer
696            .issue(
697                "ord-1",
698                &[0x30, 0x00],
699                &identifiers(),
700                RequestedValidity::default(),
701            )
702            .await
703            .unwrap();
704        assert!(matches!(outcome, IssueOutcome::Issued(_)));
705    }
706
707    #[tokio::test]
708    async fn script_timeout_returns_internal() {
709        let dir = TempDir::new("signer-custom");
710        let cfg = CustomSignerConfig {
711            timeout_ms: 100,
712            ..write_script(
713                &dir,
714                "sleep.sh",
715                "#!/bin/sh\ncat > /dev/null\nsleep 2\nexit 0\n",
716            )
717        };
718        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
719
720        match signer
721            .issue(
722                "ord-1",
723                &[0x30, 0x00],
724                &identifiers(),
725                RequestedValidity::default(),
726            )
727            .await
728        {
729            Err(SignerError::Internal(detail)) => assert!(detail.contains("timed out")),
730            other => panic!("expected Internal error on timeout, got {other:?}"),
731        }
732    }
733
734    /// `tokio::time::timeout` only abandons the future: without
735    /// `kill_on_drop`, the child process survives the expiration.
736    #[tokio::test]
737    async fn a_timed_out_script_is_killed_rather_than_left_running() {
738        let dir = TempDir::new("signer-custom");
739        let marker = dir.path().join("survived");
740        let cfg = CustomSignerConfig {
741            timeout_ms: 50,
742            ..write_script(
743                &dir,
744                "slow.sh",
745                &format!(
746                    "#!/bin/sh\ncat > /dev/null\nsleep 1\ntouch {}\n",
747                    marker.to_str().unwrap()
748                ),
749            )
750        };
751        let signer = CustomScriptSigner::from_config(&cfg).unwrap();
752
753        match signer
754            .issue(
755                "ord-1",
756                &[0x30, 0x00],
757                &identifiers(),
758                RequestedValidity::default(),
759            )
760            .await
761        {
762            Err(SignerError::Internal(detail)) => assert!(detail.contains("timed out")),
763            other => panic!("expected Internal error on timeout, got {other:?}"),
764        }
765
766        tokio::time::sleep(Duration::from_millis(1_800)).await;
767        assert!(
768            !marker.exists(),
769            "the script survived the timeout and continued executing"
770        );
771    }
772}