Add provisioning profiles to the signing crate and the CLI

Co-Authored-By: siGit Code <297239231+sigitc@users.noreply.github.com>

Seto Elkahfi committed Aug 27, 2026 at 07:22 UTC 78d9effb2ec5817e6640c899332bafd45283b39b
9 files changed +984 -4
README.md
+1 -1
@@ -9,7 +9,7 @@ from the other:
9 | --- | --- |
10 | `smbcloud-ascapi-core` | Shared transport: JWT auth, the HTTP client, JSON:API envelopes, error types |
11 | `smbcloud-ascapi-aso` | App Metadata: apps, app infos, versions, bundle IDs, localizations, screenshots |
12 -| `smbcloud-ascapi-signing` | Code signing: certificates, plus local RSA key pair and CSR generation |
12 +| `smbcloud-ascapi-signing` | Code signing: certificates and provisioning profiles, plus local RSA key pair and CSR generation |
13 | `smbcloud-ascapi-frontend` | Operations both surfaces share, so the CLI and the MCP server agree by construction |
14 | `smbcloud-ascapi-mcp` | The MCP contract and stdio server |
15 | `smbcloud-ascapi-cli` | The `ascapi` binary: clap command tree, plus `--mcp` |
crates/aso/src/bundle_id.rs
+60
@@ -22,6 +22,16 @@ pub enum BundleIdPlatform {
22 MacOs,
23 #[serde(rename = "UNIVERSAL")]
24 Universal,
25 + /// Not an app platform: the identifier kind Apple assigns to Services
26 + /// IDs (Sign in with Apple, push-only identifiers, and similar).
27 + ///
28 + /// Present because `GET /v1/bundleIds` returns every identifier the
29 + /// team owns, mixed together. Without this variant, one Services ID
30 + /// anywhere in the account fails the deserialization of the whole
31 + /// list, so listing breaks for reasons that have nothing to do with
32 + /// the app being looked up.
33 + #[serde(rename = "SERVICES")]
34 + Services,
35 }
36
37 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -69,6 +79,11 @@ impl BundleIdsApi for Client {
79 if let Some(identifier) = filter_identifier {
80 query.push(("filter[identifier]", identifier));
81 }
82 + // Apple's default page size is 20. Without this an unfiltered list
83 + // silently stops at the first 20 identifiers, which reads as "that
84 + // bundle ID is not registered" for anything further down.
85 + query.push(("limit", "200"));
86 +
87 let doc: ListDocument<BundleIdAttributes> = self
88 .request(Method::GET, "/v1/bundleIds", &query, None::<&()>)
89 .await?;
@@ -89,3 +104,48 @@ impl BundleIdsApi for Client {
104 Ok(doc.data)
105 }
106 }
107 +
108 +#[cfg(test)]
109 +mod tests {
110 + use super::*;
111 +
112 + #[test]
113 + fn a_services_identifier_does_not_break_the_list() {
114 + // Regression: `GET /v1/bundleIds` returns every identifier a team
115 + // owns, and a single Services ID used to fail the whole response's
116 + // deserialization — so `bundle-ids list` reported nothing at all
117 + // for accounts that have one.
118 + let body = serde_json::json!({
119 + "data": [
120 + {
121 + "id": "AAA",
122 + "type": "bundleIds",
123 + "attributes": {
124 + "name": "An app",
125 + "identifier": "xyz.example.app",
126 + "platform": "UNIVERSAL",
127 + "seedId": "TEAMID"
128 + }
129 + },
130 + {
131 + "id": "BBB",
132 + "type": "bundleIds",
133 + "attributes": {
134 + "name": "Sign in with Apple",
135 + "identifier": "xyz.example.service",
136 + "platform": "SERVICES",
137 + "seedId": "TEAMID"
138 + }
139 + }
140 + ]
141 + });
142 +
143 + let doc: ListDocument<BundleIdAttributes> =
144 + serde_json::from_value(body).expect("a Services identifier must not fail the list");
145 + assert_eq!(doc.data.len(), 2);
146 + assert_eq!(
147 + doc.data[1].attributes.platform,
148 + Some(BundleIdPlatform::Services)
149 + );
150 + }
151 +}
crates/cli/src/main.rs
+225
@@ -17,6 +17,9 @@ use smbcloud_ascapi_core::{ApiKey, Client};
17 use smbcloud_ascapi_signing::certificate::{CertificateCreateAttributes, CertificateType};
18 use smbcloud_ascapi_signing::csr::generate_certificate_request;
19 use smbcloud_ascapi_signing::prelude::*;
20 +use smbcloud_ascapi_signing::profile::{
21 + ProfileCreateAttributes, ProfileCreateRelationships, ProfileType,
22 +};
23 use std::path::PathBuf;
24
25 /// Add/update App Store Connect app metadata (apps, app infos, app store
@@ -103,6 +106,131 @@ enum Command {
106 #[command(subcommand)]
107 command: CertificatesCommand,
108 },
109 + /// Provisioning profiles: what a bundle ID is allowed to claim, and
110 + /// which certificates may sign it. Create a new one after changing an
111 + /// App ID's capabilities — existing profiles never pick the change up.
112 + Profiles {
113 + #[command(subcommand)]
114 + command: ProfilesCommand,
115 + },
116 +}
117 +
118 +#[derive(Subcommand)]
119 +enum ProfilesCommand {
120 + /// List the team's profiles, newest expiry last.
121 + ///
122 + /// `profileContent` is deliberately not shown; use `download` for the
123 + /// file itself.
124 + List {
125 + /// Only show profiles with this exact name. Apple allows
126 + /// duplicates, so this can still match several.
127 + #[arg(long)]
128 + name: Option<String>,
129 + /// Only show one type.
130 + #[arg(long, value_enum)]
131 + r#type: Option<CliProfileType>,
132 + },
133 + /// Create a profile over a bundle ID and one or more certificates.
134 + ///
135 + /// This is the only way to pick up a capability added to the App ID
136 + /// after an existing profile was made: profiles are snapshots, and
137 + /// Apple keeps serving the stale one as ACTIVE until its certificate
138 + /// dies. Apple does not treat an equivalent profile as a duplicate, so
139 + /// repeated runs accumulate profiles rather than replacing one.
140 + Create {
141 + /// Label shown in the developer portal.
142 + #[arg(long)]
143 + name: String,
144 + #[arg(long, value_enum)]
145 + r#type: CliProfileType,
146 + /// Resource id of the bundle ID (from `bundle-ids list`), not the
147 + /// reverse-DNS identifier.
148 + #[arg(long)]
149 + bundle_id: String,
150 + /// Certificate resource id (from `certificates list`). Repeat for
151 + /// several.
152 + #[arg(long = "certificate", required = true)]
153 + certificates: Vec<String>,
154 + /// Device resource id. Only meaningful for development and ad hoc
155 + /// types; ignored for store profiles, which Apple rejects if sent
156 + /// devices.
157 + #[arg(long = "device")]
158 + devices: Vec<String>,
159 + },
160 + /// Write a profile's bytes to a file.
161 + ///
162 + /// The output is the binary `.provisionprofile`/`.mobileprovision` a
163 + /// bundle embeds, not JSON.
164 + Download {
165 + id: String,
166 + /// Where to write the profile.
167 + #[arg(long)]
168 + output: PathBuf,
169 + },
170 + /// Delete a profile.
171 + ///
172 + /// Narrower than revoking a certificate: builds already signed with it
173 + /// keep working, but new signing against it fails.
174 + Delete {
175 + id: String,
176 + /// Required. Pass the profile id again to confirm.
177 + #[arg(long)]
178 + confirm: String,
179 + },
180 +}
181 +
182 +#[derive(Copy, Clone, Debug, ValueEnum)]
183 +enum CliProfileType {
184 + /// iOS development — device-scoped.
185 + IosAppDevelopment,
186 + /// iOS App Store distribution.
187 + IosAppStore,
188 + /// iOS ad hoc — device-scoped.
189 + IosAppAdhoc,
190 + /// iOS in-house (Enterprise) distribution.
191 + IosAppInhouse,
192 + /// macOS development — device-scoped.
193 + MacAppDevelopment,
194 + /// Mac App Store distribution — the embedded.provisionprofile a MAS
195 + /// submission carries.
196 + MacAppStore,
197 + /// Developer ID — distribution outside the App Store.
198 + MacAppDirect,
199 + /// tvOS development — device-scoped.
200 + TvosAppDevelopment,
201 + /// tvOS App Store distribution.
202 + TvosAppStore,
203 + /// tvOS ad hoc — device-scoped.
204 + TvosAppAdhoc,
205 + /// tvOS in-house (Enterprise) distribution.
206 + TvosAppInhouse,
207 + /// Mac Catalyst development — device-scoped.
208 + MacCatalystAppDevelopment,
209 + /// Mac Catalyst App Store distribution.
210 + MacCatalystAppStore,
211 + /// Mac Catalyst Developer ID distribution.
212 + MacCatalystAppDirect,
213 +}
214 +
215 +impl From<CliProfileType> for ProfileType {
216 + fn from(value: CliProfileType) -> Self {
217 + match value {
218 + CliProfileType::IosAppDevelopment => ProfileType::IosAppDevelopment,
219 + CliProfileType::IosAppStore => ProfileType::IosAppStore,
220 + CliProfileType::IosAppAdhoc => ProfileType::IosAppAdHoc,
221 + CliProfileType::IosAppInhouse => ProfileType::IosAppInHouse,
222 + CliProfileType::MacAppDevelopment => ProfileType::MacAppDevelopment,
223 + CliProfileType::MacAppStore => ProfileType::MacAppStore,
224 + CliProfileType::MacAppDirect => ProfileType::MacAppDirect,
225 + CliProfileType::TvosAppDevelopment => ProfileType::TvOsAppDevelopment,
226 + CliProfileType::TvosAppStore => ProfileType::TvOsAppStore,
227 + CliProfileType::TvosAppAdhoc => ProfileType::TvOsAppAdHoc,
228 + CliProfileType::TvosAppInhouse => ProfileType::TvOsAppInHouse,
229 + CliProfileType::MacCatalystAppDevelopment => ProfileType::MacCatalystAppDevelopment,
230 + CliProfileType::MacCatalystAppStore => ProfileType::MacCatalystAppStore,
231 + CliProfileType::MacCatalystAppDirect => ProfileType::MacCatalystAppDirect,
232 + }
233 + }
234 }
235
236 #[derive(Subcommand)]
@@ -502,6 +630,7 @@ async fn main() -> Result<()> {
630 run_app_screenshots(&client, command, cli.dry_run).await
631 }
632 Command::Certificates { command } => run_certificates(&client, command, cli.dry_run).await,
633 + Command::Profiles { command } => run_profiles(&client, command, cli.dry_run).await,
634 }
635 }
636
@@ -1007,6 +1136,102 @@ async fn run_certificates(
1136 }
1137 }
1138
1139 +async fn run_profiles(client: &Client, command: ProfilesCommand, dry_run: bool) -> Result<()> {
1140 + use smbcloud_ascapi_frontend::profiles::{download_profile, ProfileSummary};
1141 + use smbcloud_ascapi_frontend::time::now_iso8601;
1142 +
1143 + match command {
1144 + ProfilesCommand::List { name, r#type } => {
1145 + let mut profiles = client
1146 + .list_profiles(name.as_deref(), r#type.map(Into::into))
1147 + .await?;
1148 + profiles.sort_by(|a, b| {
1149 + a.attributes
1150 + .expiration_date
1151 + .cmp(&b.attributes.expiration_date)
1152 + });
1153 + let now = now_iso8601();
1154 + let summaries: Vec<_> = profiles
1155 + .iter()
1156 + .map(|profile| ProfileSummary::from_resource(profile, &now))
1157 + .collect();
1158 + print_json(&summaries)
1159 + }
1160 +
1161 + ProfilesCommand::Create {
1162 + name,
1163 + r#type,
1164 + bundle_id,
1165 + certificates,
1166 + devices,
1167 + } => {
1168 + let profile_type: ProfileType = r#type.into();
1169 +
1170 + // Say so rather than dropping them silently: a caller who
1171 + // passed devices to a store profile has misunderstood
1172 + // something, and the created profile will not be what they
1173 + // expect.
1174 + if !devices.is_empty() && !profile_type.takes_devices() {
1175 + eprintln!(
1176 + "note: {} does not take devices; ignoring the {} passed",
1177 + profile_type.as_api_str(),
1178 + devices.len()
1179 + );
1180 + }
1181 +
1182 + let relationships =
1183 + ProfileCreateRelationships::new(profile_type, &bundle_id, &certificates, &devices);
1184 + let attributes = ProfileCreateAttributes {
1185 + name: name.clone(),
1186 + profile_type,
1187 + };
1188 +
1189 + if dry_run {
1190 + print_json(&serde_json::json!({
1191 + "data": {
1192 + "type": "profiles",
1193 + "attributes": attributes,
1194 + "relationships": relationships,
1195 + }
1196 + }))?;
1197 + return Ok(());
1198 + }
1199 +
1200 + let profile = client.create_profile(attributes, relationships).await?;
1201 + print_json(&ProfileSummary::from_resource(&profile, &now_iso8601()))
1202 + }
1203 +
1204 + ProfilesCommand::Download { id, output } => {
1205 + if dry_run {
1206 + println!(
1207 + "# dry run — would write profile {id} to {}",
1208 + output.display()
1209 + );
1210 + return Ok(());
1211 + }
1212 + let downloaded = download_profile(client, &id, &output)
1213 + .await
1214 + .map_err(anyhow::Error::msg)?;
1215 + print_json(&downloaded)
1216 + }
1217 +
1218 + ProfilesCommand::Delete { id, confirm } => {
1219 + if confirm != id {
1220 + anyhow::bail!(
1221 + "refusing to delete: pass --confirm {id} to acknowledge that signing \
1222 + against this profile stops working until a replacement exists"
1223 + );
1224 + }
1225 + if dry_run {
1226 + println!("# dry run — would delete profile {id}");
1227 + return Ok(());
1228 + }
1229 + client.delete_profile(&id).await?;
1230 + print_json(&serde_json::json!({ "deleted": id }))
1231 + }
1232 + }
1233 +}
1234 +
1235 /// Write a private key with owner-only permissions, set at creation time
1236 /// rather than chmod'ed afterwards so the key is never briefly readable.
1237 fn write_private_key(path: &std::path::Path, pem: &str) -> std::io::Result<()> {
crates/core/src/jsonapi.rs
+13
@@ -48,6 +48,19 @@ pub struct ToOne {
48 pub data: ResourceId,
49 }
50
51 +/// A to-many relationship payload, e.g. `relationships.certificates` on a
52 +/// `POST /v1/profiles` body.
53 +///
54 +/// Apple distinguishes an empty array from an absent relationship: sending
55 +/// `{"data": []}` for `devices` means "no devices", which a development
56 +/// profile rejects, while omitting the key entirely is what an App Store
57 +/// profile wants. Callers that mean "absent" should pass `None` for the
58 +/// whole field rather than an empty `ToMany`.
59 +#[derive(Debug, Clone, Serialize)]
60 +pub struct ToMany {
61 + pub data: Vec<ResourceId>,
62 +}
63 +
64 #[derive(Debug, Clone, Serialize)]
65 pub struct CreateBody<A, R> {
66 pub data: CreateData<A, R>,
crates/frontend/src/enums.rs
+4
@@ -29,6 +29,10 @@ pub fn bundle_id_platform_from_str(value: &str) -> Result<BundleIdPlatform, Stri
29 "ios" => Ok(BundleIdPlatform::Ios),
30 "mac_os" | "macos" => Ok(BundleIdPlatform::MacOs),
31 "universal" => Ok(BundleIdPlatform::Universal),
32 + // Accepted so a round trip through this mapper does not lose a
33 + // value the API itself returns. Registering one is a different
34 + // flow than an app bundle ID, and Apple rejects it here.
35 + "services" => Ok(BundleIdPlatform::Services),
36 other => Err(format!(
37 "unknown bundle ID platform {other:?}; expected one of ios, mac_os, universal"
38 )),
crates/frontend/src/lib.rs
+5
@@ -16,10 +16,14 @@
16 //! refactor away from being bypassed.
17 //! - **No certificate bodies either.** They are large, never needed to
18 //! decide what to do next, and would otherwise land in a transcript.
19 +//! [`profiles::ProfileSummary`] applies the same rule to
20 +//! `profileContent`, and [`profiles::download_profile`] returns the path
21 +//! it wrote instead.
22
23 pub mod certificates;
24 pub mod enums;
25 pub mod env;
26 +pub mod profiles;
27 pub mod time;
28
29 pub use certificates::{
@@ -27,4 +31,5 @@ pub use certificates::{
31 };
32 pub use enums::{bundle_id_platform_from_str, display_type_from_str, platform_from_str};
33 pub use env::api_key_from_env;
34 +pub use profiles::{download_profile, profile_type_from_str, DownloadedProfile, ProfileSummary};
35 pub use time::{is_expired, now_iso8601};
crates/frontend/src/profiles.rs new
+215
@@ -0,0 +1,215 @@
1 +//! Provisioning profile operations shared by both front ends.
2 +
3 +use serde::Serialize;
4 +use smbcloud_ascapi_core::Client;
5 +use smbcloud_ascapi_signing::prelude::*;
6 +use smbcloud_ascapi_signing::profile::{Profile, ProfileType};
7 +
8 +use crate::time::{is_expired, now_iso8601};
9 +
10 +/// Map a CLI/MCP profile-type string onto the API enum.
11 +///
12 +/// Accepts the App Store Connect spelling with either casing, so a caller
13 +/// that copied `MAC_APP_STORE` out of an API response does not have to
14 +/// translate it.
15 +pub fn profile_type_from_str(value: &str) -> Result<ProfileType, String> {
16 + match value.to_ascii_lowercase().as_str() {
17 + "ios_app_development" => Ok(ProfileType::IosAppDevelopment),
18 + "ios_app_store" => Ok(ProfileType::IosAppStore),
19 + "ios_app_adhoc" => Ok(ProfileType::IosAppAdHoc),
20 + "ios_app_inhouse" => Ok(ProfileType::IosAppInHouse),
21 + "mac_app_development" => Ok(ProfileType::MacAppDevelopment),
22 + "mac_app_store" => Ok(ProfileType::MacAppStore),
23 + "mac_app_direct" => Ok(ProfileType::MacAppDirect),
24 + "tvos_app_development" => Ok(ProfileType::TvOsAppDevelopment),
25 + "tvos_app_store" => Ok(ProfileType::TvOsAppStore),
26 + "tvos_app_adhoc" => Ok(ProfileType::TvOsAppAdHoc),
27 + "tvos_app_inhouse" => Ok(ProfileType::TvOsAppInHouse),
28 + "mac_catalyst_app_development" => Ok(ProfileType::MacCatalystAppDevelopment),
29 + "mac_catalyst_app_store" => Ok(ProfileType::MacCatalystAppStore),
30 + "mac_catalyst_app_direct" => Ok(ProfileType::MacCatalystAppDirect),
31 + other => Err(format!(
32 + "unknown profile type {other:?}; expected one of ios_app_development, \
33 + ios_app_store, ios_app_adhoc, ios_app_inhouse, mac_app_development, \
34 + mac_app_store, mac_app_direct, tvos_app_development, tvos_app_store, \
35 + tvos_app_adhoc, tvos_app_inhouse, mac_catalyst_app_development, \
36 + mac_catalyst_app_store, mac_catalyst_app_direct"
37 + )),
38 + }
39 +}
40 +
41 +/// A profile as reported to callers.
42 +///
43 +/// Note what is missing: `profileContent`. It is the multi-kilobyte binary
44 +/// profile, it is never needed to decide what to do next, and keeping it
45 +/// out means a tool result can never carry one into a model's context.
46 +/// `profiles download` writes it to a path instead, the same bargain
47 +/// [`crate::certificates::IssuedCertificate`] makes for key material.
48 +#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
49 +pub struct ProfileSummary {
50 + pub id: String,
51 + pub name: Option<String>,
52 + pub profile_type: Option<String>,
53 + pub profile_state: Option<String>,
54 + pub platform: Option<String>,
55 + /// The UUID inside the profile, which is what `codesign` and Xcode
56 + /// diagnostics quote. Different from `id`.
57 + pub uuid: Option<String>,
58 + pub created_date: Option<String>,
59 + pub expiration_date: Option<String>,
60 + /// Computed locally: App Store Connect offers no filter for it, and a
61 + /// profile can also be unusable while unexpired if its certificate was
62 + /// revoked — check `profile_state` for that case.
63 + pub expired: Option<bool>,
64 +}
65 +
66 +impl ProfileSummary {
67 + pub fn from_resource(profile: &Profile, now: &str) -> Self {
68 + let expiration_date = profile.attributes.expiration_date.clone();
69 + let expired = expiration_date
70 + .as_ref()
71 + .map(|at| is_expired(at.as_str(), now));
72 +
73 + Self {
74 + id: profile.id.clone(),
75 + name: profile.attributes.name.clone(),
76 + profile_type: profile
77 + .attributes
78 + .profile_type
79 + .map(|t| t.as_api_str().to_string()),
80 + profile_state: profile
81 + .attributes
82 + .profile_state
83 + .map(|s| s.as_api_str().to_string()),
84 + platform: profile.attributes.platform.clone(),
85 + uuid: profile.attributes.uuid.clone(),
86 + created_date: profile.attributes.created_date.clone(),
87 + expiration_date,
88 + expired,
89 + }
90 + }
91 +}
92 +
93 +/// The result of writing a profile to disk.
94 +///
95 +/// Carries the path, never the profile bytes.
96 +#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
97 +pub struct DownloadedProfile {
98 + pub profile: ProfileSummary,
99 + pub path: String,
100 + pub bytes: usize,
101 +}
102 +
103 +/// Fetch a profile's content and write it to `path`.
104 +///
105 +/// Shared by the CLI and the MCP tool so the "ask for the field
106 +/// explicitly" detail has one implementation: a plain resource fetch omits
107 +/// `profileContent`, and the absence is indistinguishable from a profile
108 +/// that genuinely has none.
109 +pub async fn download_profile(
110 + client: &Client,
111 + id: &str,
112 + path: &std::path::Path,
113 +) -> Result<DownloadedProfile, String> {
114 + let profile = client
115 + .get_profile_content(id)
116 + .await
117 + .map_err(|error| error.to_string())?;
118 +
119 + let content = profile
120 + .attributes
121 + .profile_content
122 + .as_deref()
123 + .ok_or_else(|| {
124 + format!("profile {id} came back without profileContent, so there is nothing to write")
125 + })?;
126 +
127 + let bytes = base64_decode(content)?;
128 +
129 + if let Some(parent) = path.parent() {
130 + if !parent.as_os_str().is_empty() {
131 + std::fs::create_dir_all(parent)
132 + .map_err(|error| format!("creating {}: {error}", parent.display()))?;
133 + }
134 + }
135 + std::fs::write(path, &bytes).map_err(|error| format!("writing {}: {error}", path.display()))?;
136 +
137 + Ok(DownloadedProfile {
138 + profile: ProfileSummary::from_resource(&profile, &now_iso8601()),
139 + path: path.display().to_string(),
140 + bytes: bytes.len(),
141 + })
142 +}
143 +
144 +fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
145 + use base64::Engine;
146 + base64::engine::general_purpose::STANDARD
147 + .decode(input.trim())
148 + .map_err(|error| format!("decoding the profile Apple returned: {error}"))
149 +}
150 +
151 +#[cfg(test)]
152 +mod tests {
153 + use super::*;
154 +
155 + #[test]
156 + fn profile_type_accepts_the_api_spelling_in_either_casing() {
157 + assert_eq!(
158 + profile_type_from_str("MAC_APP_STORE").unwrap(),
159 + ProfileType::MacAppStore
160 + );
161 + assert_eq!(
162 + profile_type_from_str("mac_app_store").unwrap(),
163 + ProfileType::MacAppStore
164 + );
165 + }
166 +
167 + #[test]
168 + fn errors_name_the_accepted_values() {
169 + // A model that guessed wrong has to correct itself from the error
170 + // alone, without another round trip.
171 + let error = profile_type_from_str("mac_appstore").unwrap_err();
172 + assert!(error.contains("mac_app_store"), "unhelpful error: {error}");
173 + assert!(error.contains("ios_app_store"), "unhelpful error: {error}");
174 + }
175 +
176 + #[test]
177 + fn summary_never_serializes_the_profile_body() {
178 + let summary = ProfileSummary {
179 + id: "ABC123".to_string(),
180 + name: Some("smbCloud Browser MAS Distribution Profile".to_string()),
181 + profile_type: Some("MAC_APP_STORE".to_string()),
182 + profile_state: Some("ACTIVE".to_string()),
183 + platform: Some("MAC_OS".to_string()),
184 + uuid: Some("1a2b3c".to_string()),
185 + created_date: Some("2026-08-26T20:03:07".to_string()),
186 + expiration_date: Some("2027-08-06T19:49:55".to_string()),
187 + expired: Some(false),
188 + };
189 + let json = serde_json::to_string(&summary).expect("serializes");
190 + assert!(!json.contains("profileContent"));
191 + assert!(!json.contains("profile_content"));
192 + }
193 +
194 + #[test]
195 + fn downloaded_profile_reports_a_path_not_bytes() {
196 + let downloaded = DownloadedProfile {
197 + profile: ProfileSummary {
198 + id: "ABC123".to_string(),
199 + name: None,
200 + profile_type: None,
201 + profile_state: None,
202 + platform: None,
203 + uuid: None,
204 + created_date: None,
205 + expiration_date: None,
206 + expired: None,
207 + },
208 + path: "/tmp/out/app.provisionprofile".to_string(),
209 + bytes: 12577,
210 + };
211 + let json = serde_json::to_string(&downloaded).expect("serializes");
212 + assert!(json.contains("app.provisionprofile"));
213 + assert!(!json.contains("profileContent"));
214 + }
215 +}
crates/signing/src/lib.rs
+6 -3
@@ -1,8 +1,9 @@
1 //! Apple code signing through App Store Connect.
2 //!
3 -//! Two halves that only make sense together: [`csr`] generates an RSA key
4 -//! pair and a signing request locally, and [`certificate`] asks Apple to
5 -//! certify it.
3 +//! Three parts. [`csr`] generates an RSA key pair and a signing request
4 +//! locally, [`certificate`] asks Apple to certify it, and [`profile`] binds
5 +//! a certificate to an App ID so signing knows which entitlements are
6 +//! allowed.
7 //!
8 //! The division of labour is the whole point. Apple never receives, and
9 //! never returns, a private key. You keep it; they vouch for its public
@@ -16,7 +17,9 @@
17
18 pub mod certificate;
19 pub mod csr;
20 +pub mod profile;
21
22 pub mod prelude {
23 pub use crate::certificate::CertificatesApi;
24 + pub use crate::profile::ProfilesApi;
25 }
crates/signing/src/profile.rs new
+455
@@ -0,0 +1,455 @@
1 +//! Provisioning profiles: `/v1/profiles`.
2 +//!
3 +//! A profile is the document that ties three things together and says
4 +//! Apple approves of the combination: *which app* (a bundle ID), *signed by
5 +//! whom* (one or more certificates), and *allowed to run where* (a device
6 +//! list, for the profile types that use one). Code signing consults it to
7 +//! decide whether the entitlements a binary claims are ones the App ID is
8 +//! actually authorised for.
9 +//!
10 +//! That last part is why this module exists alongside [`crate::certificate`]
11 +//! rather than in the metadata crate. A certificate proves identity and
12 +//! nothing else; it says nothing about entitlements. Enabling a capability
13 +//! on an App ID does not reach an already-issued profile either — profiles
14 +//! are snapshots, so a capability added today is invisible to a profile
15 +//! created yesterday. The fix is always to create a new one, which is what
16 +//! [`ProfilesApi::create_profile`] is for.
17 +//!
18 +//! Two operational notes that are easy to learn the expensive way:
19 +//!
20 +//! - **Apple will happily hand back a stale profile.** A profile stays
21 +//! `ACTIVE` until its certificate expires or is revoked, so a capability
22 +//! change leaves an outdated-but-valid profile in place. Nothing in the
23 +//! API marks it as superseded. Recreate rather than re-download.
24 +//! - **`profileContent` is the file.** It is base64 of the binary
25 +//! `.mobileprovision`/`.provisionprofile` that goes into the bundle;
26 +//! there is no separate download endpoint.
27 +//!
28 +//! App Groups are the common reason a profile needs regenerating and are
29 +//! also the one thing this API cannot help with: there is no `appGroups`
30 +//! resource, so assigning a group to an App ID is Developer Portal web UI
31 +//! work. Once assigned, a new profile picks it up.
32 +
33 +use async_trait::async_trait;
34 +use reqwest::Method;
35 +use serde::{Deserialize, Serialize};
36 +use smbcloud_ascapi_core::jsonapi::{
37 + CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToMany, ToOne,
38 +};
39 +use smbcloud_ascapi_core::Client;
40 +use smbcloud_ascapi_core::Result;
41 +
42 +pub const RESOURCE_TYPE: &str = "profiles";
43 +
44 +/// The kind of profile, which fixes both the distribution channel and
45 +/// whether a device list applies.
46 +///
47 +/// The `*Development` and `*AdHoc` variants are device-scoped: a build
48 +/// signed with one runs only on hardware in the profile's device list.
49 +/// The `*AppStore` and `*InHouse` variants are not, and Apple rejects a
50 +/// create request that sends devices for them.
51 +///
52 +/// [`Self::MacAppStore`] is the one to use for a Mac App Store submission;
53 +/// [`Self::MacAppDirect`] is Developer ID distribution outside the store
54 +/// and is a different signing path entirely.
55 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56 +pub enum ProfileType {
57 + #[serde(rename = "IOS_APP_DEVELOPMENT")]
58 + IosAppDevelopment,
59 + #[serde(rename = "IOS_APP_STORE")]
60 + IosAppStore,
61 + #[serde(rename = "IOS_APP_ADHOC")]
62 + IosAppAdHoc,
63 + #[serde(rename = "IOS_APP_INHOUSE")]
64 + IosAppInHouse,
65 + #[serde(rename = "MAC_APP_DEVELOPMENT")]
66 + MacAppDevelopment,
67 + /// Mac App Store distribution — the profile a `productbuild` submission
68 + /// embeds as `Contents/embedded.provisionprofile`.
69 + #[serde(rename = "MAC_APP_STORE")]
70 + MacAppStore,
71 + #[serde(rename = "MAC_APP_DIRECT")]
72 + MacAppDirect,
73 + #[serde(rename = "TVOS_APP_DEVELOPMENT")]
74 + TvOsAppDevelopment,
75 + #[serde(rename = "TVOS_APP_STORE")]
76 + TvOsAppStore,
77 + #[serde(rename = "TVOS_APP_ADHOC")]
78 + TvOsAppAdHoc,
79 + #[serde(rename = "TVOS_APP_INHOUSE")]
80 + TvOsAppInHouse,
81 + #[serde(rename = "MAC_CATALYST_APP_DEVELOPMENT")]
82 + MacCatalystAppDevelopment,
83 + #[serde(rename = "MAC_CATALYST_APP_STORE")]
84 + MacCatalystAppStore,
85 + #[serde(rename = "MAC_CATALYST_APP_DIRECT")]
86 + MacCatalystAppDirect,
87 +}
88 +
89 +impl ProfileType {
90 + /// The string App Store Connect uses, for building
91 + /// `filter[profileType]` query values without going through serde.
92 + pub fn as_api_str(self) -> &'static str {
93 + match self {
94 + Self::IosAppDevelopment => "IOS_APP_DEVELOPMENT",
95 + Self::IosAppStore => "IOS_APP_STORE",
96 + Self::IosAppAdHoc => "IOS_APP_ADHOC",
97 + Self::IosAppInHouse => "IOS_APP_INHOUSE",
98 + Self::MacAppDevelopment => "MAC_APP_DEVELOPMENT",
99 + Self::MacAppStore => "MAC_APP_STORE",
100 + Self::MacAppDirect => "MAC_APP_DIRECT",
101 + Self::TvOsAppDevelopment => "TVOS_APP_DEVELOPMENT",
102 + Self::TvOsAppStore => "TVOS_APP_STORE",
103 + Self::TvOsAppAdHoc => "TVOS_APP_ADHOC",
104 + Self::TvOsAppInHouse => "TVOS_APP_INHOUSE",
105 + Self::MacCatalystAppDevelopment => "MAC_CATALYST_APP_DEVELOPMENT",
106 + Self::MacCatalystAppStore => "MAC_CATALYST_APP_STORE",
107 + Self::MacCatalystAppDirect => "MAC_CATALYST_APP_DIRECT",
108 + }
109 + }
110 +
111 + /// Whether Apple expects a device list for this profile type.
112 + ///
113 + /// Sending devices for a store profile is an error rather than a
114 + /// harmless extra, so callers building a create request need to know
115 + /// this before they assemble relationships.
116 + pub fn takes_devices(self) -> bool {
117 + matches!(
118 + self,
119 + Self::IosAppDevelopment
120 + | Self::IosAppAdHoc
121 + | Self::MacAppDevelopment
122 + | Self::TvOsAppDevelopment
123 + | Self::TvOsAppAdHoc
124 + | Self::MacCatalystAppDevelopment
125 + )
126 + }
127 +}
128 +
129 +/// Whether Apple still considers the profile usable.
130 +///
131 +/// `Invalid` is not a separate deletion state: it is what a profile
132 +/// becomes when something it depends on goes away, most often a revoked or
133 +/// expired certificate. The profile stays listed so you can see why a
134 +/// build that used to sign no longer does.
135 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136 +pub enum ProfileState {
137 + #[serde(rename = "ACTIVE")]
138 + Active,
139 + #[serde(rename = "INVALID")]
140 + Invalid,
141 +}
142 +
143 +impl ProfileState {
144 + pub fn as_api_str(self) -> &'static str {
145 + match self {
146 + Self::Active => "ACTIVE",
147 + Self::Invalid => "INVALID",
148 + }
149 + }
150 +}
151 +
152 +#[derive(Debug, Clone, Serialize, Deserialize)]
153 +#[serde(rename_all = "camelCase")]
154 +pub struct ProfileAttributes {
155 + pub name: Option<String>,
156 + pub platform: Option<String>,
157 + pub profile_type: Option<ProfileType>,
158 + pub profile_state: Option<ProfileState>,
159 + /// The UUID embedded in the profile itself, which is what Xcode and
160 + /// `codesign` diagnostics refer to. Distinct from the resource `id`
161 + /// this API uses.
162 + pub uuid: Option<String>,
163 + pub created_date: Option<String>,
164 + /// ISO-8601. Bounded by the signing certificate's own expiry, so a
165 + /// profile never outlives the certificate it embeds.
166 + pub expiration_date: Option<String>,
167 + /// Base64 of the binary profile file. Omitted from list responses
168 + /// unless asked for via `fields[profiles]`; see
169 + /// [`ProfilesApi::get_profile_content`].
170 + pub profile_content: Option<String>,
171 +}
172 +
173 +pub type Profile = Resource<ProfileAttributes>;
174 +
175 +#[derive(Debug, Clone, Serialize)]
176 +#[serde(rename_all = "camelCase")]
177 +pub struct ProfileCreateAttributes {
178 + /// Free-form label shown in the developer portal. Apple permits
179 + /// duplicates, so a team can accumulate several same-named profiles
180 + /// that differ only by creation date — worth making distinctive.
181 + pub name: String,
182 + pub profile_type: ProfileType,
183 +}
184 +
185 +/// The relationships a create request must carry.
186 +///
187 +/// Built explicitly rather than derived from ids alone, because `devices`
188 +/// has to be *absent* rather than empty for store profiles — see
189 +/// [`ProfileType::takes_devices`].
190 +#[derive(Debug, Clone, Serialize)]
191 +#[serde(rename_all = "camelCase")]
192 +pub struct ProfileCreateRelationships {
193 + pub bundle_id: ToOne,
194 + pub certificates: ToMany,
195 + #[serde(skip_serializing_if = "Option::is_none")]
196 + pub devices: Option<ToMany>,
197 +}
198 +
199 +impl ProfileCreateRelationships {
200 + /// Assemble relationships for `profile_type`, dropping `devices` when
201 + /// the type does not take them.
202 + pub fn new(
203 + profile_type: ProfileType,
204 + bundle_id: &str,
205 + certificate_ids: &[String],
206 + device_ids: &[String],
207 + ) -> Self {
208 + let devices = if profile_type.takes_devices() && !device_ids.is_empty() {
209 + Some(ToMany {
210 + data: device_ids
211 + .iter()
212 + .map(|id| ResourceId {
213 + resource_type: "devices",
214 + id: id.clone(),
215 + })
216 + .collect(),
217 + })
218 + } else {
219 + None
220 + };
221 +
222 + Self {
223 + bundle_id: ToOne {
224 + data: ResourceId {
225 + resource_type: "bundleIds",
226 + id: bundle_id.to_string(),
227 + },
228 + },
229 + certificates: ToMany {
230 + data: certificate_ids
231 + .iter()
232 + .map(|id| ResourceId {
233 + resource_type: "certificates",
234 + id: id.clone(),
235 + })
236 + .collect(),
237 + },
238 + devices,
239 + }
240 + }
241 +}
242 +
243 +/// Provisioning profiles.
244 +///
245 +/// An extension trait rather than inherent methods, because `Client`
246 +/// lives in the core crate and Rust only allows inherent impls in the
247 +/// crate that defines the type. Import it, or the crate's `prelude`,
248 +/// to call these on a `Client`.
249 +#[async_trait]
250 +pub trait ProfilesApi {
251 + /// `GET /v1/profiles`, optionally filtered by name and type.
252 + ///
253 + /// `profile_content` is left out of these results by Apple. That is
254 + /// usually what you want — the content is a multi-kilobyte blob and
255 + /// listing is for deciding *which* profile you need. Fetch the bytes
256 + /// with [`ProfilesApi::get_profile_content`] once you know.
257 + async fn list_profiles(
258 + &self,
259 + filter_name: Option<&str>,
260 + filter_type: Option<ProfileType>,
261 + ) -> Result<Vec<Profile>>;
262 +
263 + /// `GET /v1/profiles/{id}` asking explicitly for `profileContent`.
264 + ///
265 + /// Separate from listing because the field has to be requested: a
266 + /// plain `GET` of the same resource returns metadata with
267 + /// `profileContent` absent, which reads as "this profile has no
268 + /// content" rather than "you did not ask for it".
269 + async fn get_profile_content(&self, id: &str) -> Result<Profile>;
270 +
271 + /// `POST /v1/profiles` — creates a profile over a bundle ID and
272 + /// certificates.
273 + ///
274 + /// This is the only way to pick up a capability that was added to the
275 + /// App ID after an existing profile was made. Apple does not reject
276 + /// the request as a duplicate when an equivalent profile already
277 + /// exists; you get a second one, and both stay active.
278 + async fn create_profile(
279 + &self,
280 + attributes: ProfileCreateAttributes,
281 + relationships: ProfileCreateRelationships,
282 + ) -> Result<Profile>;
283 +
284 + /// `DELETE /v1/profiles/{id}`.
285 + ///
286 + /// Narrower than revoking a certificate: it invalidates only this
287 + /// profile, and any build already signed with it keeps working. New
288 + /// signing that referenced it fails until a replacement exists.
289 + async fn delete_profile(&self, id: &str) -> Result<()>;
290 +}
291 +
292 +#[async_trait]
293 +impl ProfilesApi for Client {
294 + async fn list_profiles(
295 + &self,
296 + filter_name: Option<&str>,
297 + filter_type: Option<ProfileType>,
298 + ) -> Result<Vec<Profile>> {
299 + let mut query = Vec::new();
300 + if let Some(name) = filter_name {
301 + query.push(("filter[name]", name));
302 + }
303 + if let Some(profile_type) = filter_type {
304 + query.push(("filter[profileType]", profile_type.as_api_str()));
305 + }
306 + // Apple's default page size is 20, which a team carrying a profile
307 + // per app per platform will exceed.
308 + query.push(("limit", "200"));
309 +
310 + let doc: ListDocument<ProfileAttributes> = self
311 + .request(Method::GET, "/v1/profiles", &query, None::<&()>)
312 + .await?;
313 + Ok(doc.data)
314 + }
315 +
316 + async fn get_profile_content(&self, id: &str) -> Result<Profile> {
317 + let query = [(
318 + "fields[profiles]",
319 + "name,platform,profileType,profileState,uuid,createdDate,expirationDate,profileContent",
320 + )];
321 + let doc: Document<ProfileAttributes> = self
322 + .request(
323 + Method::GET,
324 + &format!("/v1/profiles/{id}"),
325 + &query,
326 + None::<&()>,
327 + )
328 + .await?;
329 + Ok(doc.data)
330 + }
331 +
332 + async fn create_profile(
333 + &self,
334 + attributes: ProfileCreateAttributes,
335 + relationships: ProfileCreateRelationships,
336 + ) -> Result<Profile> {
337 + let body = CreateBody {
338 + data: CreateData {
339 + resource_type: RESOURCE_TYPE,
340 + attributes,
341 + relationships: Some(relationships),
342 + },
343 + };
344 + let doc: Document<ProfileAttributes> = self
345 + .request(Method::POST, "/v1/profiles", &[], Some(&body))
346 + .await?;
347 + Ok(doc.data)
348 + }
349 +
350 + async fn delete_profile(&self, id: &str) -> Result<()> {
351 + self.request_no_content(
352 + Method::DELETE,
353 + &format!("/v1/profiles/{id}"),
354 + &[],
355 + None::<&()>,
356 + )
357 + .await
358 + }
359 +}
360 +
361 +#[cfg(test)]
362 +mod tests {
363 + use super::*;
364 +
365 + #[test]
366 + fn profile_type_round_trips_through_the_api_wire_format() {
367 + // The serde rename and as_api_str() are two separate spellings of
368 + // the same contract, and a filter built from the wrong one fails
369 + // silently by matching nothing.
370 + for profile_type in [
371 + ProfileType::IosAppDevelopment,
372 + ProfileType::IosAppStore,
373 + ProfileType::IosAppAdHoc,
374 + ProfileType::IosAppInHouse,
375 + ProfileType::MacAppDevelopment,
376 + ProfileType::MacAppStore,
377 + ProfileType::MacAppDirect,
378 + ProfileType::TvOsAppDevelopment,
379 + ProfileType::TvOsAppStore,
380 + ProfileType::TvOsAppAdHoc,
381 + ProfileType::TvOsAppInHouse,
382 + ProfileType::MacCatalystAppDevelopment,
383 + ProfileType::MacCatalystAppStore,
384 + ProfileType::MacCatalystAppDirect,
385 + ] {
386 + let json = serde_json::to_string(&profile_type).expect("serializes");
387 + let unquoted = json.trim_matches('"');
388 + assert_eq!(
389 + unquoted,
390 + profile_type.as_api_str(),
391 + "serde and as_api_str disagree for {profile_type:?}"
392 + );
393 +
394 + let parsed: ProfileType = serde_json::from_str(&json).expect("deserializes");
395 + assert_eq!(parsed, profile_type);
396 + }
397 + }
398 +
399 + #[test]
400 + fn profile_state_round_trips_through_the_api_wire_format() {
401 + for state in [ProfileState::Active, ProfileState::Invalid] {
402 + let json = serde_json::to_string(&state).expect("serializes");
403 + assert_eq!(json.trim_matches('"'), state.as_api_str());
404 + let parsed: ProfileState = serde_json::from_str(&json).expect("deserializes");
405 + assert_eq!(parsed, state);
406 + }
407 + }
408 +
409 + #[test]
410 + fn store_profiles_omit_devices_entirely() {
411 + // An empty `devices` array is not the same as no `devices` key:
412 + // Apple rejects the former on a store profile. Serializing has to
413 + // drop the field, not send `[]`.
414 + let relationships = ProfileCreateRelationships::new(
415 + ProfileType::MacAppStore,
416 + "P59W5V5953",
417 + &["CERT1".to_string()],
418 + &["DEVICE1".to_string()],
419 + );
420 + let json = serde_json::to_value(&relationships).expect("serializes");
421 + assert!(
422 + json.get("devices").is_none(),
423 + "a store profile must not carry devices: {json}"
424 + );
425 + assert_eq!(json["bundleId"]["data"]["type"], "bundleIds");
426 + assert_eq!(json["certificates"]["data"][0]["type"], "certificates");
427 + }
428 +
429 + #[test]
430 + fn development_profiles_keep_their_devices() {
431 + let relationships = ProfileCreateRelationships::new(
432 + ProfileType::IosAppDevelopment,
433 + "P59W5V5953",
434 + &["CERT1".to_string()],
435 + &["DEVICE1".to_string(), "DEVICE2".to_string()],
436 + );
437 + let json = serde_json::to_value(&relationships).expect("serializes");
438 + assert_eq!(json["devices"]["data"].as_array().expect("array").len(), 2);
439 + assert_eq!(json["devices"]["data"][0]["type"], "devices");
440 + }
441 +
442 + #[test]
443 + fn a_development_profile_with_no_devices_omits_the_key() {
444 + // Rather than sending `{"data": []}`, which Apple reads as an
445 + // explicit empty set and rejects.
446 + let relationships = ProfileCreateRelationships::new(
447 + ProfileType::IosAppDevelopment,
448 + "P59W5V5953",
449 + &["CERT1".to_string()],
450 + &[],
451 + );
452 + let json = serde_json::to_value(&relationships).expect("serializes");
453 + assert!(json.get("devices").is_none());
454 + }
455 +}