1use std::{
2 collections::{BTreeMap, BTreeSet},
3 error::Error,
4 fmt,
5};
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Deserializer, Serialize};
9use serde_json::Value;
10use sha2::{Digest, Sha256};
11
12use crate::secret::SecretReference;
13
14const MAX_LABEL_BYTES: usize = 128;
15const MAX_ENTITLEMENT_KEY_BYTES: usize = 255;
16const MAX_ENTITLEMENT_STRING_BYTES: usize = 16 * 1024;
17const MAX_ENTITLEMENT_COLLECTION_ITEMS: usize = 1_024;
18const MAX_ENTITLEMENT_DEPTH: usize = 16;
19const SIGNING_TARGET_GRAPH_SHA256_DOMAIN: &[u8] = b"rustferry.signing-target-graph.sha256.v1\0";
20
21#[derive(
23 Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
24)]
25#[serde(rename_all = "kebab-case")]
26pub enum SigningMode {
27 UnsignedCompileOnly,
29 Development,
31 ManualDevelopment,
33 PersonalTeam,
35 AdHoc,
37 AppStore,
39}
40
41impl SigningMode {
42 pub fn is_signed(self) -> bool {
44 self != Self::UnsignedCompileOnly
45 }
46
47 pub fn requires_device(self) -> bool {
49 matches!(
50 self,
51 Self::Development | Self::ManualDevelopment | Self::PersonalTeam | Self::AdHoc
52 )
53 }
54
55 fn requires_identity_reference(self) -> bool {
56 matches!(
57 self,
58 Self::Development | Self::ManualDevelopment | Self::AdHoc | Self::AppStore
59 )
60 }
61}
62
63#[derive(
65 Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
66)]
67#[serde(rename_all = "snake_case")]
68pub enum SigningStatus {
69 Unsigned,
71 CertificateValidated,
73 ProfileValidated,
75 NestedCodeSigned,
77 ApplicationSigned,
79 IpaExported,
81 ArtifactValidated,
83 Invalid,
85}
86
87impl SigningStatus {
88 pub fn advance(self, next: Self) -> Result<Self, SigningValidationError> {
95 let valid = matches!(
96 (self, next),
97 (Self::Unsigned, Self::CertificateValidated)
98 | (Self::CertificateValidated, Self::ProfileValidated)
99 | (Self::ProfileValidated, Self::NestedCodeSigned)
100 | (Self::NestedCodeSigned, Self::ApplicationSigned)
101 | (Self::ApplicationSigned, Self::IpaExported)
102 | (Self::IpaExported, Self::ArtifactValidated)
103 );
104 if valid || next == Self::Invalid {
105 Ok(next)
106 } else {
107 Err(SigningValidationError::InvalidStatusTransition {
108 from: self,
109 to: next,
110 })
111 }
112 }
113}
114
115#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
118#[serde(deny_unknown_fields)]
119pub struct SigningReference {
120 pub identity: SigningIdentity,
122 pub password: Option<SecretReference>,
124}
125
126#[derive(Clone, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)]
128#[schemars(transparent)]
129pub struct BundleIdentifier(String);
130
131impl BundleIdentifier {
132 pub fn new(value: impl Into<String>) -> Result<Self, SigningValidationError> {
139 let value = value.into();
140 if value.len() > 255
141 || value.split('.').count() < 2
142 || value.split('.').any(|segment| {
143 segment.is_empty()
144 || !segment
145 .bytes()
146 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
147 || !segment.as_bytes()[0].is_ascii_alphanumeric()
148 || !segment.as_bytes()[segment.len() - 1].is_ascii_alphanumeric()
149 })
150 {
151 return Err(SigningValidationError::InvalidBundleIdentifier);
152 }
153 Ok(Self(value))
154 }
155
156 pub fn as_str(&self) -> &str {
158 &self.0
159 }
160}
161
162impl<'de> Deserialize<'de> for BundleIdentifier {
163 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
164 where
165 D: Deserializer<'de>,
166 {
167 let value = String::deserialize(deserializer)?;
168 Self::new(value).map_err(serde::de::Error::custom)
169 }
170}
171
172#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize)]
174pub struct DevelopmentTeam {
175 id: String,
176 display_name: Option<String>,
177}
178
179impl DevelopmentTeam {
180 pub fn new(
187 id: impl Into<String>,
188 display_name: Option<String>,
189 ) -> Result<Self, SigningValidationError> {
190 let id = id.into();
191 if id.len() != 10
192 || !id
193 .bytes()
194 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
195 {
196 return Err(SigningValidationError::InvalidTeamIdentifier);
197 }
198 if display_name
199 .as_deref()
200 .is_some_and(|name| !is_safe_label(name))
201 {
202 return Err(SigningValidationError::InvalidTeamIdentifier);
203 }
204 Ok(Self { id, display_name })
205 }
206
207 pub fn id(&self) -> &str {
209 &self.id
210 }
211
212 pub fn display_name(&self) -> Option<&str> {
214 self.display_name.as_deref()
215 }
216}
217
218#[derive(Deserialize)]
219#[serde(deny_unknown_fields)]
220struct UncheckedDevelopmentTeam {
221 id: String,
222 display_name: Option<String>,
223}
224
225impl<'de> Deserialize<'de> for DevelopmentTeam {
226 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
227 where
228 D: Deserializer<'de>,
229 {
230 let unchecked = UncheckedDevelopmentTeam::deserialize(deserializer)?;
231 Self::new(unchecked.id, unchecked.display_name).map_err(serde::de::Error::custom)
232 }
233}
234
235#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize)]
237pub struct DevicePlan {
238 udid_sha256: String,
239 display_name: Option<String>,
240}
241
242impl DevicePlan {
243 pub fn new(
250 udid: impl Into<String>,
251 display_name: Option<String>,
252 ) -> Result<Self, SigningValidationError> {
253 let udid = udid.into();
254 validate_raw_udid(&udid)?;
255 Self::from_sha256(sha256_lowercase(udid.as_bytes()), display_name)
256 }
257
258 pub fn from_sha256(
265 udid_sha256: impl Into<String>,
266 display_name: Option<String>,
267 ) -> Result<Self, SigningValidationError> {
268 let udid_sha256 = udid_sha256.into();
269 if !is_lowercase_sha256(&udid_sha256)
270 || display_name
271 .as_deref()
272 .is_some_and(|name| !is_safe_label(name))
273 {
274 return Err(SigningValidationError::InvalidDeviceIdentifier);
275 }
276 Ok(Self {
277 udid_sha256,
278 display_name,
279 })
280 }
281
282 pub fn udid_sha256(&self) -> &str {
284 &self.udid_sha256
285 }
286
287 pub fn matches_udid(&self, udid: &str) -> Result<bool, SigningValidationError> {
294 validate_raw_udid(udid)?;
295 Ok(self.udid_sha256 == sha256_lowercase(udid.as_bytes()))
296 }
297
298 pub fn display_name(&self) -> Option<&str> {
300 self.display_name.as_deref()
301 }
302}
303
304#[derive(Deserialize)]
305#[serde(deny_unknown_fields)]
306struct UncheckedDevicePlan {
307 udid_sha256: String,
308 display_name: Option<String>,
309}
310
311impl<'de> Deserialize<'de> for DevicePlan {
312 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
313 where
314 D: Deserializer<'de>,
315 {
316 let unchecked = UncheckedDevicePlan::deserialize(deserializer)?;
317 Self::from_sha256(unchecked.udid_sha256, unchecked.display_name)
318 .map_err(serde::de::Error::custom)
319 }
320}
321
322#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
324#[serde(deny_unknown_fields)]
325pub struct DevelopmentTeamPlan {
326 pub expected: DevelopmentTeam,
328}
329
330pub type TeamPlan = DevelopmentTeamPlan;
332
333#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
335#[serde(deny_unknown_fields)]
336pub struct SigningPrivateKeyReference {
337 pub reference: SecretReference,
339}
340
341#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
343#[serde(deny_unknown_fields)]
344pub struct SigningCertificate {
345 pub common_name: String,
347 pub sha256_fingerprint: String,
349 pub team: DevelopmentTeam,
351 pub expires_at_unix_seconds: u64,
353}
354
355impl SigningCertificate {
356 pub fn validate(&self) -> Result<(), SigningValidationError> {
363 if !is_safe_label(&self.common_name) {
364 return Err(SigningValidationError::InvalidCertificateName);
365 }
366 validate_fingerprint(&self.sha256_fingerprint)
367 }
368}
369
370#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
372#[serde(deny_unknown_fields)]
373pub struct SigningIdentity {
374 pub certificate: SigningCertificate,
376 pub private_key: SigningPrivateKeyReference,
378}
379
380#[derive(
382 Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
383)]
384#[serde(rename_all = "kebab-case")]
385pub enum ProvisioningProfileType {
386 Development,
388 AdHoc,
390 AppStore,
392}
393
394#[derive(
396 Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
397)]
398#[serde(rename_all = "snake_case")]
399pub enum ProvisioningPlatform {
400 Ios,
402}
403
404#[derive(Clone, Debug, Default, Eq, JsonSchema, PartialEq, Serialize)]
406#[schemars(transparent)]
407pub struct EntitlementSet(BTreeMap<String, Value>);
408
409impl EntitlementSet {
410 pub fn new(values: BTreeMap<String, Value>) -> Result<Self, SigningValidationError> {
416 if values.len() > MAX_ENTITLEMENT_COLLECTION_ITEMS {
417 return Err(SigningValidationError::InvalidEntitlementValue {
418 key: None,
419 reason: EntitlementValueError::CollectionTooLarge,
420 });
421 }
422 for (key, value) in &values {
423 validate_entitlement_key(key)?;
424 validate_entitlement_value(Some(key), value, 0)?;
425 }
426 Ok(Self(values))
427 }
428
429 pub fn values(&self) -> &BTreeMap<String, Value> {
431 &self.0
432 }
433
434 pub fn get(&self, key: &str) -> Option<&Value> {
436 self.0.get(key)
437 }
438
439 pub fn is_empty(&self) -> bool {
441 self.0.is_empty()
442 }
443}
444
445impl<'de> Deserialize<'de> for EntitlementSet {
446 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
447 where
448 D: Deserializer<'de>,
449 {
450 let values = BTreeMap::<String, Value>::deserialize(deserializer)?;
451 Self::new(values).map_err(serde::de::Error::custom)
452 }
453}
454
455#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
457#[serde(deny_unknown_fields)]
458pub struct ProvisioningProfile {
459 pub uuid: String,
461 pub name: String,
463 pub team: DevelopmentTeam,
465 pub application_identifier: String,
467 pub bundle_identifier_pattern: String,
469 pub wildcard: bool,
471 pub created_at_unix_seconds: u64,
473 pub expires_at_unix_seconds: u64,
475 pub device_udid_sha256s: Vec<String>,
478 pub entitlements: EntitlementSet,
480 pub platforms: BTreeSet<ProvisioningPlatform>,
482 pub profile_type: ProvisioningProfileType,
484 pub certificate_fingerprints: Vec<String>,
486}
487
488impl ProvisioningProfile {
489 pub fn validate_metadata(&self) -> Result<(), SigningValidationError> {
495 if !is_profile_identifier(&self.uuid) {
496 return Err(SigningValidationError::InvalidProvisioningProfileIdentifier);
497 }
498 if !is_safe_label(&self.name) {
499 return Err(SigningValidationError::InvalidProvisioningProfileName);
500 }
501 if self.created_at_unix_seconds > self.expires_at_unix_seconds {
502 return Err(SigningValidationError::InvalidProvisioningProfileDates);
503 }
504 let bundle_pattern_valid = if self.wildcard {
505 self.bundle_identifier_pattern == "*"
506 || self
507 .bundle_identifier_pattern
508 .strip_suffix(".*")
509 .is_some_and(|prefix| BundleIdentifier::new(prefix).is_ok())
510 } else {
511 BundleIdentifier::new(self.bundle_identifier_pattern.clone()).is_ok()
512 };
513 if !bundle_pattern_valid {
514 return Err(SigningValidationError::ProfileBundleIdentifierMismatch);
515 }
516 let expected_application_identifier =
517 format!("{}.{}", self.team.id(), self.bundle_identifier_pattern);
518 if self.application_identifier != expected_application_identifier {
519 return Err(SigningValidationError::ApplicationIdentifierMismatch);
520 }
521 if !self.platforms.contains(&ProvisioningPlatform::Ios) {
522 return Err(SigningValidationError::ProfilePlatformMismatch);
523 }
524 if self.certificate_fingerprints.is_empty() {
525 return Err(SigningValidationError::ProfileCertificateMismatch);
526 }
527 for fingerprint in &self.certificate_fingerprints {
528 validate_fingerprint(fingerprint)?;
529 }
530 let mut devices = BTreeSet::new();
531 for udid_sha256 in &self.device_udid_sha256s {
532 DevicePlan::from_sha256(udid_sha256.clone(), None)?;
533 if !devices.insert(udid_sha256) {
534 return Err(SigningValidationError::InvalidDeviceIdentifier);
535 }
536 }
537 match self.profile_type {
538 ProvisioningProfileType::Development | ProvisioningProfileType::AdHoc
539 if self.device_udid_sha256s.is_empty() =>
540 {
541 return Err(SigningValidationError::DeviceNotProvisioned);
542 }
543 ProvisioningProfileType::AppStore if !self.device_udid_sha256s.is_empty() => {
544 return Err(SigningValidationError::ProfileTypeMismatch);
545 }
546 ProvisioningProfileType::Development
547 | ProvisioningProfileType::AdHoc
548 | ProvisioningProfileType::AppStore => {}
549 }
550 Ok(())
551 }
552
553 pub fn validate_metadata_at(
560 &self,
561 now_unix_seconds: u64,
562 ) -> Result<(), SigningValidationError> {
563 self.validate_metadata()?;
564 if self.created_at_unix_seconds > now_unix_seconds {
565 Err(SigningValidationError::ProfileNotYetValid)
566 } else if self.expires_at_unix_seconds <= now_unix_seconds {
567 Err(SigningValidationError::ProfileExpired)
568 } else {
569 Ok(())
570 }
571 }
572}
573
574fn validate_raw_udid(udid: &str) -> Result<(), SigningValidationError> {
575 let valid = (20..=64).contains(&udid.len())
576 && udid
577 .bytes()
578 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
579 && !udid.starts_with('-')
580 && !udid.ends_with('-');
581 if valid {
582 Ok(())
583 } else {
584 Err(SigningValidationError::InvalidDeviceIdentifier)
585 }
586}
587
588fn is_lowercase_sha256(value: &str) -> bool {
589 value.len() == 64
590 && value
591 .bytes()
592 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
593}
594
595fn sha256_lowercase(bytes: &[u8]) -> String {
596 hex::encode(Sha256::digest(bytes))
597}
598
599#[derive(
601 Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
602)]
603#[serde(rename_all = "snake_case")]
604pub enum SigningTargetKind {
605 DynamicLibrary,
607 Framework,
609 Extension,
611 Application,
613}
614
615#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
617#[serde(deny_unknown_fields)]
618pub struct SigningTarget {
619 pub name: String,
621 pub bundle_identifier: BundleIdentifier,
623 pub kind: SigningTargetKind,
625}
626
627pub fn canonical_signing_target_graph_sha256(targets: &[SigningTarget]) -> String {
635 let mut targets = targets.iter().collect::<Vec<_>>();
636 targets.sort_unstable_by(|left, right| {
637 left.name
638 .as_bytes()
639 .cmp(right.name.as_bytes())
640 .then_with(|| {
641 left.bundle_identifier
642 .as_str()
643 .as_bytes()
644 .cmp(right.bundle_identifier.as_str().as_bytes())
645 })
646 .then_with(|| {
647 signing_target_kind_name(left.kind).cmp(signing_target_kind_name(right.kind))
648 })
649 });
650
651 let mut digest = Sha256::new();
652 digest.update(SIGNING_TARGET_GRAPH_SHA256_DOMAIN);
653 update_canonical_length(&mut digest, targets.len());
654 for target in targets {
655 update_canonical_field(
656 &mut digest,
657 signing_target_kind_name(target.kind).as_bytes(),
658 );
659 update_canonical_field(&mut digest, target.name.as_bytes());
660 update_canonical_field(&mut digest, target.bundle_identifier.as_str().as_bytes());
661 }
662 hex::encode(digest.finalize())
663}
664
665fn update_canonical_field(digest: &mut Sha256, value: &[u8]) {
666 update_canonical_length(digest, value.len());
667 digest.update(value);
668}
669
670fn update_canonical_length(digest: &mut Sha256, length: usize) {
671 digest.update(length.to_string().as_bytes());
672 digest.update(b":");
673}
674
675const fn signing_target_kind_name(kind: SigningTargetKind) -> &'static str {
676 match kind {
677 SigningTargetKind::DynamicLibrary => "dynamic_library",
678 SigningTargetKind::Framework => "framework",
679 SigningTargetKind::Extension => "extension",
680 SigningTargetKind::Application => "application",
681 }
682}
683
684#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
686#[serde(deny_unknown_fields)]
687pub struct ProvisioningPlan {
688 pub target: String,
690 pub profile: SecretReference,
692 pub profile_type: ProvisioningProfileType,
694}
695
696#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
698#[serde(deny_unknown_fields)]
699pub struct EntitlementPlan {
700 pub target: String,
702 pub required: EntitlementSet,
704}
705
706#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
708#[serde(deny_unknown_fields)]
709pub struct SigningPlan {
710 pub mode: SigningMode,
712 pub signing: Option<SigningReference>,
714 pub team: Option<DevelopmentTeamPlan>,
716 pub device: Option<DevicePlan>,
718 pub targets: Vec<SigningTarget>,
720 pub provisioning: Vec<ProvisioningPlan>,
722 pub entitlements: Vec<EntitlementPlan>,
724 pub allow_provisioning_updates: bool,
726}
727
728impl SigningPlan {
729 pub fn validate(&self) -> Result<(), SigningValidationErrors> {
735 let mut errors = Vec::new();
736
737 if self.mode == SigningMode::UnsignedCompileOnly {
738 if self.signing.is_some() || !self.provisioning.is_empty() {
739 errors.push(SigningValidationError::SigningReferencesForbiddenForUnsigned);
740 }
741 if self.allow_provisioning_updates {
742 errors.push(SigningValidationError::ProvisioningUpdatesForbiddenForUnsigned);
743 }
744 } else {
745 if self.mode.requires_identity_reference() && self.signing.is_none() {
746 errors.push(SigningValidationError::MissingSigningReference);
747 }
748 if let Some(signing) = &self.signing {
749 if let Err(error) = signing.identity.certificate.validate() {
750 errors.push(error);
751 }
752 if self.team.as_ref().is_some_and(|team| {
753 team.expected.id() != signing.identity.certificate.team.id()
754 }) {
755 errors.push(SigningValidationError::CertificateTeamMismatch);
756 }
757 }
758 if self.mode == SigningMode::ManualDevelopment
759 && self
760 .signing
761 .as_ref()
762 .is_some_and(|signing| signing.password.is_none())
763 {
764 errors.push(SigningValidationError::MissingSigningPasswordReference);
765 }
766 if self.team.is_none() {
767 errors.push(SigningValidationError::MissingDevelopmentTeam);
768 }
769 if self.mode.requires_device() && self.device.is_none() {
770 errors.push(SigningValidationError::MissingDevice);
771 }
772 }
773
774 let mut target_names = BTreeSet::new();
775 let mut bundle_identifiers = BTreeSet::new();
776 let mut application_targets = 0;
777 for target in &self.targets {
778 if !is_safe_identifier(&target.name) {
779 errors.push(SigningValidationError::InvalidTargetName);
780 }
781 if !target_names.insert(target.name.as_str()) {
782 errors.push(SigningValidationError::DuplicateTarget {
783 target: target.name.clone(),
784 });
785 }
786 if !bundle_identifiers.insert(target.bundle_identifier.as_str()) {
787 errors.push(SigningValidationError::DuplicateBundleIdentifier {
788 bundle_identifier: target.bundle_identifier.as_str().to_owned(),
789 });
790 }
791 if target.kind == SigningTargetKind::Application {
792 application_targets += 1;
793 }
794 }
795 if application_targets == 0 {
796 errors.push(SigningValidationError::MissingApplicationTarget);
797 } else if application_targets > 1 {
798 errors.push(SigningValidationError::MultipleApplicationTargets);
799 }
800
801 validate_per_target_plans(self, &target_names, &mut errors);
802
803 if errors.is_empty() {
804 Ok(())
805 } else {
806 Err(SigningValidationErrors { errors })
807 }
808 }
809
810 pub fn targets_in_signing_order(&self) -> Vec<&SigningTarget> {
812 let mut targets: Vec<_> = self.targets.iter().collect();
813 targets.sort_by_key(|target| target.kind);
814 targets
815 }
816}
817
818fn validate_per_target_plans(
819 plan: &SigningPlan,
820 target_names: &BTreeSet<&str>,
821 errors: &mut Vec<SigningValidationError>,
822) {
823 let mut profile_targets = BTreeSet::new();
824 for provisioning in &plan.provisioning {
825 if !target_names.contains(provisioning.target.as_str()) {
826 errors.push(SigningValidationError::UnknownProvisioningTarget {
827 target: provisioning.target.clone(),
828 });
829 }
830 if !profile_targets.insert(provisioning.target.as_str()) {
831 errors.push(SigningValidationError::AmbiguousProvisioningProfile {
832 target: provisioning.target.clone(),
833 });
834 }
835 if expected_profile_type(plan.mode)
836 .is_some_and(|expected| provisioning.profile_type != expected)
837 {
838 errors.push(SigningValidationError::ProfileTypeMismatch);
839 }
840 }
841
842 let mut entitlement_targets = BTreeSet::new();
843 for entitlements in &plan.entitlements {
844 if !target_names.contains(entitlements.target.as_str()) {
845 errors.push(SigningValidationError::UnknownEntitlementTarget {
846 target: entitlements.target.clone(),
847 });
848 }
849 if !entitlement_targets.insert(entitlements.target.as_str()) {
850 errors.push(SigningValidationError::DuplicateEntitlementPlan {
851 target: entitlements.target.clone(),
852 });
853 }
854 }
855
856 for target in &plan.targets {
857 if !plan.mode.is_signed()
858 || !matches!(
859 target.kind,
860 SigningTargetKind::Application | SigningTargetKind::Extension
861 )
862 {
863 continue;
864 }
865 if !profile_targets.contains(target.name.as_str()) {
866 errors.push(SigningValidationError::MissingProvisioningProfile {
867 target: target.name.clone(),
868 });
869 }
870 if !entitlement_targets.contains(target.name.as_str()) {
871 errors.push(SigningValidationError::MissingEntitlementPlan {
872 target: target.name.clone(),
873 });
874 }
875 }
876}
877
878fn expected_profile_type(mode: SigningMode) -> Option<ProvisioningProfileType> {
879 match mode {
880 SigningMode::UnsignedCompileOnly => None,
881 SigningMode::Development | SigningMode::ManualDevelopment | SigningMode::PersonalTeam => {
882 Some(ProvisioningProfileType::Development)
883 }
884 SigningMode::AdHoc => Some(ProvisioningProfileType::AdHoc),
885 SigningMode::AppStore => Some(ProvisioningProfileType::AppStore),
886 }
887}
888
889#[derive(
891 Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
892)]
893#[serde(rename_all = "snake_case")]
894pub enum ValidationStatus {
895 NotChecked,
897 Validated,
899 Invalid,
901 NotApplicable,
903}
904
905#[derive(
907 Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
908)]
909#[serde(rename_all = "snake_case")]
910pub enum ValidationComponent {
911 Certificate,
913 Provisioning,
915 Team,
917 Device,
919 Entitlements,
921}
922
923#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
925#[serde(deny_unknown_fields)]
926pub struct SigningValidationReport {
927 pub mode: SigningMode,
929 pub signing_status: SigningStatus,
931 pub certificate: ValidationStatus,
933 pub provisioning: ValidationStatus,
935 pub team: ValidationStatus,
937 pub device: ValidationStatus,
939 pub entitlements: ValidationStatus,
941 pub errors: Vec<SigningValidationError>,
943}
944
945impl SigningValidationReport {
946 pub fn new(mode: SigningMode) -> Self {
948 let initial = if mode.is_signed() {
949 ValidationStatus::NotChecked
950 } else {
951 ValidationStatus::NotApplicable
952 };
953 Self {
954 mode,
955 signing_status: SigningStatus::Unsigned,
956 certificate: initial,
957 provisioning: initial,
958 team: initial,
959 device: if mode.requires_device() {
960 initial
961 } else {
962 ValidationStatus::NotApplicable
963 },
964 entitlements: initial,
965 errors: Vec::new(),
966 }
967 }
968
969 pub fn mark_validated(&mut self, component: ValidationComponent) {
971 *self.component_mut(component) = ValidationStatus::Validated;
972 }
973
974 pub fn record_error(&mut self, component: ValidationComponent, error: SigningValidationError) {
976 *self.component_mut(component) = ValidationStatus::Invalid;
977 self.signing_status = SigningStatus::Invalid;
978 self.errors.push(error);
979 }
980
981 pub fn advance(&mut self, next: SigningStatus) -> Result<(), SigningValidationError> {
987 self.signing_status = self.signing_status.advance(next)?;
988 Ok(())
989 }
990
991 pub fn artifact_is_validated(&self) -> bool {
993 self.signing_status == SigningStatus::ArtifactValidated
994 && self.errors.is_empty()
995 && [
996 self.certificate,
997 self.provisioning,
998 self.team,
999 self.entitlements,
1000 ]
1001 .iter()
1002 .all(|status| *status == ValidationStatus::Validated)
1003 && if self.mode.requires_device() {
1004 self.device == ValidationStatus::Validated
1005 } else {
1006 self.device == ValidationStatus::NotApplicable
1007 }
1008 }
1009
1010 fn component_mut(&mut self, component: ValidationComponent) -> &mut ValidationStatus {
1011 match component {
1012 ValidationComponent::Certificate => &mut self.certificate,
1013 ValidationComponent::Provisioning => &mut self.provisioning,
1014 ValidationComponent::Team => &mut self.team,
1015 ValidationComponent::Device => &mut self.device,
1016 ValidationComponent::Entitlements => &mut self.entitlements,
1017 }
1018 }
1019}
1020
1021#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
1023pub struct SigningValidationErrors {
1024 errors: Vec<SigningValidationError>,
1025}
1026
1027impl SigningValidationErrors {
1028 pub fn errors(&self) -> &[SigningValidationError] {
1030 &self.errors
1031 }
1032
1033 pub fn into_errors(self) -> Vec<SigningValidationError> {
1035 self.errors
1036 }
1037}
1038
1039impl fmt::Display for SigningValidationErrors {
1040 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1041 write!(
1042 formatter,
1043 "signing plan has {} validation error(s)",
1044 self.errors.len()
1045 )
1046 }
1047}
1048
1049impl Error for SigningValidationErrors {}
1050
1051#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
1053#[serde(rename_all = "snake_case", tag = "code")]
1054pub enum SigningValidationError {
1055 MissingSigningReference,
1057 MissingSigningPasswordReference,
1059 MissingDevelopmentTeam,
1061 MissingDevice,
1063 SigningReferencesForbiddenForUnsigned,
1065 ProvisioningUpdatesForbiddenForUnsigned,
1067 InvalidTeamIdentifier,
1069 InvalidDeviceIdentifier,
1071 InvalidBundleIdentifier,
1073 InvalidTargetName,
1075 InvalidCertificateName,
1077 InvalidCertificateFingerprint,
1079 InvalidProvisioningProfileIdentifier,
1081 InvalidProvisioningProfileName,
1083 InvalidProvisioningProfileDates,
1085 InvalidEntitlementKey {
1087 index: Option<usize>,
1089 },
1090 InvalidEntitlementValue {
1092 key: Option<String>,
1094 reason: EntitlementValueError,
1096 },
1097 MissingApplicationTarget,
1099 MultipleApplicationTargets,
1101 DuplicateTarget {
1103 target: String,
1105 },
1106 DuplicateBundleIdentifier {
1108 bundle_identifier: String,
1110 },
1111 UnknownProvisioningTarget {
1113 target: String,
1115 },
1116 AmbiguousProvisioningProfile {
1118 target: String,
1120 },
1121 MissingProvisioningProfile {
1123 target: String,
1125 },
1126 UnknownEntitlementTarget {
1128 target: String,
1130 },
1131 DuplicateEntitlementPlan {
1133 target: String,
1135 },
1136 MissingEntitlementPlan {
1138 target: String,
1140 },
1141 CertificateExpired,
1143 CertificateTypeMismatch,
1145 CertificatePrivateKeyMismatch,
1147 CertificateTeamMismatch,
1149 ProfileExpired,
1151 ProfileNotYetValid,
1153 ProfilePlatformMismatch,
1155 ProfileTypeMismatch,
1157 ProfileTeamMismatch,
1159 ProfileCertificateMismatch,
1161 ProfileBundleIdentifierMismatch,
1163 ApplicationIdentifierMismatch,
1165 DeviceNotProvisioned,
1167 ExtensionProfileRequired {
1169 target: String,
1171 },
1172 MissingEntitlement {
1174 target: String,
1176 key: String,
1178 },
1179 EntitlementMismatch {
1181 target: String,
1183 key: String,
1185 },
1186 AppGroupMismatch,
1188 GetTaskAllowMismatch,
1190 InvalidStatusTransition {
1192 from: SigningStatus,
1194 to: SigningStatus,
1196 },
1197}
1198
1199impl fmt::Display for SigningValidationError {
1200 #[allow(clippy::too_many_lines)]
1201 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1202 match self {
1203 Self::MissingSigningReference => formatter.write_str("signing reference is required"),
1204 Self::MissingSigningPasswordReference => {
1205 formatter.write_str("signing password reference is required")
1206 }
1207 Self::MissingDevelopmentTeam => formatter.write_str("development team is required"),
1208 Self::MissingDevice => formatter.write_str("registered device is required"),
1209 Self::SigningReferencesForbiddenForUnsigned => {
1210 formatter.write_str("unsigned mode cannot carry signing references")
1211 }
1212 Self::ProvisioningUpdatesForbiddenForUnsigned => {
1213 formatter.write_str("unsigned mode cannot update provisioning")
1214 }
1215 Self::InvalidTeamIdentifier => formatter.write_str("development team is malformed"),
1216 Self::InvalidDeviceIdentifier => formatter.write_str("device identifier is malformed"),
1217 Self::InvalidBundleIdentifier => formatter.write_str("bundle identifier is malformed"),
1218 Self::InvalidTargetName => formatter.write_str("target name is malformed"),
1219 Self::InvalidCertificateName => formatter.write_str("certificate name is malformed"),
1220 Self::InvalidCertificateFingerprint => {
1221 formatter.write_str("certificate fingerprint is malformed")
1222 }
1223 Self::InvalidProvisioningProfileIdentifier => {
1224 formatter.write_str("provisioning profile identifier is malformed")
1225 }
1226 Self::InvalidProvisioningProfileName => {
1227 formatter.write_str("provisioning profile name is malformed")
1228 }
1229 Self::InvalidProvisioningProfileDates => {
1230 formatter.write_str("provisioning profile dates are inconsistent")
1231 }
1232 Self::InvalidEntitlementKey { .. } => {
1233 formatter.write_str("entitlement key is malformed")
1234 }
1235 Self::InvalidEntitlementValue { reason, .. } => {
1236 write!(formatter, "entitlement value is invalid: {reason}")
1237 }
1238 Self::MissingApplicationTarget => formatter.write_str("application target is missing"),
1239 Self::MultipleApplicationTargets => {
1240 formatter.write_str("multiple application targets were supplied")
1241 }
1242 Self::DuplicateTarget { target } => write!(formatter, "duplicate target `{target}`"),
1243 Self::DuplicateBundleIdentifier { bundle_identifier } => {
1244 write!(
1245 formatter,
1246 "duplicate bundle identifier `{bundle_identifier}`"
1247 )
1248 }
1249 Self::UnknownProvisioningTarget { target } => {
1250 write!(formatter, "unknown provisioning target `{target}`")
1251 }
1252 Self::AmbiguousProvisioningProfile { target } => {
1253 write!(formatter, "ambiguous provisioning profile for `{target}`")
1254 }
1255 Self::MissingProvisioningProfile { target } => {
1256 write!(formatter, "provisioning profile is missing for `{target}`")
1257 }
1258 Self::UnknownEntitlementTarget { target } => {
1259 write!(formatter, "unknown entitlement target `{target}`")
1260 }
1261 Self::DuplicateEntitlementPlan { target } => {
1262 write!(formatter, "duplicate entitlement plan for `{target}`")
1263 }
1264 Self::MissingEntitlementPlan { target } => {
1265 write!(formatter, "entitlement plan is missing for `{target}`")
1266 }
1267 Self::CertificateExpired => formatter.write_str("certificate has expired"),
1268 Self::CertificateTypeMismatch => {
1269 formatter.write_str("certificate type does not match signing mode")
1270 }
1271 Self::CertificatePrivateKeyMismatch => {
1272 formatter.write_str("certificate does not match private key")
1273 }
1274 Self::CertificateTeamMismatch => formatter.write_str("certificate team does not match"),
1275 Self::ProfileExpired => formatter.write_str("provisioning profile has expired"),
1276 Self::ProfileNotYetValid => {
1277 formatter.write_str("provisioning profile is not yet valid")
1278 }
1279 Self::ProfilePlatformMismatch => {
1280 formatter.write_str("provisioning profile is not for iOS devices")
1281 }
1282 Self::ProfileTypeMismatch => {
1283 formatter.write_str("provisioning profile type does not match")
1284 }
1285 Self::ProfileTeamMismatch => formatter.write_str("profile team does not match"),
1286 Self::ProfileCertificateMismatch => {
1287 formatter.write_str("profile certificate does not match")
1288 }
1289 Self::ProfileBundleIdentifierMismatch => {
1290 formatter.write_str("profile bundle identifier does not match")
1291 }
1292 Self::ApplicationIdentifierMismatch => {
1293 formatter.write_str("profile application identifier does not match")
1294 }
1295 Self::DeviceNotProvisioned => {
1296 formatter.write_str("device is absent from provisioning profile")
1297 }
1298 Self::ExtensionProfileRequired { target } => {
1299 write!(
1300 formatter,
1301 "extension `{target}` requires a separate profile"
1302 )
1303 }
1304 Self::MissingEntitlement { target, key } => {
1305 write!(
1306 formatter,
1307 "target `{target}` is missing entitlement `{key}`"
1308 )
1309 }
1310 Self::EntitlementMismatch { target, key } => {
1311 write!(
1312 formatter,
1313 "target `{target}` has incompatible entitlement `{key}`"
1314 )
1315 }
1316 Self::AppGroupMismatch => formatter.write_str("application groups do not match"),
1317 Self::GetTaskAllowMismatch => {
1318 formatter.write_str("get-task-allow does not match signing mode")
1319 }
1320 Self::InvalidStatusTransition { from, to } => {
1321 write!(
1322 formatter,
1323 "invalid signing transition from {from:?} to {to:?}"
1324 )
1325 }
1326 }
1327 }
1328}
1329
1330impl Error for SigningValidationError {}
1331
1332#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
1334#[serde(rename_all = "snake_case")]
1335pub enum EntitlementValueError {
1336 Null,
1338 FloatingPoint,
1340 StringTooLong,
1342 CollectionTooLarge,
1344 TooDeep,
1346}
1347
1348impl fmt::Display for EntitlementValueError {
1349 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1350 match self {
1351 Self::Null => formatter.write_str("null is unsupported"),
1352 Self::FloatingPoint => formatter.write_str("floating point is unsupported"),
1353 Self::StringTooLong => formatter.write_str("string is too long"),
1354 Self::CollectionTooLarge => formatter.write_str("collection is too large"),
1355 Self::TooDeep => formatter.write_str("nesting is too deep"),
1356 }
1357 }
1358}
1359
1360fn validate_entitlement_key(key: &str) -> Result<(), SigningValidationError> {
1361 if key.is_empty() || key.len() > MAX_ENTITLEMENT_KEY_BYTES {
1362 return Err(SigningValidationError::InvalidEntitlementKey { index: None });
1363 }
1364 for (index, byte) in key.bytes().enumerate() {
1365 if !(byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) {
1366 return Err(SigningValidationError::InvalidEntitlementKey { index: Some(index) });
1367 }
1368 }
1369 Ok(())
1370}
1371
1372fn validate_entitlement_value(
1373 key: Option<&str>,
1374 value: &Value,
1375 depth: usize,
1376) -> Result<(), SigningValidationError> {
1377 if depth > MAX_ENTITLEMENT_DEPTH {
1378 return invalid_entitlement_value(key, EntitlementValueError::TooDeep);
1379 }
1380 match value {
1381 Value::Null => invalid_entitlement_value(key, EntitlementValueError::Null),
1382 Value::Number(number) if number.is_i64() || number.is_u64() => Ok(()),
1383 Value::Number(_) => invalid_entitlement_value(key, EntitlementValueError::FloatingPoint),
1384 Value::String(text) if text.len() > MAX_ENTITLEMENT_STRING_BYTES => {
1385 invalid_entitlement_value(key, EntitlementValueError::StringTooLong)
1386 }
1387 Value::Bool(_) | Value::String(_) => Ok(()),
1388 Value::Array(items) => {
1389 if items.len() > MAX_ENTITLEMENT_COLLECTION_ITEMS {
1390 return invalid_entitlement_value(key, EntitlementValueError::CollectionTooLarge);
1391 }
1392 for item in items {
1393 validate_entitlement_value(key, item, depth + 1)?;
1394 }
1395 Ok(())
1396 }
1397 Value::Object(object) => {
1398 if object.len() > MAX_ENTITLEMENT_COLLECTION_ITEMS {
1399 return invalid_entitlement_value(key, EntitlementValueError::CollectionTooLarge);
1400 }
1401 for (nested_key, nested_value) in object {
1402 validate_entitlement_key(nested_key)?;
1403 validate_entitlement_value(Some(nested_key), nested_value, depth + 1)?;
1404 }
1405 Ok(())
1406 }
1407 }
1408}
1409
1410fn invalid_entitlement_value(
1411 key: Option<&str>,
1412 reason: EntitlementValueError,
1413) -> Result<(), SigningValidationError> {
1414 Err(SigningValidationError::InvalidEntitlementValue {
1415 key: key.map(str::to_owned),
1416 reason,
1417 })
1418}
1419
1420fn validate_fingerprint(fingerprint: &str) -> Result<(), SigningValidationError> {
1421 if fingerprint.len() == 64 && fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1422 Ok(())
1423 } else {
1424 Err(SigningValidationError::InvalidCertificateFingerprint)
1425 }
1426}
1427
1428fn is_safe_label(value: &str) -> bool {
1429 !value.is_empty()
1430 && value.len() <= MAX_LABEL_BYTES
1431 && !value.chars().any(char::is_control)
1432 && value.trim() == value
1433}
1434
1435fn is_safe_identifier(value: &str) -> bool {
1436 !value.is_empty()
1437 && value.len() <= MAX_LABEL_BYTES
1438 && value
1439 .bytes()
1440 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
1441 && !value.contains("..")
1442}
1443
1444fn is_profile_identifier(value: &str) -> bool {
1445 (8..=64).contains(&value.len())
1446 && value
1447 .bytes()
1448 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
1449 && !value.starts_with('-')
1450 && !value.ends_with('-')
1451}
1452
1453#[cfg(test)]
1454mod target_graph_digest_tests {
1455 use super::*;
1456
1457 fn target(name: &str, bundle_identifier: &str, kind: SigningTargetKind) -> SigningTarget {
1458 SigningTarget {
1459 name: name.to_owned(),
1460 bundle_identifier: BundleIdentifier::new(bundle_identifier).expect("bundle identifier"),
1461 kind,
1462 }
1463 }
1464
1465 #[test]
1466 fn app_only_target_graph_digest_matches_golden() {
1467 let targets = [target(
1468 "App",
1469 "com.example.app",
1470 SigningTargetKind::Application,
1471 )];
1472
1473 assert_eq!(
1474 canonical_signing_target_graph_sha256(&targets),
1475 "144f18f553557a96d6a39105b3aa0928c54bf075a5e32fef077a2eef59b37aff"
1476 );
1477 }
1478
1479 #[test]
1480 fn full_target_graph_digest_is_deterministic_and_binds_every_identity() {
1481 let targets = vec![
1482 target(
1483 "RuntimeBridge",
1484 "com.example.weather.runtime-bridge",
1485 SigningTargetKind::Framework,
1486 ),
1487 target(
1488 "WeatherWidget",
1489 "com.example.weather.widget",
1490 SigningTargetKind::Extension,
1491 ),
1492 target(
1493 "Weather",
1494 "com.example.weather",
1495 SigningTargetKind::Application,
1496 ),
1497 ];
1498 let digest = canonical_signing_target_graph_sha256(&targets);
1499 let reordered = targets.iter().rev().cloned().collect::<Vec<_>>();
1500
1501 assert_eq!(
1502 digest,
1503 "112e2935c240c978a4e356b5a0e7b3ffc0068ba249807666e8d1662bd366a691"
1504 );
1505 assert_eq!(canonical_signing_target_graph_sha256(&reordered), digest);
1506
1507 let mut app_drift = targets.clone();
1508 app_drift
1509 .iter_mut()
1510 .find(|target| target.kind == SigningTargetKind::Application)
1511 .expect("application target")
1512 .bundle_identifier =
1513 BundleIdentifier::new("com.example.weather.renamed").expect("app bundle drift");
1514 assert_ne!(canonical_signing_target_graph_sha256(&app_drift), digest);
1515
1516 let mut framework_drift = targets;
1517 framework_drift
1518 .iter_mut()
1519 .find(|target| target.kind == SigningTargetKind::Framework)
1520 .expect("framework target")
1521 .bundle_identifier =
1522 BundleIdentifier::new("com.example.weather.changed").expect("framework bundle drift");
1523 assert_ne!(
1524 canonical_signing_target_graph_sha256(&framework_drift),
1525 digest
1526 );
1527 }
1528}