main
rs 273 lines 9.71 KB
Raw
1 //! Cross-checking the local login keychain against App Store Connect.
2 //!
3 //! `asc signing doctor` answers one question a `list_certificates` call
4 //! alone cannot: of the certificates Apple holds, which ones can this
5 //! machine actually sign with? A certificate is only usable if its private
6 //! key sits in a local keychain — Apple keeps no copy — so the check reads
7 //! the serial numbers of the certificates in the login keychain and
8 //! intersects them with what Apple reports.
9 //!
10 //! macOS only. `security` and the login keychain do not exist elsewhere,
11 //! so the whole module is gated and the CLI exposes the command only on
12 //! macOS.
13 //!
14 //! The implementation shells out the same way the CSR tests do: `security`
15 //! to export the keychain certificates as PEM, `openssl x509` to read each
16 //! serial. Serial formatting differs between OpenSSL 3 and macOS
17 //! LibreSSL, so every serial is normalised (uppercase hex, no separators,
18 //! no leading zeros) before comparison rather than compared as printed.
19
20 use serde::Serialize;
21 use smbcloud_ascapi_core::Client;
22 use smbcloud_ascapi_signing::prelude::*;
23 use std::process::Command;
24
25 use crate::certificates::CertificateSummary;
26 use crate::time::now_iso8601;
27
28 /// One certificate Apple holds, annotated with whether this machine can
29 /// sign with it.
30 #[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
31 pub struct DoctorCertificate {
32 #[serde(flatten)]
33 pub certificate: CertificateSummary,
34 /// True when a certificate with this serial is present in the local
35 /// login keychain, which is the only place its private key can live.
36 pub private_key_present: bool,
37 }
38
39 /// The result of `asc signing doctor`.
40 ///
41 /// Carries no key or certificate material: `DoctorCertificate` reuses
42 /// [`CertificateSummary`], whose shape already forbids it.
43 #[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
44 pub struct DoctorReport {
45 /// Every certificate Apple holds, each flagged with whether its key is
46 /// present locally. Expired certificates are included — they are still
47 /// what a shipped build was signed with — and carry `expired: true`.
48 pub certificates: Vec<DoctorCertificate>,
49 /// Serial numbers found in the local keychain that Apple does not
50 /// report. Usually a certificate revoked at Apple while its key lingers
51 /// locally, or a certificate from a different team.
52 pub local_only_serials: Vec<String>,
53 /// How many of Apple's certificates are usable here: not expired and
54 /// backed by a local private key.
55 pub usable_count: usize,
56 }
57
58 /// Normalise a serial number for comparison across toolchains.
59 ///
60 /// OpenSSL 3 prints `serial=1A2B3C`, LibreSSL may print colon-separated
61 /// bytes, and Apple returns its own casing. Reducing each to uppercase hex
62 /// digits with leading zeros stripped makes them comparable regardless of
63 /// how they were rendered.
64 fn normalise_serial(serial: &str) -> String {
65 let hex: String = serial
66 .chars()
67 .filter(|c| c.is_ascii_hexdigit())
68 .collect::<String>()
69 .to_uppercase();
70 let trimmed = hex.trim_start_matches('0');
71 if trimmed.is_empty() {
72 "0".to_string()
73 } else {
74 trimmed.to_string()
75 }
76 }
77
78 /// Read the serial numbers of every certificate in the login keychain.
79 ///
80 /// Returns normalised serials. Errors only when `security` itself cannot
81 /// be run; an empty keychain, or one `openssl` cannot parse an entry in,
82 /// yields the serials it could read rather than failing the whole command.
83 fn local_keychain_serials() -> Result<Vec<String>, String> {
84 // `-a -p` dumps every certificate in the keychain as concatenated PEM.
85 let output = Command::new("security")
86 .args(["find-certificate", "-a", "-p"])
87 .output()
88 .map_err(|error| format!("running `security find-certificate`: {error}"))?;
89
90 if !output.status.success() {
91 return Err(format!(
92 "`security find-certificate` failed: {}",
93 String::from_utf8_lossy(&output.stderr).trim()
94 ));
95 }
96
97 let pem = String::from_utf8_lossy(&output.stdout);
98 let mut serials = Vec::new();
99
100 for block in split_pem_certificates(&pem) {
101 if let Some(serial) = openssl_serial(&block) {
102 let normalised = normalise_serial(&serial);
103 if !normalised.is_empty() && !serials.contains(&normalised) {
104 serials.push(normalised);
105 }
106 }
107 }
108
109 Ok(serials)
110 }
111
112 /// Split a concatenated PEM dump into individual certificate blocks.
113 fn split_pem_certificates(pem: &str) -> Vec<String> {
114 const BEGIN: &str = "-----BEGIN CERTIFICATE-----";
115 const END: &str = "-----END CERTIFICATE-----";
116
117 let mut blocks = Vec::new();
118 let mut rest = pem;
119 while let Some(start) = rest.find(BEGIN) {
120 let after_start = &rest[start..];
121 if let Some(end_rel) = after_start.find(END) {
122 let end = end_rel + END.len();
123 blocks.push(after_start[..end].to_string());
124 rest = &after_start[end..];
125 } else {
126 break;
127 }
128 }
129 blocks
130 }
131
132 /// Read one PEM certificate's serial via `openssl x509 -noout -serial`.
133 fn openssl_serial(pem: &str) -> Option<String> {
134 use std::io::Write;
135 use std::process::Stdio;
136
137 let mut child = Command::new("openssl")
138 .args(["x509", "-noout", "-serial"])
139 .stdin(Stdio::piped())
140 .stdout(Stdio::piped())
141 .stderr(Stdio::null())
142 .spawn()
143 .ok()?;
144
145 child.stdin.take()?.write_all(pem.as_bytes()).ok()?;
146
147 let output = child.wait_with_output().ok()?;
148 if !output.status.success() {
149 return None;
150 }
151
152 let text = String::from_utf8_lossy(&output.stdout);
153 // Output looks like `serial=1A2B3C`.
154 text.split('=').nth(1).map(|s| s.trim().to_string())
155 }
156
157 /// Build the doctor report: list Apple's certificates, flag each with
158 /// whether its private key is present in the local keychain.
159 pub async fn run_doctor(client: &Client) -> Result<DoctorReport, String> {
160 let certificates = client
161 .list_certificates(None)
162 .await
163 .map_err(|error| error.to_string())?;
164
165 let local_serials = local_keychain_serials()?;
166 let now = now_iso8601();
167
168 let mut doctor_certificates = Vec::with_capacity(certificates.len());
169 let mut matched_local = std::collections::HashSet::new();
170 let mut usable_count = 0;
171
172 for certificate in &certificates {
173 let summary = CertificateSummary::from_resource(certificate, &now);
174 let normalised = summary
175 .serial_number
176 .as_deref()
177 .map(normalise_serial)
178 .filter(|s| !s.is_empty());
179
180 let private_key_present = match &normalised {
181 Some(serial) => local_serials.contains(serial),
182 None => false,
183 };
184 if private_key_present {
185 if let Some(serial) = &normalised {
186 matched_local.insert(serial.clone());
187 }
188 if summary.expired != Some(true) {
189 usable_count += 1;
190 }
191 }
192
193 doctor_certificates.push(DoctorCertificate {
194 certificate: summary,
195 private_key_present,
196 });
197 }
198
199 let local_only_serials = local_serials
200 .into_iter()
201 .filter(|serial| !matched_local.contains(serial))
202 .collect();
203
204 Ok(DoctorReport {
205 certificates: doctor_certificates,
206 local_only_serials,
207 usable_count,
208 })
209 }
210
211 #[cfg(test)]
212 mod tests {
213 use super::*;
214
215 #[test]
216 fn normalise_serial_folds_casing_separators_and_leading_zeros() {
217 assert_eq!(normalise_serial("1a2b3c"), "1A2B3C");
218 assert_eq!(normalise_serial("1A:2B:3C"), "1A2B3C");
219 assert_eq!(normalise_serial("0x1a2b3c"), "1A2B3C");
220 assert_eq!(normalise_serial("001A2B3C"), "1A2B3C");
221 }
222
223 #[test]
224 fn normalise_serial_keeps_a_single_zero() {
225 assert_eq!(normalise_serial("00"), "0");
226 assert_eq!(normalise_serial("0"), "0");
227 }
228
229 #[test]
230 fn split_pem_separates_concatenated_certificates() {
231 let pem = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n\
232 -----BEGIN CERTIFICATE-----\nBBBB\n-----END CERTIFICATE-----\n";
233 let blocks = split_pem_certificates(pem);
234 assert_eq!(blocks.len(), 2);
235 assert!(blocks[0].contains("AAAA"));
236 assert!(blocks[1].contains("BBBB"));
237 }
238
239 #[test]
240 fn split_pem_ignores_an_unterminated_trailing_block() {
241 let pem = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n\
242 -----BEGIN CERTIFICATE-----\nBBBB\n";
243 let blocks = split_pem_certificates(pem);
244 assert_eq!(blocks.len(), 1);
245 assert!(blocks[0].contains("AAAA"));
246 }
247
248 #[test]
249 fn doctor_report_never_serializes_certificate_material() {
250 let report = DoctorReport {
251 certificates: vec![DoctorCertificate {
252 certificate: CertificateSummary {
253 id: "ABC123".to_string(),
254 name: Some("Apple Distribution: Example".to_string()),
255 certificate_type: Some("DISTRIBUTION".to_string()),
256 serial_number: Some("1A2B3C".to_string()),
257 expiration_date: Some("2027-08-06T00:00:00".to_string()),
258 expired: Some(false),
259 },
260 private_key_present: true,
261 }],
262 local_only_serials: vec!["DEADBEEF".to_string()],
263 usable_count: 1,
264 };
265
266 let json = serde_json::to_string(&report).expect("serializes");
267 assert!(!json.contains("BEGIN CERTIFICATE"));
268 assert!(!json.contains("BEGIN PRIVATE KEY"));
269 // The flattened summary should surface serial and the presence flag.
270 assert!(json.contains("1A2B3C"));
271 assert!(json.contains("private_key_present"));
272 }
273 }