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}