use std::{
collections::{BTreeMap, BTreeSet},
error::Error,
fmt,
};
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::secret::SecretReference;
const MAX_LABEL_BYTES: usize = 128;
const MAX_ENTITLEMENT_KEY_BYTES: usize = 255;
const MAX_ENTITLEMENT_STRING_BYTES: usize = 16 * 1024;
const MAX_ENTITLEMENT_COLLECTION_ITEMS: usize = 1_024;
const MAX_ENTITLEMENT_DEPTH: usize = 16;
const SIGNING_TARGET_GRAPH_SHA256_DOMAIN: &[u8] = b"rustferry.signing-target-graph.sha256.v1\0";
#[derive(
Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
)]
#[serde(rename_all = "kebab-case")]
pub enum SigningMode {
UnsignedCompileOnly,
Development,
ManualDevelopment,
PersonalTeam,
AdHoc,
AppStore,
}
impl SigningMode {
pub fn is_signed(self) -> bool {
self != Self::UnsignedCompileOnly
}
pub fn requires_device(self) -> bool {
matches!(
self,
Self::Development | Self::ManualDevelopment | Self::PersonalTeam | Self::AdHoc
)
}
fn requires_identity_reference(self) -> bool {
matches!(
self,
Self::Development | Self::ManualDevelopment | Self::AdHoc | Self::AppStore
)
}
}
#[derive(
Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
)]
#[serde(rename_all = "snake_case")]
pub enum SigningStatus {
Unsigned,
CertificateValidated,
ProfileValidated,
NestedCodeSigned,
ApplicationSigned,
IpaExported,
ArtifactValidated,
Invalid,
}
impl SigningStatus {
pub fn advance(self, next: Self) -> Result<Self, SigningValidationError> {
let valid = matches!(
(self, next),
(Self::Unsigned, Self::CertificateValidated)
| (Self::CertificateValidated, Self::ProfileValidated)
| (Self::ProfileValidated, Self::NestedCodeSigned)
| (Self::NestedCodeSigned, Self::ApplicationSigned)
| (Self::ApplicationSigned, Self::IpaExported)
| (Self::IpaExported, Self::ArtifactValidated)
);
if valid || next == Self::Invalid {
Ok(next)
} else {
Err(SigningValidationError::InvalidStatusTransition {
from: self,
to: next,
})
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SigningReference {
pub identity: SigningIdentity,
pub password: Option<SecretReference>,
}
#[derive(Clone, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)]
#[schemars(transparent)]
pub struct BundleIdentifier(String);
impl BundleIdentifier {
pub fn new(value: impl Into<String>) -> Result<Self, SigningValidationError> {
let value = value.into();
if value.len() > 255
|| value.split('.').count() < 2
|| value.split('.').any(|segment| {
segment.is_empty()
|| !segment
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|| !segment.as_bytes()[0].is_ascii_alphanumeric()
|| !segment.as_bytes()[segment.len() - 1].is_ascii_alphanumeric()
})
{
return Err(SigningValidationError::InvalidBundleIdentifier);
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl<'de> Deserialize<'de> for BundleIdentifier {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(value).map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize)]
pub struct DevelopmentTeam {
id: String,
display_name: Option<String>,
}
impl DevelopmentTeam {
pub fn new(
id: impl Into<String>,
display_name: Option<String>,
) -> Result<Self, SigningValidationError> {
let id = id.into();
if id.len() != 10
|| !id
.bytes()
.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
{
return Err(SigningValidationError::InvalidTeamIdentifier);
}
if display_name
.as_deref()
.is_some_and(|name| !is_safe_label(name))
{
return Err(SigningValidationError::InvalidTeamIdentifier);
}
Ok(Self { id, display_name })
}
pub fn id(&self) -> &str {
&self.id
}
pub fn display_name(&self) -> Option<&str> {
self.display_name.as_deref()
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct UncheckedDevelopmentTeam {
id: String,
display_name: Option<String>,
}
impl<'de> Deserialize<'de> for DevelopmentTeam {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let unchecked = UncheckedDevelopmentTeam::deserialize(deserializer)?;
Self::new(unchecked.id, unchecked.display_name).map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize)]
pub struct DevicePlan {
udid_sha256: String,
display_name: Option<String>,
}
impl DevicePlan {
pub fn new(
udid: impl Into<String>,
display_name: Option<String>,
) -> Result<Self, SigningValidationError> {
let udid = udid.into();
validate_raw_udid(&udid)?;
Self::from_sha256(sha256_lowercase(udid.as_bytes()), display_name)
}
pub fn from_sha256(
udid_sha256: impl Into<String>,
display_name: Option<String>,
) -> Result<Self, SigningValidationError> {
let udid_sha256 = udid_sha256.into();
if !is_lowercase_sha256(&udid_sha256)
|| display_name
.as_deref()
.is_some_and(|name| !is_safe_label(name))
{
return Err(SigningValidationError::InvalidDeviceIdentifier);
}
Ok(Self {
udid_sha256,
display_name,
})
}
pub fn udid_sha256(&self) -> &str {
&self.udid_sha256
}
pub fn matches_udid(&self, udid: &str) -> Result<bool, SigningValidationError> {
validate_raw_udid(udid)?;
Ok(self.udid_sha256 == sha256_lowercase(udid.as_bytes()))
}
pub fn display_name(&self) -> Option<&str> {
self.display_name.as_deref()
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct UncheckedDevicePlan {
udid_sha256: String,
display_name: Option<String>,
}
impl<'de> Deserialize<'de> for DevicePlan {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let unchecked = UncheckedDevicePlan::deserialize(deserializer)?;
Self::from_sha256(unchecked.udid_sha256, unchecked.display_name)
.map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct DevelopmentTeamPlan {
pub expected: DevelopmentTeam,
}
pub type TeamPlan = DevelopmentTeamPlan;
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SigningPrivateKeyReference {
pub reference: SecretReference,
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SigningCertificate {
pub common_name: String,
pub sha256_fingerprint: String,
pub team: DevelopmentTeam,
pub expires_at_unix_seconds: u64,
}
impl SigningCertificate {
pub fn validate(&self) -> Result<(), SigningValidationError> {
if !is_safe_label(&self.common_name) {
return Err(SigningValidationError::InvalidCertificateName);
}
validate_fingerprint(&self.sha256_fingerprint)
}
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SigningIdentity {
pub certificate: SigningCertificate,
pub private_key: SigningPrivateKeyReference,
}
#[derive(
Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
)]
#[serde(rename_all = "kebab-case")]
pub enum ProvisioningProfileType {
Development,
AdHoc,
AppStore,
}
#[derive(
Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
)]
#[serde(rename_all = "snake_case")]
pub enum ProvisioningPlatform {
Ios,
}
#[derive(Clone, Debug, Default, Eq, JsonSchema, PartialEq, Serialize)]
#[schemars(transparent)]
pub struct EntitlementSet(BTreeMap<String, Value>);
impl EntitlementSet {
pub fn new(values: BTreeMap<String, Value>) -> Result<Self, SigningValidationError> {
if values.len() > MAX_ENTITLEMENT_COLLECTION_ITEMS {
return Err(SigningValidationError::InvalidEntitlementValue {
key: None,
reason: EntitlementValueError::CollectionTooLarge,
});
}
for (key, value) in &values {
validate_entitlement_key(key)?;
validate_entitlement_value(Some(key), value, 0)?;
}
Ok(Self(values))
}
pub fn values(&self) -> &BTreeMap<String, Value> {
&self.0
}
pub fn get(&self, key: &str) -> Option<&Value> {
self.0.get(key)
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl<'de> Deserialize<'de> for EntitlementSet {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let values = BTreeMap::<String, Value>::deserialize(deserializer)?;
Self::new(values).map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ProvisioningProfile {
pub uuid: String,
pub name: String,
pub team: DevelopmentTeam,
pub application_identifier: String,
pub bundle_identifier_pattern: String,
pub wildcard: bool,
pub created_at_unix_seconds: u64,
pub expires_at_unix_seconds: u64,
pub device_udid_sha256s: Vec<String>,
pub entitlements: EntitlementSet,
pub platforms: BTreeSet<ProvisioningPlatform>,
pub profile_type: ProvisioningProfileType,
pub certificate_fingerprints: Vec<String>,
}
impl ProvisioningProfile {
pub fn validate_metadata(&self) -> Result<(), SigningValidationError> {
if !is_profile_identifier(&self.uuid) {
return Err(SigningValidationError::InvalidProvisioningProfileIdentifier);
}
if !is_safe_label(&self.name) {
return Err(SigningValidationError::InvalidProvisioningProfileName);
}
if self.created_at_unix_seconds > self.expires_at_unix_seconds {
return Err(SigningValidationError::InvalidProvisioningProfileDates);
}
let bundle_pattern_valid = if self.wildcard {
self.bundle_identifier_pattern == "*"
|| self
.bundle_identifier_pattern
.strip_suffix(".*")
.is_some_and(|prefix| BundleIdentifier::new(prefix).is_ok())
} else {
BundleIdentifier::new(self.bundle_identifier_pattern.clone()).is_ok()
};
if !bundle_pattern_valid {
return Err(SigningValidationError::ProfileBundleIdentifierMismatch);
}
let expected_application_identifier =
format!("{}.{}", self.team.id(), self.bundle_identifier_pattern);
if self.application_identifier != expected_application_identifier {
return Err(SigningValidationError::ApplicationIdentifierMismatch);
}
if !self.platforms.contains(&ProvisioningPlatform::Ios) {
return Err(SigningValidationError::ProfilePlatformMismatch);
}
if self.certificate_fingerprints.is_empty() {
return Err(SigningValidationError::ProfileCertificateMismatch);
}
for fingerprint in &self.certificate_fingerprints {
validate_fingerprint(fingerprint)?;
}
let mut devices = BTreeSet::new();
for udid_sha256 in &self.device_udid_sha256s {
DevicePlan::from_sha256(udid_sha256.clone(), None)?;
if !devices.insert(udid_sha256) {
return Err(SigningValidationError::InvalidDeviceIdentifier);
}
}
match self.profile_type {
ProvisioningProfileType::Development | ProvisioningProfileType::AdHoc
if self.device_udid_sha256s.is_empty() =>
{
return Err(SigningValidationError::DeviceNotProvisioned);
}
ProvisioningProfileType::AppStore if !self.device_udid_sha256s.is_empty() => {
return Err(SigningValidationError::ProfileTypeMismatch);
}
ProvisioningProfileType::Development
| ProvisioningProfileType::AdHoc
| ProvisioningProfileType::AppStore => {}
}
Ok(())
}
pub fn validate_metadata_at(
&self,
now_unix_seconds: u64,
) -> Result<(), SigningValidationError> {
self.validate_metadata()?;
if self.created_at_unix_seconds > now_unix_seconds {
Err(SigningValidationError::ProfileNotYetValid)
} else if self.expires_at_unix_seconds <= now_unix_seconds {
Err(SigningValidationError::ProfileExpired)
} else {
Ok(())
}
}
}
fn validate_raw_udid(udid: &str) -> Result<(), SigningValidationError> {
let valid = (20..=64).contains(&udid.len())
&& udid
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
&& !udid.starts_with('-')
&& !udid.ends_with('-');
if valid {
Ok(())
} else {
Err(SigningValidationError::InvalidDeviceIdentifier)
}
}
fn is_lowercase_sha256(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn sha256_lowercase(bytes: &[u8]) -> String {
hex::encode(Sha256::digest(bytes))
}
#[derive(
Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
)]
#[serde(rename_all = "snake_case")]
pub enum SigningTargetKind {
DynamicLibrary,
Framework,
Extension,
Application,
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SigningTarget {
pub name: String,
pub bundle_identifier: BundleIdentifier,
pub kind: SigningTargetKind,
}
pub fn canonical_signing_target_graph_sha256(targets: &[SigningTarget]) -> String {
let mut targets = targets.iter().collect::<Vec<_>>();
targets.sort_unstable_by(|left, right| {
left.name
.as_bytes()
.cmp(right.name.as_bytes())
.then_with(|| {
left.bundle_identifier
.as_str()
.as_bytes()
.cmp(right.bundle_identifier.as_str().as_bytes())
})
.then_with(|| {
signing_target_kind_name(left.kind).cmp(signing_target_kind_name(right.kind))
})
});
let mut digest = Sha256::new();
digest.update(SIGNING_TARGET_GRAPH_SHA256_DOMAIN);
update_canonical_length(&mut digest, targets.len());
for target in targets {
update_canonical_field(
&mut digest,
signing_target_kind_name(target.kind).as_bytes(),
);
update_canonical_field(&mut digest, target.name.as_bytes());
update_canonical_field(&mut digest, target.bundle_identifier.as_str().as_bytes());
}
hex::encode(digest.finalize())
}
fn update_canonical_field(digest: &mut Sha256, value: &[u8]) {
update_canonical_length(digest, value.len());
digest.update(value);
}
fn update_canonical_length(digest: &mut Sha256, length: usize) {
digest.update(length.to_string().as_bytes());
digest.update(b":");
}
const fn signing_target_kind_name(kind: SigningTargetKind) -> &'static str {
match kind {
SigningTargetKind::DynamicLibrary => "dynamic_library",
SigningTargetKind::Framework => "framework",
SigningTargetKind::Extension => "extension",
SigningTargetKind::Application => "application",
}
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ProvisioningPlan {
pub target: String,
pub profile: SecretReference,
pub profile_type: ProvisioningProfileType,
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct EntitlementPlan {
pub target: String,
pub required: EntitlementSet,
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SigningPlan {
pub mode: SigningMode,
pub signing: Option<SigningReference>,
pub team: Option<DevelopmentTeamPlan>,
pub device: Option<DevicePlan>,
pub targets: Vec<SigningTarget>,
pub provisioning: Vec<ProvisioningPlan>,
pub entitlements: Vec<EntitlementPlan>,
pub allow_provisioning_updates: bool,
}
impl SigningPlan {
pub fn validate(&self) -> Result<(), SigningValidationErrors> {
let mut errors = Vec::new();
if self.mode == SigningMode::UnsignedCompileOnly {
if self.signing.is_some() || !self.provisioning.is_empty() {
errors.push(SigningValidationError::SigningReferencesForbiddenForUnsigned);
}
if self.allow_provisioning_updates {
errors.push(SigningValidationError::ProvisioningUpdatesForbiddenForUnsigned);
}
} else {
if self.mode.requires_identity_reference() && self.signing.is_none() {
errors.push(SigningValidationError::MissingSigningReference);
}
if let Some(signing) = &self.signing {
if let Err(error) = signing.identity.certificate.validate() {
errors.push(error);
}
if self.team.as_ref().is_some_and(|team| {
team.expected.id() != signing.identity.certificate.team.id()
}) {
errors.push(SigningValidationError::CertificateTeamMismatch);
}
}
if self.mode == SigningMode::ManualDevelopment
&& self
.signing
.as_ref()
.is_some_and(|signing| signing.password.is_none())
{
errors.push(SigningValidationError::MissingSigningPasswordReference);
}
if self.team.is_none() {
errors.push(SigningValidationError::MissingDevelopmentTeam);
}
if self.mode.requires_device() && self.device.is_none() {
errors.push(SigningValidationError::MissingDevice);
}
}
let mut target_names = BTreeSet::new();
let mut bundle_identifiers = BTreeSet::new();
let mut application_targets = 0;
for target in &self.targets {
if !is_safe_identifier(&target.name) {
errors.push(SigningValidationError::InvalidTargetName);
}
if !target_names.insert(target.name.as_str()) {
errors.push(SigningValidationError::DuplicateTarget {
target: target.name.clone(),
});
}
if !bundle_identifiers.insert(target.bundle_identifier.as_str()) {
errors.push(SigningValidationError::DuplicateBundleIdentifier {
bundle_identifier: target.bundle_identifier.as_str().to_owned(),
});
}
if target.kind == SigningTargetKind::Application {
application_targets += 1;
}
}
if application_targets == 0 {
errors.push(SigningValidationError::MissingApplicationTarget);
} else if application_targets > 1 {
errors.push(SigningValidationError::MultipleApplicationTargets);
}
validate_per_target_plans(self, &target_names, &mut errors);
if errors.is_empty() {
Ok(())
} else {
Err(SigningValidationErrors { errors })
}
}
pub fn targets_in_signing_order(&self) -> Vec<&SigningTarget> {
let mut targets: Vec<_> = self.targets.iter().collect();
targets.sort_by_key(|target| target.kind);
targets
}
}
fn validate_per_target_plans(
plan: &SigningPlan,
target_names: &BTreeSet<&str>,
errors: &mut Vec<SigningValidationError>,
) {
let mut profile_targets = BTreeSet::new();
for provisioning in &plan.provisioning {
if !target_names.contains(provisioning.target.as_str()) {
errors.push(SigningValidationError::UnknownProvisioningTarget {
target: provisioning.target.clone(),
});
}
if !profile_targets.insert(provisioning.target.as_str()) {
errors.push(SigningValidationError::AmbiguousProvisioningProfile {
target: provisioning.target.clone(),
});
}
if expected_profile_type(plan.mode)
.is_some_and(|expected| provisioning.profile_type != expected)
{
errors.push(SigningValidationError::ProfileTypeMismatch);
}
}
let mut entitlement_targets = BTreeSet::new();
for entitlements in &plan.entitlements {
if !target_names.contains(entitlements.target.as_str()) {
errors.push(SigningValidationError::UnknownEntitlementTarget {
target: entitlements.target.clone(),
});
}
if !entitlement_targets.insert(entitlements.target.as_str()) {
errors.push(SigningValidationError::DuplicateEntitlementPlan {
target: entitlements.target.clone(),
});
}
}
for target in &plan.targets {
if !plan.mode.is_signed()
|| !matches!(
target.kind,
SigningTargetKind::Application | SigningTargetKind::Extension
)
{
continue;
}
if !profile_targets.contains(target.name.as_str()) {
errors.push(SigningValidationError::MissingProvisioningProfile {
target: target.name.clone(),
});
}
if !entitlement_targets.contains(target.name.as_str()) {
errors.push(SigningValidationError::MissingEntitlementPlan {
target: target.name.clone(),
});
}
}
}
fn expected_profile_type(mode: SigningMode) -> Option<ProvisioningProfileType> {
match mode {
SigningMode::UnsignedCompileOnly => None,
SigningMode::Development | SigningMode::ManualDevelopment | SigningMode::PersonalTeam => {
Some(ProvisioningProfileType::Development)
}
SigningMode::AdHoc => Some(ProvisioningProfileType::AdHoc),
SigningMode::AppStore => Some(ProvisioningProfileType::AppStore),
}
}
#[derive(
Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
)]
#[serde(rename_all = "snake_case")]
pub enum ValidationStatus {
NotChecked,
Validated,
Invalid,
NotApplicable,
}
#[derive(
Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
)]
#[serde(rename_all = "snake_case")]
pub enum ValidationComponent {
Certificate,
Provisioning,
Team,
Device,
Entitlements,
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SigningValidationReport {
pub mode: SigningMode,
pub signing_status: SigningStatus,
pub certificate: ValidationStatus,
pub provisioning: ValidationStatus,
pub team: ValidationStatus,
pub device: ValidationStatus,
pub entitlements: ValidationStatus,
pub errors: Vec<SigningValidationError>,
}
impl SigningValidationReport {
pub fn new(mode: SigningMode) -> Self {
let initial = if mode.is_signed() {
ValidationStatus::NotChecked
} else {
ValidationStatus::NotApplicable
};
Self {
mode,
signing_status: SigningStatus::Unsigned,
certificate: initial,
provisioning: initial,
team: initial,
device: if mode.requires_device() {
initial
} else {
ValidationStatus::NotApplicable
},
entitlements: initial,
errors: Vec::new(),
}
}
pub fn mark_validated(&mut self, component: ValidationComponent) {
*self.component_mut(component) = ValidationStatus::Validated;
}
pub fn record_error(&mut self, component: ValidationComponent, error: SigningValidationError) {
*self.component_mut(component) = ValidationStatus::Invalid;
self.signing_status = SigningStatus::Invalid;
self.errors.push(error);
}
pub fn advance(&mut self, next: SigningStatus) -> Result<(), SigningValidationError> {
self.signing_status = self.signing_status.advance(next)?;
Ok(())
}
pub fn artifact_is_validated(&self) -> bool {
self.signing_status == SigningStatus::ArtifactValidated
&& self.errors.is_empty()
&& [
self.certificate,
self.provisioning,
self.team,
self.entitlements,
]
.iter()
.all(|status| *status == ValidationStatus::Validated)
&& if self.mode.requires_device() {
self.device == ValidationStatus::Validated
} else {
self.device == ValidationStatus::NotApplicable
}
}
fn component_mut(&mut self, component: ValidationComponent) -> &mut ValidationStatus {
match component {
ValidationComponent::Certificate => &mut self.certificate,
ValidationComponent::Provisioning => &mut self.provisioning,
ValidationComponent::Team => &mut self.team,
ValidationComponent::Device => &mut self.device,
ValidationComponent::Entitlements => &mut self.entitlements,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
pub struct SigningValidationErrors {
errors: Vec<SigningValidationError>,
}
impl SigningValidationErrors {
pub fn errors(&self) -> &[SigningValidationError] {
&self.errors
}
pub fn into_errors(self) -> Vec<SigningValidationError> {
self.errors
}
}
impl fmt::Display for SigningValidationErrors {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"signing plan has {} validation error(s)",
self.errors.len()
)
}
}
impl Error for SigningValidationErrors {}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "snake_case", tag = "code")]
pub enum SigningValidationError {
MissingSigningReference,
MissingSigningPasswordReference,
MissingDevelopmentTeam,
MissingDevice,
SigningReferencesForbiddenForUnsigned,
ProvisioningUpdatesForbiddenForUnsigned,
InvalidTeamIdentifier,
InvalidDeviceIdentifier,
InvalidBundleIdentifier,
InvalidTargetName,
InvalidCertificateName,
InvalidCertificateFingerprint,
InvalidProvisioningProfileIdentifier,
InvalidProvisioningProfileName,
InvalidProvisioningProfileDates,
InvalidEntitlementKey {
index: Option<usize>,
},
InvalidEntitlementValue {
key: Option<String>,
reason: EntitlementValueError,
},
MissingApplicationTarget,
MultipleApplicationTargets,
DuplicateTarget {
target: String,
},
DuplicateBundleIdentifier {
bundle_identifier: String,
},
UnknownProvisioningTarget {
target: String,
},
AmbiguousProvisioningProfile {
target: String,
},
MissingProvisioningProfile {
target: String,
},
UnknownEntitlementTarget {
target: String,
},
DuplicateEntitlementPlan {
target: String,
},
MissingEntitlementPlan {
target: String,
},
CertificateExpired,
CertificateTypeMismatch,
CertificatePrivateKeyMismatch,
CertificateTeamMismatch,
ProfileExpired,
ProfileNotYetValid,
ProfilePlatformMismatch,
ProfileTypeMismatch,
ProfileTeamMismatch,
ProfileCertificateMismatch,
ProfileBundleIdentifierMismatch,
ApplicationIdentifierMismatch,
DeviceNotProvisioned,
ExtensionProfileRequired {
target: String,
},
MissingEntitlement {
target: String,
key: String,
},
EntitlementMismatch {
target: String,
key: String,
},
AppGroupMismatch,
GetTaskAllowMismatch,
InvalidStatusTransition {
from: SigningStatus,
to: SigningStatus,
},
}
impl fmt::Display for SigningValidationError {
#[allow(clippy::too_many_lines)]
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingSigningReference => formatter.write_str("signing reference is required"),
Self::MissingSigningPasswordReference => {
formatter.write_str("signing password reference is required")
}
Self::MissingDevelopmentTeam => formatter.write_str("development team is required"),
Self::MissingDevice => formatter.write_str("registered device is required"),
Self::SigningReferencesForbiddenForUnsigned => {
formatter.write_str("unsigned mode cannot carry signing references")
}
Self::ProvisioningUpdatesForbiddenForUnsigned => {
formatter.write_str("unsigned mode cannot update provisioning")
}
Self::InvalidTeamIdentifier => formatter.write_str("development team is malformed"),
Self::InvalidDeviceIdentifier => formatter.write_str("device identifier is malformed"),
Self::InvalidBundleIdentifier => formatter.write_str("bundle identifier is malformed"),
Self::InvalidTargetName => formatter.write_str("target name is malformed"),
Self::InvalidCertificateName => formatter.write_str("certificate name is malformed"),
Self::InvalidCertificateFingerprint => {
formatter.write_str("certificate fingerprint is malformed")
}
Self::InvalidProvisioningProfileIdentifier => {
formatter.write_str("provisioning profile identifier is malformed")
}
Self::InvalidProvisioningProfileName => {
formatter.write_str("provisioning profile name is malformed")
}
Self::InvalidProvisioningProfileDates => {
formatter.write_str("provisioning profile dates are inconsistent")
}
Self::InvalidEntitlementKey { .. } => {
formatter.write_str("entitlement key is malformed")
}
Self::InvalidEntitlementValue { reason, .. } => {
write!(formatter, "entitlement value is invalid: {reason}")
}
Self::MissingApplicationTarget => formatter.write_str("application target is missing"),
Self::MultipleApplicationTargets => {
formatter.write_str("multiple application targets were supplied")
}
Self::DuplicateTarget { target } => write!(formatter, "duplicate target `{target}`"),
Self::DuplicateBundleIdentifier { bundle_identifier } => {
write!(
formatter,
"duplicate bundle identifier `{bundle_identifier}`"
)
}
Self::UnknownProvisioningTarget { target } => {
write!(formatter, "unknown provisioning target `{target}`")
}
Self::AmbiguousProvisioningProfile { target } => {
write!(formatter, "ambiguous provisioning profile for `{target}`")
}
Self::MissingProvisioningProfile { target } => {
write!(formatter, "provisioning profile is missing for `{target}`")
}
Self::UnknownEntitlementTarget { target } => {
write!(formatter, "unknown entitlement target `{target}`")
}
Self::DuplicateEntitlementPlan { target } => {
write!(formatter, "duplicate entitlement plan for `{target}`")
}
Self::MissingEntitlementPlan { target } => {
write!(formatter, "entitlement plan is missing for `{target}`")
}
Self::CertificateExpired => formatter.write_str("certificate has expired"),
Self::CertificateTypeMismatch => {
formatter.write_str("certificate type does not match signing mode")
}
Self::CertificatePrivateKeyMismatch => {
formatter.write_str("certificate does not match private key")
}
Self::CertificateTeamMismatch => formatter.write_str("certificate team does not match"),
Self::ProfileExpired => formatter.write_str("provisioning profile has expired"),
Self::ProfileNotYetValid => {
formatter.write_str("provisioning profile is not yet valid")
}
Self::ProfilePlatformMismatch => {
formatter.write_str("provisioning profile is not for iOS devices")
}
Self::ProfileTypeMismatch => {
formatter.write_str("provisioning profile type does not match")
}
Self::ProfileTeamMismatch => formatter.write_str("profile team does not match"),
Self::ProfileCertificateMismatch => {
formatter.write_str("profile certificate does not match")
}
Self::ProfileBundleIdentifierMismatch => {
formatter.write_str("profile bundle identifier does not match")
}
Self::ApplicationIdentifierMismatch => {
formatter.write_str("profile application identifier does not match")
}
Self::DeviceNotProvisioned => {
formatter.write_str("device is absent from provisioning profile")
}
Self::ExtensionProfileRequired { target } => {
write!(
formatter,
"extension `{target}` requires a separate profile"
)
}
Self::MissingEntitlement { target, key } => {
write!(
formatter,
"target `{target}` is missing entitlement `{key}`"
)
}
Self::EntitlementMismatch { target, key } => {
write!(
formatter,
"target `{target}` has incompatible entitlement `{key}`"
)
}
Self::AppGroupMismatch => formatter.write_str("application groups do not match"),
Self::GetTaskAllowMismatch => {
formatter.write_str("get-task-allow does not match signing mode")
}
Self::InvalidStatusTransition { from, to } => {
write!(
formatter,
"invalid signing transition from {from:?} to {to:?}"
)
}
}
}
}
impl Error for SigningValidationError {}
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EntitlementValueError {
Null,
FloatingPoint,
StringTooLong,
CollectionTooLarge,
TooDeep,
}
impl fmt::Display for EntitlementValueError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Null => formatter.write_str("null is unsupported"),
Self::FloatingPoint => formatter.write_str("floating point is unsupported"),
Self::StringTooLong => formatter.write_str("string is too long"),
Self::CollectionTooLarge => formatter.write_str("collection is too large"),
Self::TooDeep => formatter.write_str("nesting is too deep"),
}
}
}
fn validate_entitlement_key(key: &str) -> Result<(), SigningValidationError> {
if key.is_empty() || key.len() > MAX_ENTITLEMENT_KEY_BYTES {
return Err(SigningValidationError::InvalidEntitlementKey { index: None });
}
for (index, byte) in key.bytes().enumerate() {
if !(byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) {
return Err(SigningValidationError::InvalidEntitlementKey { index: Some(index) });
}
}
Ok(())
}
fn validate_entitlement_value(
key: Option<&str>,
value: &Value,
depth: usize,
) -> Result<(), SigningValidationError> {
if depth > MAX_ENTITLEMENT_DEPTH {
return invalid_entitlement_value(key, EntitlementValueError::TooDeep);
}
match value {
Value::Null => invalid_entitlement_value(key, EntitlementValueError::Null),
Value::Number(number) if number.is_i64() || number.is_u64() => Ok(()),
Value::Number(_) => invalid_entitlement_value(key, EntitlementValueError::FloatingPoint),
Value::String(text) if text.len() > MAX_ENTITLEMENT_STRING_BYTES => {
invalid_entitlement_value(key, EntitlementValueError::StringTooLong)
}
Value::Bool(_) | Value::String(_) => Ok(()),
Value::Array(items) => {
if items.len() > MAX_ENTITLEMENT_COLLECTION_ITEMS {
return invalid_entitlement_value(key, EntitlementValueError::CollectionTooLarge);
}
for item in items {
validate_entitlement_value(key, item, depth + 1)?;
}
Ok(())
}
Value::Object(object) => {
if object.len() > MAX_ENTITLEMENT_COLLECTION_ITEMS {
return invalid_entitlement_value(key, EntitlementValueError::CollectionTooLarge);
}
for (nested_key, nested_value) in object {
validate_entitlement_key(nested_key)?;
validate_entitlement_value(Some(nested_key), nested_value, depth + 1)?;
}
Ok(())
}
}
}
fn invalid_entitlement_value(
key: Option<&str>,
reason: EntitlementValueError,
) -> Result<(), SigningValidationError> {
Err(SigningValidationError::InvalidEntitlementValue {
key: key.map(str::to_owned),
reason,
})
}
fn validate_fingerprint(fingerprint: &str) -> Result<(), SigningValidationError> {
if fingerprint.len() == 64 && fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) {
Ok(())
} else {
Err(SigningValidationError::InvalidCertificateFingerprint)
}
}
fn is_safe_label(value: &str) -> bool {
!value.is_empty()
&& value.len() <= MAX_LABEL_BYTES
&& !value.chars().any(char::is_control)
&& value.trim() == value
}
fn is_safe_identifier(value: &str) -> bool {
!value.is_empty()
&& value.len() <= MAX_LABEL_BYTES
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
&& !value.contains("..")
}
fn is_profile_identifier(value: &str) -> bool {
(8..=64).contains(&value.len())
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
&& !value.starts_with('-')
&& !value.ends_with('-')
}
#[cfg(test)]
mod target_graph_digest_tests {
use super::*;
fn target(name: &str, bundle_identifier: &str, kind: SigningTargetKind) -> SigningTarget {
SigningTarget {
name: name.to_owned(),
bundle_identifier: BundleIdentifier::new(bundle_identifier).expect("bundle identifier"),
kind,
}
}
#[test]
fn app_only_target_graph_digest_matches_golden() {
let targets = [target(
"App",
"com.example.app",
SigningTargetKind::Application,
)];
assert_eq!(
canonical_signing_target_graph_sha256(&targets),
"144f18f553557a96d6a39105b3aa0928c54bf075a5e32fef077a2eef59b37aff"
);
}
#[test]
fn full_target_graph_digest_is_deterministic_and_binds_every_identity() {
let targets = vec![
target(
"RuntimeBridge",
"com.example.weather.runtime-bridge",
SigningTargetKind::Framework,
),
target(
"WeatherWidget",
"com.example.weather.widget",
SigningTargetKind::Extension,
),
target(
"Weather",
"com.example.weather",
SigningTargetKind::Application,
),
];
let digest = canonical_signing_target_graph_sha256(&targets);
let reordered = targets.iter().rev().cloned().collect::<Vec<_>>();
assert_eq!(
digest,
"112e2935c240c978a4e356b5a0e7b3ffc0068ba249807666e8d1662bd366a691"
);
assert_eq!(canonical_signing_target_graph_sha256(&reordered), digest);
let mut app_drift = targets.clone();
app_drift
.iter_mut()
.find(|target| target.kind == SigningTargetKind::Application)
.expect("application target")
.bundle_identifier =
BundleIdentifier::new("com.example.weather.renamed").expect("app bundle drift");
assert_ne!(canonical_signing_target_graph_sha256(&app_drift), digest);
let mut framework_drift = targets;
framework_drift
.iter_mut()
.find(|target| target.kind == SigningTargetKind::Framework)
.expect("framework target")
.bundle_identifier =
BundleIdentifier::new("com.example.weather.changed").expect("framework bundle drift");
assert_ne!(
canonical_signing_target_graph_sha256(&framework_drift),
digest
);
}
}