smbcloud-ascapi-signing 0.2.0

Apple code signing through App Store Connect: certificates, plus local RSA key pair and CSR generation.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
//! Provisioning profiles: `/v1/profiles`.
//!
//! A profile is the document that ties three things together and says
//! Apple approves of the combination: *which app* (a bundle ID), *signed by
//! whom* (one or more certificates), and *allowed to run where* (a device
//! list, for the profile types that use one). Code signing consults it to
//! decide whether the entitlements a binary claims are ones the App ID is
//! actually authorised for.
//!
//! That last part is why this module exists alongside [`crate::certificate`]
//! rather than in the metadata crate. A certificate proves identity and
//! nothing else; it says nothing about entitlements. Enabling a capability
//! on an App ID does not reach an already-issued profile either — profiles
//! are snapshots, so a capability added today is invisible to a profile
//! created yesterday. The fix is always to create a new one, which is what
//! [`ProfilesApi::create_profile`] is for.
//!
//! Two operational notes that are easy to learn the expensive way:
//!
//! - **Apple will happily hand back a stale profile.** A profile stays
//!   `ACTIVE` until its certificate expires or is revoked, so a capability
//!   change leaves an outdated-but-valid profile in place. Nothing in the
//!   API marks it as superseded. Recreate rather than re-download.
//! - **`profileContent` is the file.** It is base64 of the binary
//!   `.mobileprovision`/`.provisionprofile` that goes into the bundle;
//!   there is no separate download endpoint.
//!
//! App Groups are the common reason a profile needs regenerating and are
//! also the one thing this API cannot help with: there is no `appGroups`
//! resource, so assigning a group to an App ID is Developer Portal web UI
//! work. Once assigned, a new profile picks it up.

use async_trait::async_trait;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use smbcloud_ascapi_core::jsonapi::{
    CreateBody, CreateData, Document, Resource, ResourceId, ToMany, ToOne,
};
use smbcloud_ascapi_core::Client;
use smbcloud_ascapi_core::Result;

pub const RESOURCE_TYPE: &str = "profiles";

/// The kind of profile, which fixes both the distribution channel and
/// whether a device list applies.
///
/// The `*Development` and `*AdHoc` variants are device-scoped: a build
/// signed with one runs only on hardware in the profile's device list.
/// The `*AppStore` and `*InHouse` variants are not, and Apple rejects a
/// create request that sends devices for them.
///
/// [`Self::MacAppStore`] is the one to use for a Mac App Store submission;
/// [`Self::MacAppDirect`] is Developer ID distribution outside the store
/// and is a different signing path entirely.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProfileType {
    #[serde(rename = "IOS_APP_DEVELOPMENT")]
    IosAppDevelopment,
    #[serde(rename = "IOS_APP_STORE")]
    IosAppStore,
    #[serde(rename = "IOS_APP_ADHOC")]
    IosAppAdHoc,
    #[serde(rename = "IOS_APP_INHOUSE")]
    IosAppInHouse,
    #[serde(rename = "MAC_APP_DEVELOPMENT")]
    MacAppDevelopment,
    /// Mac App Store distribution — the profile a `productbuild` submission
    /// embeds as `Contents/embedded.provisionprofile`.
    #[serde(rename = "MAC_APP_STORE")]
    MacAppStore,
    #[serde(rename = "MAC_APP_DIRECT")]
    MacAppDirect,
    #[serde(rename = "TVOS_APP_DEVELOPMENT")]
    TvOsAppDevelopment,
    #[serde(rename = "TVOS_APP_STORE")]
    TvOsAppStore,
    #[serde(rename = "TVOS_APP_ADHOC")]
    TvOsAppAdHoc,
    #[serde(rename = "TVOS_APP_INHOUSE")]
    TvOsAppInHouse,
    #[serde(rename = "MAC_CATALYST_APP_DEVELOPMENT")]
    MacCatalystAppDevelopment,
    #[serde(rename = "MAC_CATALYST_APP_STORE")]
    MacCatalystAppStore,
    #[serde(rename = "MAC_CATALYST_APP_DIRECT")]
    MacCatalystAppDirect,
}

impl ProfileType {
    /// The string App Store Connect uses, for building
    /// `filter[profileType]` query values without going through serde.
    pub fn as_api_str(self) -> &'static str {
        match self {
            Self::IosAppDevelopment => "IOS_APP_DEVELOPMENT",
            Self::IosAppStore => "IOS_APP_STORE",
            Self::IosAppAdHoc => "IOS_APP_ADHOC",
            Self::IosAppInHouse => "IOS_APP_INHOUSE",
            Self::MacAppDevelopment => "MAC_APP_DEVELOPMENT",
            Self::MacAppStore => "MAC_APP_STORE",
            Self::MacAppDirect => "MAC_APP_DIRECT",
            Self::TvOsAppDevelopment => "TVOS_APP_DEVELOPMENT",
            Self::TvOsAppStore => "TVOS_APP_STORE",
            Self::TvOsAppAdHoc => "TVOS_APP_ADHOC",
            Self::TvOsAppInHouse => "TVOS_APP_INHOUSE",
            Self::MacCatalystAppDevelopment => "MAC_CATALYST_APP_DEVELOPMENT",
            Self::MacCatalystAppStore => "MAC_CATALYST_APP_STORE",
            Self::MacCatalystAppDirect => "MAC_CATALYST_APP_DIRECT",
        }
    }

    /// Whether Apple expects a device list for this profile type.
    ///
    /// Sending devices for a store profile is an error rather than a
    /// harmless extra, so callers building a create request need to know
    /// this before they assemble relationships.
    pub fn takes_devices(self) -> bool {
        matches!(
            self,
            Self::IosAppDevelopment
                | Self::IosAppAdHoc
                | Self::MacAppDevelopment
                | Self::TvOsAppDevelopment
                | Self::TvOsAppAdHoc
                | Self::MacCatalystAppDevelopment
        )
    }
}

/// Whether Apple still considers the profile usable.
///
/// `Invalid` is not a separate deletion state: it is what a profile
/// becomes when something it depends on goes away, most often a revoked or
/// expired certificate. The profile stays listed so you can see why a
/// build that used to sign no longer does.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProfileState {
    #[serde(rename = "ACTIVE")]
    Active,
    #[serde(rename = "INVALID")]
    Invalid,
}

impl ProfileState {
    pub fn as_api_str(self) -> &'static str {
        match self {
            Self::Active => "ACTIVE",
            Self::Invalid => "INVALID",
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileAttributes {
    pub name: Option<String>,
    pub platform: Option<String>,
    pub profile_type: Option<ProfileType>,
    pub profile_state: Option<ProfileState>,
    /// The UUID embedded in the profile itself, which is what Xcode and
    /// `codesign` diagnostics refer to. Distinct from the resource `id`
    /// this API uses.
    pub uuid: Option<String>,
    pub created_date: Option<String>,
    /// ISO-8601. Bounded by the signing certificate's own expiry, so a
    /// profile never outlives the certificate it embeds.
    pub expiration_date: Option<String>,
    /// Base64 of the binary profile file. Omitted from list responses
    /// unless asked for via `fields[profiles]`; see
    /// [`ProfilesApi::get_profile_content`].
    pub profile_content: Option<String>,
}

pub type Profile = Resource<ProfileAttributes>;

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileCreateAttributes {
    /// Free-form label shown in the developer portal. Apple permits
    /// duplicates, so a team can accumulate several same-named profiles
    /// that differ only by creation date — worth making distinctive.
    pub name: String,
    pub profile_type: ProfileType,
}

/// The bundle ID behind a profile, as `GET /v1/profiles/{id}/bundleId`
/// returns it. Only the fields a caller needs to match a profile to an
/// app; the signing crate doesn't depend on the aso crate's full type.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileBundleIdAttributes {
    pub identifier: Option<String>,
    pub platform: Option<String>,
}

pub type ProfileBundleId = Resource<ProfileBundleIdAttributes>;

/// The relationships a create request must carry.
///
/// Built explicitly rather than derived from ids alone, because `devices`
/// has to be *absent* rather than empty for store profiles — see
/// [`ProfileType::takes_devices`].
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileCreateRelationships {
    pub bundle_id: ToOne,
    pub certificates: ToMany,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub devices: Option<ToMany>,
}

impl ProfileCreateRelationships {
    /// Assemble relationships for `profile_type`, dropping `devices` when
    /// the type does not take them.
    pub fn new(
        profile_type: ProfileType,
        bundle_id: &str,
        certificate_ids: &[String],
        device_ids: &[String],
    ) -> Self {
        let devices = if profile_type.takes_devices() && !device_ids.is_empty() {
            Some(ToMany {
                data: device_ids
                    .iter()
                    .map(|id| ResourceId {
                        resource_type: "devices",
                        id: id.clone(),
                    })
                    .collect(),
            })
        } else {
            None
        };

        Self {
            bundle_id: ToOne {
                data: ResourceId {
                    resource_type: "bundleIds",
                    id: bundle_id.to_string(),
                },
            },
            certificates: ToMany {
                data: certificate_ids
                    .iter()
                    .map(|id| ResourceId {
                        resource_type: "certificates",
                        id: id.clone(),
                    })
                    .collect(),
            },
            devices,
        }
    }
}

/// Provisioning profiles.
///
/// An extension trait rather than inherent methods, because `Client`
/// lives in the core crate and Rust only allows inherent impls in the
/// crate that defines the type. Import it, or the crate's `prelude`,
/// to call these on a `Client`.
#[async_trait]
pub trait ProfilesApi {
    /// `GET /v1/profiles`, optionally filtered by name and type.
    ///
    /// `profile_content` is left out of these results by Apple. That is
    /// usually what you want — the content is a multi-kilobyte blob and
    /// listing is for deciding *which* profile you need. Fetch the bytes
    /// with [`ProfilesApi::get_profile_content`] once you know.
    async fn list_profiles(
        &self,
        filter_name: Option<&str>,
        filter_type: Option<ProfileType>,
    ) -> Result<Vec<Profile>>;

    /// `GET /v1/profiles/{id}` asking explicitly for `profileContent`.
    ///
    /// Separate from listing because the field has to be requested: a
    /// plain `GET` of the same resource returns metadata with
    /// `profileContent` absent, which reads as "this profile has no
    /// content" rather than "you did not ask for it".
    async fn get_profile_content(&self, id: &str) -> Result<Profile>;

    /// `POST /v1/profiles` — creates a profile over a bundle ID and
    /// certificates.
    ///
    /// This is the only way to pick up a capability that was added to the
    /// App ID after an existing profile was made. Apple does not reject
    /// the request as a duplicate when an equivalent profile already
    /// exists; you get a second one, and both stay active.
    async fn create_profile(
        &self,
        attributes: ProfileCreateAttributes,
        relationships: ProfileCreateRelationships,
    ) -> Result<Profile>;

    /// `GET /v1/profiles/{id}/bundleId` — the bundle ID a profile is for.
    ///
    /// A list response carries only the relationship link, not the
    /// identifier, so finding which app an expired profile belongs to
    /// takes one of these per profile.
    async fn get_profile_bundle_id(&self, id: &str) -> Result<ProfileBundleId>;

    /// `DELETE /v1/profiles/{id}`.
    ///
    /// Narrower than revoking a certificate: it invalidates only this
    /// profile, and any build already signed with it keeps working. New
    /// signing that referenced it fails until a replacement exists.
    async fn delete_profile(&self, id: &str) -> Result<()>;
}

#[async_trait]
impl ProfilesApi for Client {
    async fn list_profiles(
        &self,
        filter_name: Option<&str>,
        filter_type: Option<ProfileType>,
    ) -> Result<Vec<Profile>> {
        let mut query = Vec::new();
        if let Some(name) = filter_name {
            query.push(("filter[name]", name));
        }
        if let Some(profile_type) = filter_type {
            query.push(("filter[profileType]", profile_type.as_api_str()));
        }
        // 200 rows per page instead of Apple's default 20; `list_all` follows
        // `links.next` so a team with one profile per app per platform is
        // never truncated.
        query.push(("limit", "200"));

        self.list_all::<ProfileAttributes>("/v1/profiles", &query)
            .await
    }

    async fn get_profile_content(&self, id: &str) -> Result<Profile> {
        let query = [(
            "fields[profiles]",
            "name,platform,profileType,profileState,uuid,createdDate,expirationDate,profileContent",
        )];
        let doc: Document<ProfileAttributes> = self
            .request(
                Method::GET,
                &format!("/v1/profiles/{id}"),
                &query,
                None::<&()>,
            )
            .await?;
        Ok(doc.data)
    }

    async fn create_profile(
        &self,
        attributes: ProfileCreateAttributes,
        relationships: ProfileCreateRelationships,
    ) -> Result<Profile> {
        let body = CreateBody {
            data: CreateData {
                resource_type: RESOURCE_TYPE,
                attributes,
                relationships: Some(relationships),
            },
        };
        let doc: Document<ProfileAttributes> = self
            .request(Method::POST, "/v1/profiles", &[], Some(&body))
            .await?;
        Ok(doc.data)
    }

    async fn get_profile_bundle_id(&self, id: &str) -> Result<ProfileBundleId> {
        let doc: Document<ProfileBundleIdAttributes> = self
            .request(
                Method::GET,
                &format!("/v1/profiles/{id}/bundleId"),
                &[],
                None::<&()>,
            )
            .await?;
        Ok(doc.data)
    }

    async fn delete_profile(&self, id: &str) -> Result<()> {
        self.request_no_content(
            Method::DELETE,
            &format!("/v1/profiles/{id}"),
            &[],
            None::<&()>,
        )
        .await
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn profile_type_round_trips_through_the_api_wire_format() {
        // The serde rename and as_api_str() are two separate spellings of
        // the same contract, and a filter built from the wrong one fails
        // silently by matching nothing.
        for profile_type in [
            ProfileType::IosAppDevelopment,
            ProfileType::IosAppStore,
            ProfileType::IosAppAdHoc,
            ProfileType::IosAppInHouse,
            ProfileType::MacAppDevelopment,
            ProfileType::MacAppStore,
            ProfileType::MacAppDirect,
            ProfileType::TvOsAppDevelopment,
            ProfileType::TvOsAppStore,
            ProfileType::TvOsAppAdHoc,
            ProfileType::TvOsAppInHouse,
            ProfileType::MacCatalystAppDevelopment,
            ProfileType::MacCatalystAppStore,
            ProfileType::MacCatalystAppDirect,
        ] {
            let json = serde_json::to_string(&profile_type).expect("serializes");
            let unquoted = json.trim_matches('"');
            assert_eq!(
                unquoted,
                profile_type.as_api_str(),
                "serde and as_api_str disagree for {profile_type:?}"
            );

            let parsed: ProfileType = serde_json::from_str(&json).expect("deserializes");
            assert_eq!(parsed, profile_type);
        }
    }

    #[test]
    fn profile_state_round_trips_through_the_api_wire_format() {
        for state in [ProfileState::Active, ProfileState::Invalid] {
            let json = serde_json::to_string(&state).expect("serializes");
            assert_eq!(json.trim_matches('"'), state.as_api_str());
            let parsed: ProfileState = serde_json::from_str(&json).expect("deserializes");
            assert_eq!(parsed, state);
        }
    }

    #[test]
    fn store_profiles_omit_devices_entirely() {
        // An empty `devices` array is not the same as no `devices` key:
        // Apple rejects the former on a store profile. Serializing has to
        // drop the field, not send `[]`.
        let relationships = ProfileCreateRelationships::new(
            ProfileType::MacAppStore,
            "P59W5V5953",
            &["CERT1".to_string()],
            &["DEVICE1".to_string()],
        );
        let json = serde_json::to_value(&relationships).expect("serializes");
        assert!(
            json.get("devices").is_none(),
            "a store profile must not carry devices: {json}"
        );
        assert_eq!(json["bundleId"]["data"]["type"], "bundleIds");
        assert_eq!(json["certificates"]["data"][0]["type"], "certificates");
    }

    #[test]
    fn development_profiles_keep_their_devices() {
        let relationships = ProfileCreateRelationships::new(
            ProfileType::IosAppDevelopment,
            "P59W5V5953",
            &["CERT1".to_string()],
            &["DEVICE1".to_string(), "DEVICE2".to_string()],
        );
        let json = serde_json::to_value(&relationships).expect("serializes");
        assert_eq!(json["devices"]["data"].as_array().expect("array").len(), 2);
        assert_eq!(json["devices"]["data"][0]["type"], "devices");
    }

    #[test]
    fn a_development_profile_with_no_devices_omits_the_key() {
        // Rather than sending `{"data": []}`, which Apple reads as an
        // explicit empty set and rejects.
        let relationships = ProfileCreateRelationships::new(
            ProfileType::IosAppDevelopment,
            "P59W5V5953",
            &["CERT1".to_string()],
            &[],
        );
        let json = serde_json::to_value(&relationships).expect("serializes");
        assert!(json.get("devices").is_none());
    }

    #[test]
    fn reads_the_bundle_id_behind_a_profile() {
        let doc: Document<ProfileBundleIdAttributes> = serde_json::from_value(serde_json::json!({
            "data": { "id": "C3PP53MLM3", "type": "bundleIds",
                      "attributes": { "identifier": "ai.splitfire.SplitfireAI",
                                      "platform": "UNIVERSAL", "name": "SplitFire AI", "seedId": "2TQF86ZACD" } }
        }))
        .unwrap();
        assert_eq!(doc.data.id, "C3PP53MLM3");
        assert_eq!(
            doc.data.attributes.identifier.as_deref(),
            Some("ai.splitfire.SplitfireAI")
        );
    }
}