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