From 4c775f139fc1e4f6d31004cb0c4054cc3fd0ad56 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:26:30 +0200 Subject: [PATCH 1/2] docs(aso): clarify list_builds limit=200 is page size, not a cap The `limit=200` on the builds collection reads like a ceiling, but `list_all` follows Apple's `links.next` cursor until the collection is exhausted. Spell out that 200 is the largest page ASC allows (fewer round-trips), not the number of builds returned, so callers don't treat the vector length as "the 200 most recent builds." Co-Authored-By: siGit Code v1.6.7-acp --- crates/aso/src/build.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/aso/src/build.rs b/crates/aso/src/build.rs index 5f392a8..3f78b4e 100644 --- a/crates/aso/src/build.rs +++ b/crates/aso/src/build.rs @@ -97,6 +97,13 @@ pub trait BuildsApi { /// Pass `&BuildFilter::default()` for every build, or set fields to /// narrow by build number, pre-release (marketing) version, or /// processing state. + /// + /// The `limit=200` this sends is a *page size*, not a ceiling on the + /// result: [`Client::list_all`] follows Apple's `links.next` cursor until + /// the collection is exhausted, so an app with more than 200 builds still + /// returns all of them — 200 is just the largest page ASC allows for this + /// collection, which keeps the round-trip count down. Do not read the + /// vector's length as "the 200 most recent builds." async fn list_builds(&self, app_id: &str, filter: &BuildFilter) -> Result>; /// `GET /v1/builds/{id}` — a single build by its App Store Connect id. From 7ffa301989d26808edc72b49c9289ce208e6ef73 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:37:34 +0200 Subject: [PATCH 2/2] 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 --- crates/cli/src/main.rs | 36 +++++ crates/frontend/src/keychain.rs | 273 ++++++++++++++++++++++++++++++++ crates/frontend/src/lib.rs | 4 + 3 files changed, 313 insertions(+) create mode 100644 crates/frontend/src/keychain.rs diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 5c18cf4..74faa60 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -138,6 +138,26 @@ enum Command { #[command(subcommand)] command: SalesReportsCommand, }, + /// Local signing health checks that compare this machine against App + /// Store Connect. macOS only — they read the login keychain. + #[cfg(target_os = "macos")] + Signing { + #[command(subcommand)] + command: SigningCommand, + }, +} + +/// macOS-only signing diagnostics. +#[cfg(target_os = "macos")] +#[derive(Subcommand)] +enum SigningCommand { + /// Cross-check the login keychain against Apple's certificates and + /// report which of Apple's certificates this machine can actually sign + /// with — i.e. which ones have their private key present locally. + /// + /// Read-only: it lists certificates and inspects the keychain, and + /// never writes or transmits key material. + Doctor, } #[derive(Subcommand)] @@ -860,6 +880,22 @@ async fn main() -> Result<()> { run_review_submissions(&client, command, cli.dry_run).await } Command::SalesReports { command } => run_sales_reports(&client, command).await, + #[cfg(target_os = "macos")] + Command::Signing { command } => run_signing(&client, command).await, + } +} + +/// macOS-only signing diagnostics. Kept out of MCP: a tool call doesn't +/// need to inspect the operator's login keychain. +#[cfg(target_os = "macos")] +async fn run_signing(client: &Client, command: SigningCommand) -> Result<()> { + match command { + SigningCommand::Doctor => { + let report = smbcloud_ascapi_frontend::run_doctor(client) + .await + .map_err(|error| anyhow::anyhow!(error))?; + print_json(&report) + } } } diff --git a/crates/frontend/src/keychain.rs b/crates/frontend/src/keychain.rs new file mode 100644 index 0000000..9c49b24 --- /dev/null +++ b/crates/frontend/src/keychain.rs @@ -0,0 +1,273 @@ +//! Cross-checking the local login keychain against App Store Connect. +//! +//! `asc signing doctor` answers one question a `list_certificates` call +//! alone cannot: of the certificates Apple holds, which ones can this +//! machine actually sign with? A certificate is only usable if its private +//! key sits in a local keychain — Apple keeps no copy — so the check reads +//! the serial numbers of the certificates in the login keychain and +//! intersects them with what Apple reports. +//! +//! macOS only. `security` and the login keychain do not exist elsewhere, +//! so the whole module is gated and the CLI exposes the command only on +//! macOS. +//! +//! The implementation shells out the same way the CSR tests do: `security` +//! to export the keychain certificates as PEM, `openssl x509` to read each +//! serial. Serial formatting differs between OpenSSL 3 and macOS +//! LibreSSL, so every serial is normalised (uppercase hex, no separators, +//! no leading zeros) before comparison rather than compared as printed. + +use serde::Serialize; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_signing::prelude::*; +use std::process::Command; + +use crate::certificates::CertificateSummary; +use crate::time::now_iso8601; + +/// One certificate Apple holds, annotated with whether this machine can +/// sign with it. +#[derive(Debug, Clone, Serialize, schemars::JsonSchema)] +pub struct DoctorCertificate { + #[serde(flatten)] + pub certificate: CertificateSummary, + /// True when a certificate with this serial is present in the local + /// login keychain, which is the only place its private key can live. + pub private_key_present: bool, +} + +/// The result of `asc signing doctor`. +/// +/// Carries no key or certificate material: `DoctorCertificate` reuses +/// [`CertificateSummary`], whose shape already forbids it. +#[derive(Debug, Clone, Serialize, schemars::JsonSchema)] +pub struct DoctorReport { + /// Every certificate Apple holds, each flagged with whether its key is + /// present locally. Expired certificates are included — they are still + /// what a shipped build was signed with — and carry `expired: true`. + pub certificates: Vec, + /// Serial numbers found in the local keychain that Apple does not + /// report. Usually a certificate revoked at Apple while its key lingers + /// locally, or a certificate from a different team. + pub local_only_serials: Vec, + /// How many of Apple's certificates are usable here: not expired and + /// backed by a local private key. + pub usable_count: usize, +} + +/// Normalise a serial number for comparison across toolchains. +/// +/// OpenSSL 3 prints `serial=1A2B3C`, LibreSSL may print colon-separated +/// bytes, and Apple returns its own casing. Reducing each to uppercase hex +/// digits with leading zeros stripped makes them comparable regardless of +/// how they were rendered. +fn normalise_serial(serial: &str) -> String { + let hex: String = serial + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect::() + .to_uppercase(); + let trimmed = hex.trim_start_matches('0'); + if trimmed.is_empty() { + "0".to_string() + } else { + trimmed.to_string() + } +} + +/// Read the serial numbers of every certificate in the login keychain. +/// +/// Returns normalised serials. Errors only when `security` itself cannot +/// be run; an empty keychain, or one `openssl` cannot parse an entry in, +/// yields the serials it could read rather than failing the whole command. +fn local_keychain_serials() -> Result, String> { + // `-a -p` dumps every certificate in the keychain as concatenated PEM. + let output = Command::new("security") + .args(["find-certificate", "-a", "-p"]) + .output() + .map_err(|error| format!("running `security find-certificate`: {error}"))?; + + if !output.status.success() { + return Err(format!( + "`security find-certificate` failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let pem = String::from_utf8_lossy(&output.stdout); + let mut serials = Vec::new(); + + for block in split_pem_certificates(&pem) { + if let Some(serial) = openssl_serial(&block) { + let normalised = normalise_serial(&serial); + if !normalised.is_empty() && !serials.contains(&normalised) { + serials.push(normalised); + } + } + } + + Ok(serials) +} + +/// Split a concatenated PEM dump into individual certificate blocks. +fn split_pem_certificates(pem: &str) -> Vec { + const BEGIN: &str = "-----BEGIN CERTIFICATE-----"; + const END: &str = "-----END CERTIFICATE-----"; + + let mut blocks = Vec::new(); + let mut rest = pem; + while let Some(start) = rest.find(BEGIN) { + let after_start = &rest[start..]; + if let Some(end_rel) = after_start.find(END) { + let end = end_rel + END.len(); + blocks.push(after_start[..end].to_string()); + rest = &after_start[end..]; + } else { + break; + } + } + blocks +} + +/// Read one PEM certificate's serial via `openssl x509 -noout -serial`. +fn openssl_serial(pem: &str) -> Option { + use std::io::Write; + use std::process::Stdio; + + let mut child = Command::new("openssl") + .args(["x509", "-noout", "-serial"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + child.stdin.take()?.write_all(pem.as_bytes()).ok()?; + + let output = child.wait_with_output().ok()?; + if !output.status.success() { + return None; + } + + let text = String::from_utf8_lossy(&output.stdout); + // Output looks like `serial=1A2B3C`. + text.split('=').nth(1).map(|s| s.trim().to_string()) +} + +/// Build the doctor report: list Apple's certificates, flag each with +/// whether its private key is present in the local keychain. +pub async fn run_doctor(client: &Client) -> Result { + let certificates = client + .list_certificates(None) + .await + .map_err(|error| error.to_string())?; + + let local_serials = local_keychain_serials()?; + let now = now_iso8601(); + + let mut doctor_certificates = Vec::with_capacity(certificates.len()); + let mut matched_local = std::collections::HashSet::new(); + let mut usable_count = 0; + + for certificate in &certificates { + let summary = CertificateSummary::from_resource(certificate, &now); + let normalised = summary + .serial_number + .as_deref() + .map(normalise_serial) + .filter(|s| !s.is_empty()); + + let private_key_present = match &normalised { + Some(serial) => local_serials.contains(serial), + None => false, + }; + if private_key_present { + if let Some(serial) = &normalised { + matched_local.insert(serial.clone()); + } + if summary.expired != Some(true) { + usable_count += 1; + } + } + + doctor_certificates.push(DoctorCertificate { + certificate: summary, + private_key_present, + }); + } + + let local_only_serials = local_serials + .into_iter() + .filter(|serial| !matched_local.contains(serial)) + .collect(); + + Ok(DoctorReport { + certificates: doctor_certificates, + local_only_serials, + usable_count, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalise_serial_folds_casing_separators_and_leading_zeros() { + assert_eq!(normalise_serial("1a2b3c"), "1A2B3C"); + assert_eq!(normalise_serial("1A:2B:3C"), "1A2B3C"); + assert_eq!(normalise_serial("0x1a2b3c"), "1A2B3C"); + assert_eq!(normalise_serial("001A2B3C"), "1A2B3C"); + } + + #[test] + fn normalise_serial_keeps_a_single_zero() { + assert_eq!(normalise_serial("00"), "0"); + assert_eq!(normalise_serial("0"), "0"); + } + + #[test] + fn split_pem_separates_concatenated_certificates() { + let pem = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n\ + -----BEGIN CERTIFICATE-----\nBBBB\n-----END CERTIFICATE-----\n"; + let blocks = split_pem_certificates(pem); + assert_eq!(blocks.len(), 2); + assert!(blocks[0].contains("AAAA")); + assert!(blocks[1].contains("BBBB")); + } + + #[test] + fn split_pem_ignores_an_unterminated_trailing_block() { + let pem = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n\ + -----BEGIN CERTIFICATE-----\nBBBB\n"; + let blocks = split_pem_certificates(pem); + assert_eq!(blocks.len(), 1); + assert!(blocks[0].contains("AAAA")); + } + + #[test] + fn doctor_report_never_serializes_certificate_material() { + let report = DoctorReport { + certificates: vec![DoctorCertificate { + certificate: CertificateSummary { + id: "ABC123".to_string(), + name: Some("Apple Distribution: Example".to_string()), + certificate_type: Some("DISTRIBUTION".to_string()), + serial_number: Some("1A2B3C".to_string()), + expiration_date: Some("2027-08-06T00:00:00".to_string()), + expired: Some(false), + }, + private_key_present: true, + }], + local_only_serials: vec!["DEADBEEF".to_string()], + usable_count: 1, + }; + + let json = serde_json::to_string(&report).expect("serializes"); + assert!(!json.contains("BEGIN CERTIFICATE")); + assert!(!json.contains("BEGIN PRIVATE KEY")); + // The flattened summary should surface serial and the presence flag. + assert!(json.contains("1A2B3C")); + assert!(json.contains("private_key_present")); + } +} diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs index 9570035..a53312b 100644 --- a/crates/frontend/src/lib.rs +++ b/crates/frontend/src/lib.rs @@ -23,6 +23,8 @@ pub mod certificates; pub mod enums; pub mod env; +#[cfg(target_os = "macos")] +pub mod keychain; pub mod profiles; pub mod review; pub mod time; @@ -33,6 +35,8 @@ pub use certificates::{ }; pub use enums::{bundle_id_platform_from_str, display_type_from_str, platform_from_str}; pub use env::api_key_from_env; +#[cfg(target_os = "macos")] +pub use keychain::{run_doctor, DoctorCertificate, DoctorReport}; pub use profiles::{download_profile, profile_type_from_str, DownloadedProfile, ProfileSummary}; pub use review::{plan_submission, submit_for_review, SubmissionPlan, SubmittedReview}; pub use time::{is_expired, now_iso8601};