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