main
rs 225 lines 8.63 KB
Raw
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
23 use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair as RcgenKeyPair};
24 use rsa::pkcs8::{EncodePrivateKey, LineEnding};
25 use rsa::RsaPrivateKey;
26 use smbcloud_ascapi_core::{Error, Result};
27 use zeroize::Zeroizing;
28
29 /// Apple rejects anything else. Not a tunable.
30 const 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.
38 pub 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
48 impl 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.
66 impl 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.
86 pub 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)]
120 mod 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 }