Skip to main content

acme_proxy/signer/local_ca/
key.rs

1//! Where the local CA's issuing private key lives.
2//!
3//! [`CaSigningKey`] is the seam between [`LocalCa`](super::LocalCa) and the key
4//! that signs its leaves and its CRL. It exists because **rcgen already defines
5//! the abstraction we need**: `rcgen::SigningKey` is a public trait, and every
6//! signing entry point this backend uses — `CertificateSigningRequestParams::
7//! signed_by`, `CertificateRevocationListParams::signed_by`,
8//! `CertificateParams::self_signed`, and `Issuer<'_, S>` itself — is generic
9//! over it. So `LocalCa` holds an `Issuer<'static, CaSigningKey>` and nothing
10//! in `issue`/`revoke`/`crl_der` has any idea whether the private key is a few
11//! bytes in this process's heap or a token on the other end of a USB cable.
12//!
13//! An enum rather than `Arc<dyn SigningKey>`: rcgen implements its traits for
14//! `&S` but not for `Arc<dyn _>`, the variant set is closed and small, and the
15//! default (software) path keeps a direct call with no dynamic dispatch.
16//!
17//! The [`Pkcs11`](CaSigningKey::Pkcs11) variant only exists in a build with
18//! `--features hsm`; see [`super::pkcs11`].
19
20use std::path::Path;
21
22use rcgen::{KeyPair, PublicKeyData, SignatureAlgorithm, SigningKey};
23
24use crate::config::LocalCaConfig;
25
26/// The key that signs this CA's leaves and CRLs.
27pub enum CaSigningKey {
28    /// A key pair held in this process's memory, read from `key_path` (or
29    /// generated and written there on first run).
30    Software(KeyPair),
31    /// A key that never leaves a PKCS#11 token. `sign` is a `C_Sign` call.
32    #[cfg(feature = "hsm")]
33    Pkcs11(super::pkcs11::Pkcs11SigningKey),
34}
35
36impl PublicKeyData for CaSigningKey {
37    fn der_bytes(&self) -> &[u8] {
38        match self {
39            Self::Software(key) => key.der_bytes(),
40            #[cfg(feature = "hsm")]
41            Self::Pkcs11(key) => key.der_bytes(),
42        }
43    }
44
45    fn algorithm(&self) -> &'static SignatureAlgorithm {
46        match self {
47            Self::Software(key) => key.algorithm(),
48            #[cfg(feature = "hsm")]
49            Self::Pkcs11(key) => key.algorithm(),
50        }
51    }
52}
53
54impl SigningKey for CaSigningKey {
55    fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, rcgen::Error> {
56        match self {
57            Self::Software(key) => key.sign(msg),
58            #[cfg(feature = "hsm")]
59            Self::Pkcs11(key) => key.sign(msg),
60        }
61    }
62}
63
64impl std::fmt::Debug for CaSigningKey {
65    /// Never renders key material — mirroring rcgen's own `Issuer`/`KeyPair`
66    /// `Debug` impls, which elide it deliberately.
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            Self::Software(_) => f.write_str("CaSigningKey::Software([elided])"),
70            #[cfg(feature = "hsm")]
71            Self::Pkcs11(key) => write!(f, "CaSigningKey::Pkcs11({key:?})"),
72        }
73    }
74}
75
76/// Where the issuing private key comes from — the `signer.local_ca.key_source`
77/// values, parsed once at startup so a typo cannot reach the request path.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum KeySource {
80    /// A PEM file at `key_path`, loaded or generated. The default, and the only
81    /// behaviour that existed before this module.
82    File,
83    /// A PKCS#11 token described by `[signer.local_ca.pkcs11]`.
84    Pkcs11,
85}
86
87impl KeySource {
88    /// Parses `signer.local_ca.key_source`.
89    ///
90    /// An unknown value is a startup error rather than a silent fallback to
91    /// `File`: falling back would mean an operator who meant to protect the CA
92    /// key in hardware gets a software key and no indication of it.
93    pub fn parse(value: &str) -> anyhow::Result<Self> {
94        match value {
95            "file" => Ok(Self::File),
96            "pkcs11" => Ok(Self::Pkcs11),
97            other => anyhow::bail!(
98                "unsupported local_ca key_source: `{other}` (expected \"file\" or \"pkcs11\")"
99            ),
100        }
101    }
102}
103
104/// Reads the PKCS#11 user PIN from `pin_file` if set, else from `pin`.
105///
106/// `pin_file` wins so a deployment can keep the PIN out of both the config file
107/// and the process environment. Neither set is a startup error: this is only
108/// ever called for `key_source = "pkcs11"`, where a login is not optional.
109///
110/// The file's contents are trimmed of trailing whitespace. This is not
111/// cosmetic — `printf 'x' > pin` and `echo x > pin` differ by a newline, and
112/// `C_Login` does not ignore it: the PIN simply comes back **wrong**, which on
113/// a YubiKey PIV applet burns one of the three attempts standing between the
114/// operator and a PUK-blocked slot.
115#[cfg_attr(not(feature = "hsm"), allow(dead_code))]
116pub fn read_pin(cfg: &LocalCaConfig) -> anyhow::Result<String> {
117    let pkcs11 = &cfg.pkcs11;
118
119    if !pkcs11.pin_file.is_empty() {
120        let path = Path::new(&pkcs11.pin_file);
121        // The PIN unlocks the CA's signing key; a world-readable file holding
122        // it is worth the same nag `ca.key` gets.
123        crate::pemfile::warn_if_key_is_readable("local_ca_pkcs11_pin_permissive", path);
124        let raw = std::fs::read_to_string(path).map_err(|error| {
125            anyhow::anyhow!(
126                "local_ca pkcs11 pin_file `{}` could not be read: {error}",
127                pkcs11.pin_file
128            )
129        })?;
130        let pin = raw.trim_end().to_string();
131        if pin.is_empty() {
132            anyhow::bail!("local_ca pkcs11 pin_file `{}` is empty", pkcs11.pin_file);
133        }
134        return Ok(pin);
135    }
136
137    if !pkcs11.pin.is_empty() {
138        return Ok(pkcs11.pin.clone());
139    }
140
141    anyhow::bail!(
142        "local_ca key_source = \"pkcs11\" needs a user PIN: set \
143         signer.local_ca.pkcs11.pin_file, or the \
144         ACME_PROXY_SIGNER__LOCAL_CA__PKCS11__PIN environment variable"
145    )
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::config::Pkcs11Config;
152    use crate::testutil::TempDir;
153    use rcgen::{CertificateParams, Issuer};
154
155    fn config_with(pkcs11: Pkcs11Config) -> LocalCaConfig {
156        LocalCaConfig {
157            pkcs11,
158            ..LocalCaConfig::default()
159        }
160    }
161
162    #[test]
163    fn key_source_parses_the_two_known_values() {
164        assert_eq!(KeySource::parse("file").unwrap(), KeySource::File);
165        assert_eq!(KeySource::parse("pkcs11").unwrap(), KeySource::Pkcs11);
166    }
167
168    /// A typo must stop the server rather than quietly leave the CA key in a
169    /// file when the operator asked for hardware.
170    #[test]
171    fn an_unknown_key_source_is_an_error_naming_it() {
172        let error = KeySource::parse("hsm").unwrap_err().to_string();
173        assert!(error.contains("hsm"), "{error}");
174        assert!(error.contains("pkcs11"), "{error}");
175    }
176
177    /// The regression test for the `Issuer<'static, KeyPair>` →
178    /// `Issuer<'static, CaSigningKey>` swap: wrapping a key pair in the enum
179    /// must produce byte-identical behaviour, not merely a certificate that
180    /// happens to parse.
181    #[test]
182    fn the_software_variant_signs_exactly_as_the_bare_key_pair_does() {
183        let key_pair = KeyPair::generate().unwrap();
184        // The same key, reached through the enum — `KeyPair` is not `Clone`, so
185        // it round-trips through PEM.
186        let wrapped = CaSigningKey::Software(KeyPair::from_pem(&key_pair.serialize_pem()).unwrap());
187
188        // The public halves must agree, or the CA certificate and the issued
189        // leaves would name different keys.
190        assert_eq!(wrapped.der_bytes(), key_pair.der_bytes());
191        assert_eq!(
192            wrapped.subject_public_key_info(),
193            key_pair.subject_public_key_info()
194        );
195        assert_eq!(wrapped.algorithm(), key_pair.algorithm());
196
197        // ECDSA is randomised, so two signatures over one message differ by
198        // construction. What must hold is that both verify — build a real CA
199        // through each and check the issued leaf against it.
200        let mut ca_params = CertificateParams::new(Vec::<String>::new()).unwrap();
201        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Constrained(0));
202        let ca_cert = ca_params.self_signed(&wrapped).unwrap();
203
204        let issuer = Issuer::new(ca_params, wrapped);
205        let leaf_key = KeyPair::generate().unwrap();
206        let csr = CertificateParams::new(vec!["example.com".to_string()])
207            .unwrap()
208            .serialize_request(&leaf_key)
209            .unwrap();
210        let parsed =
211            rcgen::CertificateSigningRequestParams::from_der(&csr.der().to_vec().into()).unwrap();
212        let leaf = parsed.signed_by(&issuer).unwrap();
213
214        // `webpki` is the strictest parser in the tree; if the signature the
215        // enum produced were malformed, this is where it would show.
216        let (_, parsed_leaf) = x509_parser::parse_x509_certificate(leaf.der()).unwrap();
217        let (_, parsed_ca) = x509_parser::parse_x509_certificate(ca_cert.der()).unwrap();
218        assert!(
219            parsed_leaf
220                .verify_signature(Some(parsed_ca.public_key()))
221                .is_ok()
222        );
223    }
224
225    #[test]
226    fn debug_never_renders_key_material() {
227        let key = CaSigningKey::Software(KeyPair::generate().unwrap());
228        let rendered = format!("{key:?}");
229        assert!(rendered.contains("elided"), "{rendered}");
230    }
231
232    #[test]
233    fn a_pin_file_wins_over_the_pin_field() {
234        let dir = TempDir::new("pin");
235        let path = dir.write("hsm.pin", "from-file\n");
236        let cfg = config_with(Pkcs11Config {
237            pin: "from-config".to_string(),
238            pin_file: path.to_string_lossy().into_owned(),
239            ..Pkcs11Config::default()
240        });
241        assert_eq!(read_pin(&cfg).unwrap(), "from-file");
242    }
243
244    /// The newline `echo` leaves behind is not part of the PIN. Getting this
245    /// wrong costs one of three attempts before a PIV slot blocks.
246    #[test]
247    fn a_trailing_newline_is_not_part_of_the_pin() {
248        let dir = TempDir::new("pin");
249        for written in ["1234\n", "1234\r\n", "1234"] {
250            let path = dir.write("hsm.pin", written);
251            let cfg = config_with(Pkcs11Config {
252                pin_file: path.to_string_lossy().into_owned(),
253                ..Pkcs11Config::default()
254            });
255            assert_eq!(read_pin(&cfg).unwrap(), "1234", "for {written:?}");
256        }
257    }
258
259    #[test]
260    fn the_pin_field_is_used_when_no_file_is_configured() {
261        let cfg = config_with(Pkcs11Config {
262            pin: "1234".to_string(),
263            ..Pkcs11Config::default()
264        });
265        assert_eq!(read_pin(&cfg).unwrap(), "1234");
266    }
267
268    #[test]
269    fn no_pin_at_all_is_an_error_naming_both_ways_to_set_one() {
270        let error = read_pin(&config_with(Pkcs11Config::default()))
271            .unwrap_err()
272            .to_string();
273        assert!(error.contains("pin_file"), "{error}");
274        assert!(
275            error.contains("ACME_PROXY_SIGNER__LOCAL_CA__PKCS11__PIN"),
276            "{error}"
277        );
278    }
279
280    #[test]
281    fn a_missing_pin_file_is_an_error_naming_the_path() {
282        let cfg = config_with(Pkcs11Config {
283            pin_file: "/nonexistent/acme-proxy/hsm.pin".to_string(),
284            ..Pkcs11Config::default()
285        });
286        let error = read_pin(&cfg).unwrap_err().to_string();
287        assert!(error.contains("/nonexistent/acme-proxy/hsm.pin"), "{error}");
288    }
289
290    /// An empty file is a configuration mistake, not an empty PIN — catching it
291    /// here turns it into a startup error instead of a `CKR_PIN_INCORRECT` that
292    /// eats a login attempt.
293    #[test]
294    fn an_empty_pin_file_is_an_error() {
295        let dir = TempDir::new("pin");
296        let path = dir.write("hsm.pin", "\n");
297        let cfg = config_with(Pkcs11Config {
298            pin_file: path.to_string_lossy().into_owned(),
299            ..Pkcs11Config::default()
300        });
301        assert!(read_pin(&cfg).is_err());
302    }
303}