Skip to main content

smbcloud_ascapi_signing/
csr.rs

1//! Certificate Signing Requests, and the key pairs behind them.
2//!
3//! A CSR is the only thing you ever send Apple when asking for a signing
4//! certificate. It carries the *public* half of a freshly generated key
5//! pair plus a subject name, self-signed to prove you hold the matching
6//! private key. Apple checks that signature, then issues a certificate
7//! binding your public key to your team identity.
8//!
9//! The private half never leaves this process except through
10//! [`CertificateRequest::private_key_pem`], and it is the part that actually matters:
11//! a certificate whose private key is lost is dead, permanently, because
12//! Apple never had a copy to give back.
13//!
14//! # Why RSA 2048
15//!
16//! Apple's certificate service accepts RSA 2048 only. An ECDSA CSR, which
17//! is what most modern tooling produces by default, is rejected at
18//! submission. That single constraint dictates the whole implementation
19//! here: [`rcgen`] cannot generate RSA keys, so the key comes from the
20//! [`rsa`] crate and is handed to rcgen as an externally supplied key
21//! pair purely for the CSR's self-signature.
22
23use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair as RcgenKeyPair};
24use rsa::pkcs8::{EncodePrivateKey, LineEnding};
25use rsa::RsaPrivateKey;
26use smbcloud_ascapi_core::{Error, Result};
27use zeroize::Zeroizing;
28
29/// Apple rejects anything else. Not a tunable.
30const RSA_MODULUS_BITS: usize = 2048;
31
32/// A freshly generated RSA key pair and the CSR that asks Apple to certify
33/// it.
34///
35/// Keep these two together. The CSR is worthless without the key, the
36/// certificate Apple returns is worthless without the key, and the key is
37/// unrecoverable if dropped.
38pub struct CertificateRequest {
39    /// PEM-encoded PKCS#10, ready for
40    /// [`CertificateCreateAttributes::csr_content`](crate::certificate::CertificateCreateAttributes::csr_content).
41    csr_pem: String,
42    /// PEM-encoded PKCS#8. Wrapped in [`Zeroizing`] so the bytes are
43    /// wiped when dropped rather than left in freed heap for whatever
44    /// allocates next.
45    private_key_pem: Zeroizing<String>,
46}
47
48impl CertificateRequest {
49    /// The CSR to send Apple, as PEM text including the
50    /// `-----BEGIN CERTIFICATE REQUEST-----` armour.
51    pub fn csr_pem(&self) -> &str {
52        &self.csr_pem
53    }
54
55    /// The private key, PKCS#8 PEM. This is the secret: write it somewhere
56    /// encrypted, and never log it, print it to stdout, or return it from
57    /// an API a language model can read.
58    pub fn private_key_pem(&self) -> &Zeroizing<String> {
59        &self.private_key_pem
60    }
61}
62
63/// Deliberately opaque: printing a key pair, even by accident through a
64/// derived `Debug` on some struct three layers up, is how private keys end
65/// up in CI logs.
66impl std::fmt::Debug for CertificateRequest {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        f.debug_struct("CertificateRequest")
69            .field("csr_pem", &"<csr>")
70            .field("private_key_pem", &"<redacted>")
71            .finish()
72    }
73}
74
75/// Generate an RSA 2048 key pair and a PKCS#10 CSR over it.
76///
77/// `common_name` becomes the CSR subject's CN. Apple overwrites the
78/// subject on the certificate it issues (with `Apple Distribution: Team
79/// Name (TEAMID)` or similar), so this value is not what you will see in
80/// your keychain afterwards. It is still worth setting to something
81/// identifying, because the CSR itself may be archived.
82///
83/// Generation is CPU-bound and takes a noticeable moment: finding two
84/// 1024-bit primes is not free. Call it off the async executor if you are
85/// in one.
86pub fn generate_certificate_request(common_name: &str) -> Result<CertificateRequest> {
87    let mut rng = rand::thread_rng();
88
89    let private_key = RsaPrivateKey::new(&mut rng, RSA_MODULUS_BITS)
90        .map_err(|source| Error::KeyGeneration(source.to_string()))?;
91
92    let private_key_pem = private_key
93        .to_pkcs8_pem(LineEnding::LF)
94        .map_err(|source| Error::KeyGeneration(source.to_string()))?;
95
96    // rcgen re-parses the PEM rather than taking the rsa crate's type
97    // directly: the two crates share no key representation, and PKCS#8 PEM
98    // is the interchange format both agree on.
99    let rcgen_key = RcgenKeyPair::from_pem(&private_key_pem)
100        .map_err(|source| Error::CsrGeneration(source.to_string()))?;
101
102    let mut params = CertificateParams::default();
103    let mut dn = DistinguishedName::new();
104    dn.push(DnType::CommonName, common_name);
105    params.distinguished_name = dn;
106
107    let csr_pem = params
108        .serialize_request(&rcgen_key)
109        .map_err(|source| Error::CsrGeneration(source.to_string()))?
110        .pem()
111        .map_err(|source| Error::CsrGeneration(source.to_string()))?;
112
113    Ok(CertificateRequest {
114        csr_pem,
115        private_key_pem,
116    })
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use std::io::Write;
123    use std::process::{Command, Stdio};
124
125    /// Run `openssl` over a PEM on stdin and return stdout **and** stderr
126    /// joined, or `None` if openssl isn't on this machine.
127    ///
128    /// Both streams, deliberately: macOS ships LibreSSL, which prints the
129    /// `verify OK` banner to stderr while OpenSSL proper puts it on
130    /// stdout. Reading only one stream makes this test pass or fail based
131    /// on which implementation the machine happens to have.
132    fn openssl(args: &[&str], stdin_pem: &str) -> Option<String> {
133        let mut child = Command::new("openssl")
134            .args(args)
135            .stdin(Stdio::piped())
136            .stdout(Stdio::piped())
137            .stderr(Stdio::piped())
138            .spawn()
139            .ok()?;
140        child
141            .stdin
142            .as_mut()
143            .expect("stdin piped")
144            .write_all(stdin_pem.as_bytes())
145            .expect("writes");
146        let out = child.wait_with_output().expect("waits");
147        assert!(
148            out.status.success(),
149            "openssl {args:?} failed: {}",
150            String::from_utf8_lossy(&out.stderr)
151        );
152        Some(format!(
153            "{}{}",
154            String::from_utf8_lossy(&out.stdout),
155            String::from_utf8_lossy(&out.stderr)
156        ))
157    }
158
159    #[test]
160    fn csr_is_rsa_2048_and_self_signature_verifies() {
161        let request = generate_certificate_request("smbCloud signing test").expect("generates");
162
163        // Structural checks that hold with or without openssl present.
164        assert!(request
165            .csr_pem()
166            .starts_with("-----BEGIN CERTIFICATE REQUEST-----"));
167        assert!(request
168            .private_key_pem()
169            .starts_with("-----BEGIN PRIVATE KEY-----"));
170
171        // The parts that actually decide whether Apple accepts this: the
172        // key must be RSA 2048, and the CSR's own signature must verify.
173        // Asserting on our own library's output would be circular, so this
174        // asks a completely independent implementation.
175        let Some(text) = openssl(&["req", "-noout", "-text", "-verify"], request.csr_pem()) else {
176            eprintln!("openssl not available; skipped the cryptographic assertions");
177            return;
178        };
179
180        assert!(
181            text.contains("Public-Key: (2048 bit)"),
182            "expected an RSA 2048 key, got:\n{text}"
183        );
184        assert!(
185            text.contains("rsaEncryption"),
186            "expected an RSA key; Apple rejects ECDSA CSRs. Got:\n{text}"
187        );
188        // openssl prints this only when the CSR's self-signature checks
189        // out, which is the proof we hold the matching private key.
190        assert!(
191            text.contains("Certificate request self-signature verify OK")
192                || text.contains("verify OK"),
193            "CSR self-signature did not verify:\n{text}"
194        );
195        assert!(
196            text.contains("smbCloud signing test"),
197            "common name missing from subject:\n{text}"
198        );
199    }
200
201    #[test]
202    fn each_request_gets_a_fresh_key() {
203        // Reusing a key across certificates means one compromise revokes
204        // everything, and it is an easy mistake to make when adding
205        // caching later.
206        let a = generate_certificate_request("a").expect("generates");
207        let b = generate_certificate_request("b").expect("generates");
208        assert_ne!(
209            a.private_key_pem().as_str(),
210            b.private_key_pem().as_str(),
211            "two requests shared a private key"
212        );
213    }
214
215    #[test]
216    fn debug_never_prints_key_material() {
217        let request = generate_certificate_request("redaction check").expect("generates");
218        let rendered = format!("{request:?}");
219        assert!(rendered.contains("<redacted>"));
220        assert!(
221            !rendered.contains("BEGIN PRIVATE KEY"),
222            "Debug leaked the private key"
223        );
224    }
225}