| 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 | } |