feat(signing): add macOS signing doctor

Add a read-only signing health check that cross-references the login keychain against Apple's certificates and reports which ASC certificates this machine can actually sign with (private key present locally). - frontend: new keychain module + run_doctor, DoctorReport/DoctorCertificate - cli: macOS-gated `asc signing doctor` subcommand, JSON output - CLI-only (reads the login keychain); never transmits key material Co-Authored-By: siGit Code v1.6.7-acp <noreply@sigit.si>

Seto Elkahfi committed Oct 11, 2026 at 20:37 UTC 7ffa301989d26808edc72b49c9289ce208e6ef73
3 files changed +313
crates/cli/src/main.rs
+36
@@ -138,6 +138,26 @@ enum Command {
138 #[command(subcommand)]
139 command: SalesReportsCommand,
140 },
141 + /// Local signing health checks that compare this machine against App
142 + /// Store Connect. macOS only — they read the login keychain.
143 + #[cfg(target_os = "macos")]
144 + Signing {
145 + #[command(subcommand)]
146 + command: SigningCommand,
147 + },
148 +}
149 +
150 +/// macOS-only signing diagnostics.
151 +#[cfg(target_os = "macos")]
152 +#[derive(Subcommand)]
153 +enum SigningCommand {
154 + /// Cross-check the login keychain against Apple's certificates and
155 + /// report which of Apple's certificates this machine can actually sign
156 + /// with — i.e. which ones have their private key present locally.
157 + ///
158 + /// Read-only: it lists certificates and inspects the keychain, and
159 + /// never writes or transmits key material.
160 + Doctor,
161 }
162
163 #[derive(Subcommand)]
@@ -860,6 +880,22 @@ async fn main() -> Result<()> {
880 run_review_submissions(&client, command, cli.dry_run).await
881 }
882 Command::SalesReports { command } => run_sales_reports(&client, command).await,
883 + #[cfg(target_os = "macos")]
884 + Command::Signing { command } => run_signing(&client, command).await,
885 + }
886 +}
887 +
888 +/// macOS-only signing diagnostics. Kept out of MCP: a tool call doesn't
889 +/// need to inspect the operator's login keychain.
890 +#[cfg(target_os = "macos")]
891 +async fn run_signing(client: &Client, command: SigningCommand) -> Result<()> {
892 + match command {
893 + SigningCommand::Doctor => {
894 + let report = smbcloud_ascapi_frontend::run_doctor(client)
895 + .await
896 + .map_err(|error| anyhow::anyhow!(error))?;
897 + print_json(&report)
898 + }
899 }
900 }
901
crates/frontend/src/keychain.rs new
+273
@@ -0,0 +1,273 @@
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 +}
crates/frontend/src/lib.rs
+4
@@ -23,6 +23,8 @@
23 pub mod certificates;
24 pub mod enums;
25 pub mod env;
26 +#[cfg(target_os = "macos")]
27 +pub mod keychain;
28 pub mod profiles;
29 pub mod review;
30 pub mod time;
@@ -33,6 +35,8 @@ pub use certificates::{
35 };
36 pub use enums::{bundle_id_platform_from_str, display_type_from_str, platform_from_str};
37 pub use env::api_key_from_env;
38 +#[cfg(target_os = "macos")]
39 +pub use keychain::{run_doctor, DoctorCertificate, DoctorReport};
40 pub use profiles::{download_profile, profile_type_from_str, DownloadedProfile, ProfileSummary};
41 pub use review::{plan_submission, submit_for_review, SubmissionPlan, SubmittedReview};
42 pub use time::{is_expired, now_iso8601};