Skip to main content

smbcloud_ascapi_signing/
certificate.rs

1//! Signing certificates: `/v1/certificates`.
2//!
3//! The one thing to understand before using this module: **Apple never
4//! gives you a private key.** You generate an RSA key pair locally, send
5//! Apple only the public half inside a Certificate Signing Request, and
6//! Apple returns a certificate that vouches for it. The private key never
7//! leaves the machine that made it.
8//!
9//! Two consequences follow, and both are the reason a tool like this
10//! exists:
11//!
12//! - Losing the private key makes its certificate permanently useless.
13//!   Re-downloading from the developer portal cannot recover it, because
14//!   Apple never had it.
15//! - An expired certificate cannot be renewed. There is no such endpoint.
16//!   You create a new one, which means a new key pair and a new CSR, and
17//!   every provisioning profile embedding the old certificate has to be
18//!   regenerated.
19//!
20//! See [`crate::csr`] for producing the `csr_content` that
21//! [`CertificatesApi::create_certificate`] wants.
22
23use async_trait::async_trait;
24use reqwest::Method;
25use serde::{Deserialize, Serialize};
26use smbcloud_ascapi_core::jsonapi::{CreateBody, CreateData, Document, Resource};
27use smbcloud_ascapi_core::Client;
28use smbcloud_ascapi_core::Result;
29
30pub const RESOURCE_TYPE: &str = "certificates";
31
32/// The kinds of certificate App Store Connect can issue.
33///
34/// `Development` and `Distribution` are the modern, platform-agnostic
35/// pair: one Apple Development certificate covers iOS, macOS, tvOS, and
36/// visionOS development, and one Apple Distribution certificate covers
37/// distribution for all of them. The `Ios*` and `Mac*` variants are the
38/// older per-platform types, still issued and still required in two
39/// cases worth knowing:
40///
41/// - [`Self::MacInstallerDistribution`] signs the installer package for a
42///   Mac App Store submission (`productbuild --sign`). Nothing else can,
43///   so a Mac App Store release needs it alongside a distribution
44///   certificate for the app bundle itself.
45/// - [`Self::DeveloperIdApplication`] and [`Self::DeveloperIdKext`] are for
46///   distribution outside the App Store, and are capped per team in a way
47///   the others are not.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49pub enum CertificateType {
50    /// Apple Development: one certificate for development across every
51    /// Apple platform.
52    #[serde(rename = "DEVELOPMENT")]
53    Development,
54    /// Apple Distribution: one certificate for App Store and Ad Hoc
55    /// distribution across every Apple platform.
56    #[serde(rename = "DISTRIBUTION")]
57    Distribution,
58    #[serde(rename = "IOS_DEVELOPMENT")]
59    IosDevelopment,
60    #[serde(rename = "IOS_DISTRIBUTION")]
61    IosDistribution,
62    #[serde(rename = "MAC_APP_DEVELOPMENT")]
63    MacAppDevelopment,
64    /// "3rd Party Mac Developer Application": signs the `.app` bundle of a
65    /// Mac App Store submission.
66    #[serde(rename = "MAC_APP_DISTRIBUTION")]
67    MacAppDistribution,
68    /// "3rd Party Mac Developer Installer": signs the `.pkg` a Mac App
69    /// Store submission is delivered in. Required by `productbuild
70    /// --sign`; no other certificate type substitutes for it.
71    #[serde(rename = "MAC_INSTALLER_DISTRIBUTION")]
72    MacInstallerDistribution,
73    #[serde(rename = "DEVELOPER_ID_APPLICATION")]
74    DeveloperIdApplication,
75    #[serde(rename = "DEVELOPER_ID_KEXT")]
76    DeveloperIdKext,
77}
78
79impl CertificateType {
80    /// The string App Store Connect uses, for building `filter[certificateType]`
81    /// query values without going through serde.
82    pub fn as_api_str(self) -> &'static str {
83        match self {
84            Self::Development => "DEVELOPMENT",
85            Self::Distribution => "DISTRIBUTION",
86            Self::IosDevelopment => "IOS_DEVELOPMENT",
87            Self::IosDistribution => "IOS_DISTRIBUTION",
88            Self::MacAppDevelopment => "MAC_APP_DEVELOPMENT",
89            Self::MacAppDistribution => "MAC_APP_DISTRIBUTION",
90            Self::MacInstallerDistribution => "MAC_INSTALLER_DISTRIBUTION",
91            Self::DeveloperIdApplication => "DEVELOPER_ID_APPLICATION",
92            Self::DeveloperIdKext => "DEVELOPER_ID_KEXT",
93        }
94    }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(rename_all = "camelCase")]
99pub struct CertificateAttributes {
100    /// The certificate's common name, e.g.
101    /// `Apple Distribution: PT Sigit Mitra Bangun (2TQF86ZACD)`. This is what
102    /// `codesign --sign` matches when given a name rather than a hash.
103    pub name: Option<String>,
104    pub certificate_type: Option<CertificateType>,
105    pub display_name: Option<String>,
106    pub serial_number: Option<String>,
107    pub platform: Option<String>,
108    /// ISO-8601. Apple reports this directly, so callers never need to
109    /// parse the DER to find out when a certificate dies.
110    pub expiration_date: Option<String>,
111    /// Base64-encoded DER of the certificate itself (the public half).
112    /// Absent from some list responses; always present on create.
113    pub certificate_content: Option<String>,
114}
115
116pub type Certificate = Resource<CertificateAttributes>;
117
118#[derive(Debug, Clone, Serialize)]
119#[serde(rename_all = "camelCase")]
120pub struct CertificateCreateAttributes {
121    /// PEM-encoded PKCS#10 Certificate Signing Request. Apple requires the
122    /// key to be RSA 2048; see [`crate::csr`].
123    pub csr_content: String,
124    pub certificate_type: CertificateType,
125}
126
127/// Signing certificates.
128///
129/// An extension trait rather than inherent methods, because `Client`
130/// lives in the core crate and Rust only allows inherent impls in the
131/// crate that defines the type. Import it, or the crate's `prelude`,
132/// to call these on a `Client`.
133#[async_trait]
134pub trait CertificatesApi {
135    /// `GET /v1/certificates`, optionally filtered by type.
136    ///
137    /// Apple returns expired certificates too, and gives no filter for
138    /// "valid" — read [`CertificateAttributes::expiration_date`] and decide
139    /// for yourself. That is deliberate on their side: an expired
140    /// certificate is still meaningful for identifying what a previously
141    /// shipped build was signed with.
142    async fn list_certificates(
143        &self,
144        filter_type: Option<CertificateType>,
145    ) -> Result<Vec<Certificate>>;
146
147    /// `POST /v1/certificates` — issues a new certificate for a CSR.
148    ///
149    /// The response carries `certificate_content`, the only copy of the
150    /// signed certificate you are handed at creation time. It can be
151    /// fetched again later with [`CertificatesApi::list_certificates`], but the
152    /// private key it belongs to cannot, so store the two together
153    /// immediately.
154    ///
155    /// Apple caps how many certificates of each type a team may hold at
156    /// once (two for distribution). At the cap this returns a 409; revoke
157    /// something with [`CertificatesApi::revoke_certificate`] first.
158    async fn create_certificate(
159        &self,
160        attributes: CertificateCreateAttributes,
161    ) -> Result<Certificate>;
162
163    /// `DELETE /v1/certificates/{id}` — revokes a certificate.
164    ///
165    /// Irreversible, and wider-reaching than it looks: every provisioning
166    /// profile that embeds this certificate stops working for everyone on
167    /// the team at once, including builds already in CI. Revoking is for
168    /// reclaiming a slot at Apple's per-type cap or for a key you believe
169    /// is compromised, not for tidying up.
170    async fn revoke_certificate(&self, id: &str) -> Result<()>;
171}
172
173#[async_trait]
174impl CertificatesApi for Client {
175    async fn list_certificates(
176        &self,
177        filter_type: Option<CertificateType>,
178    ) -> Result<Vec<Certificate>> {
179        let mut query = Vec::new();
180        if let Some(certificate_type) = filter_type {
181            query.push(("filter[certificateType]", certificate_type.as_api_str()));
182        }
183        // 200 rows per page instead of Apple's default 20; `list_all` follows
184        // `links.next` so several platforms plus years of history never
185        // truncate.
186        query.push(("limit", "200"));
187
188        self.list_all::<CertificateAttributes>("/v1/certificates", &query)
189            .await
190    }
191
192    async fn create_certificate(
193        &self,
194        attributes: CertificateCreateAttributes,
195    ) -> Result<Certificate> {
196        let body = CreateBody {
197            data: CreateData {
198                resource_type: RESOURCE_TYPE,
199                attributes,
200                relationships: None::<()>,
201            },
202        };
203        let doc: Document<CertificateAttributes> = self
204            .request(Method::POST, "/v1/certificates", &[], Some(&body))
205            .await?;
206        Ok(doc.data)
207    }
208
209    async fn revoke_certificate(&self, id: &str) -> Result<()> {
210        self.request_no_content(
211            Method::DELETE,
212            &format!("/v1/certificates/{id}"),
213            &[],
214            None::<&()>,
215        )
216        .await
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn certificate_type_round_trips_through_the_api_wire_format() {
226        // The serde rename and as_api_str() are two separate spellings of
227        // the same contract, and a filter built from the wrong one fails
228        // silently by matching nothing.
229        for certificate_type in [
230            CertificateType::Development,
231            CertificateType::Distribution,
232            CertificateType::IosDevelopment,
233            CertificateType::IosDistribution,
234            CertificateType::MacAppDevelopment,
235            CertificateType::MacAppDistribution,
236            CertificateType::MacInstallerDistribution,
237            CertificateType::DeveloperIdApplication,
238            CertificateType::DeveloperIdKext,
239        ] {
240            let json = serde_json::to_string(&certificate_type).expect("serializes");
241            let unquoted = json.trim_matches('"');
242            assert_eq!(
243                unquoted,
244                certificate_type.as_api_str(),
245                "serde and as_api_str disagree for {certificate_type:?}"
246            );
247
248            let parsed: CertificateType = serde_json::from_str(&json).expect("deserializes");
249            assert_eq!(parsed, certificate_type);
250        }
251    }
252}