development
rs 252 lines 9.96 KB
Raw
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
23 use async_trait::async_trait;
24 use reqwest::Method;
25 use serde::{Deserialize, Serialize};
26 use smbcloud_ascapi_core::jsonapi::{CreateBody, CreateData, Document, Resource};
27 use smbcloud_ascapi_core::Client;
28 use smbcloud_ascapi_core::Result;
29
30 pub 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)]
49 pub 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
79 impl 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")]
99 pub 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
116 pub type Certificate = Resource<CertificateAttributes>;
117
118 #[derive(Debug, Clone, Serialize)]
119 #[serde(rename_all = "camelCase")]
120 pub 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]
134 pub 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]
174 impl 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)]
221 mod 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 }