12
AppStoreVersionLocalizationCreateAttributes, AppStoreVersionLocalizationFields,
13
};
14
use smbcloud_ascapi::bundle_id::{BundleIdCreateAttributes, BundleIdPlatform};
15
+use smbcloud_ascapi::certificate::{CertificateCreateAttributes, CertificateType};
16
+use smbcloud_ascapi::csr::generate_certificate_request;
17
use smbcloud_ascapi::{ApiKey, Client};
18
use std::path::PathBuf;
19
86
#[command(subcommand)]
87
command: AppScreenshotsCommand,
88
},
89
+ /// Signing certificates: list what the team holds, issue a new one, or
90
+ /// revoke an old one.
91
+ Certificates {
92
+ #[command(subcommand)]
93
+ command: CertificatesCommand,
94
+ },
95
+}
96
+
97
+#[derive(Subcommand)]
98
+enum CertificatesCommand {
99
+ /// List the team's certificates, newest expiry last. Apple returns
100
+ /// expired ones too; `expired` in the output says which.
101
+ List {
102
+ /// Only show one type.
103
+ #[arg(long, value_enum)]
104
+ r#type: Option<CliCertificateType>,
105
+ },
106
+ /// Generate an RSA 2048 key pair, ask Apple to certify it, and write
107
+ /// both halves to disk.
108
+ ///
109
+ /// The private key is written first and is the part that matters:
110
+ /// Apple keeps no copy, so losing it makes the certificate permanently
111
+ /// useless. Back up the whole output directory somewhere encrypted.
112
+ Create {
113
+ #[arg(long, value_enum)]
114
+ r#type: CliCertificateType,
115
+ /// Where to write `<name>.key.pem` and `<name>.cer`.
116
+ #[arg(long, default_value = ".")]
117
+ out_dir: PathBuf,
118
+ /// Subject common name for the CSR. Apple replaces it on the
119
+ /// issued certificate, so this only labels the request itself.
120
+ #[arg(long, default_value = "smbcloud-ascapi")]
121
+ common_name: String,
122
+ },
123
+ /// Revoke a certificate.
124
+ ///
125
+ /// Irreversible, and it breaks every provisioning profile embedding
126
+ /// this certificate for the whole team at once. Use it to free a slot
127
+ /// at Apple's per-type cap, or when a private key is compromised.
128
+ Revoke {
129
+ id: String,
130
+ /// Required. Pass the certificate id again to confirm.
131
+ #[arg(long)]
132
+ confirm: String,
133
+ },
134
+}
135
+
136
+#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)]
137
+enum CliCertificateType {
138
+ /// Apple Development — all platforms.
139
+ Development,
140
+ /// Apple Distribution — App Store and Ad Hoc, all platforms.
141
+ Distribution,
142
+ /// 3rd Party Mac Developer Application — the .app in a Mac App Store build.
143
+ MacAppDistribution,
144
+ /// 3rd Party Mac Developer Installer — the .pkg `productbuild --sign` needs.
145
+ MacInstallerDistribution,
146
+ /// Developer ID Application — distribution outside the App Store.
147
+ DeveloperIdApplication,
148
+}
149
+
150
+impl From<CliCertificateType> for CertificateType {
151
+ fn from(value: CliCertificateType) -> Self {
152
+ match value {
153
+ CliCertificateType::Development => CertificateType::Development,
154
+ CliCertificateType::Distribution => CertificateType::Distribution,
155
+ CliCertificateType::MacAppDistribution => CertificateType::MacAppDistribution,
156
+ CliCertificateType::MacInstallerDistribution => {
157
+ CertificateType::MacInstallerDistribution
158
+ }
159
+ CliCertificateType::DeveloperIdApplication => CertificateType::DeveloperIdApplication,
160
+ }
161
+ }
162
}
163
164
#[derive(Subcommand)]
471
Command::AppScreenshots { command } => {
472
run_app_screenshots(&client, command, cli.dry_run).await
473
}
474
+ Command::Certificates { command } => run_certificates(&client, command, cli.dry_run).await,
475
}
476
}
477
803
}
804
}
805
}
806
+
807
+/// Human-readable summary of a certificate, with the fields that decide
808
+/// whether it is usable pulled to the top. Deliberately omits
809
+/// `certificateContent`: it is large, and printing it invites pasting
810
+/// certificate bodies into places they should not go.
811
+#[derive(serde::Serialize)]
812
+struct CertificateSummary {
813
+ id: String,
814
+ name: Option<String>,
815
+ r#type: Option<String>,
816
+ serial_number: Option<String>,
817
+ expiration_date: Option<String>,
818
+ /// Computed here rather than by Apple, which offers no such filter.
819
+ expired: Option<bool>,
820
+}
821
+
822
+fn summarize(certificate: &smbcloud_ascapi::certificate::Certificate) -> CertificateSummary {
823
+ let expiration_date = certificate.attributes.expiration_date.clone();
824
+ // Lexicographic comparison is sound for ISO-8601 UTC timestamps, which
825
+ // is what Apple returns, and avoids a date-parsing dependency for a
826
+ // field that is only ever displayed.
827
+ let now = now_iso8601();
828
+ let expired = expiration_date
829
+ .as_ref()
830
+ .map(|at| at.as_str() < now.as_str());
831
+
832
+ CertificateSummary {
833
+ id: certificate.id.clone(),
834
+ name: certificate.attributes.name.clone(),
835
+ r#type: certificate
836
+ .attributes
837
+ .certificate_type
838
+ .map(|t| t.as_api_str().to_string()),
839
+ serial_number: certificate.attributes.serial_number.clone(),
840
+ expiration_date,
841
+ expired,
842
+ }
843
+}
844
+
845
+/// Current UTC time as `YYYY-MM-DDTHH:MM:SS`, for comparing against
846
+/// Apple's ISO-8601 expiry strings without pulling in a date library.
847
+fn now_iso8601() -> String {
848
+ let secs = std::time::SystemTime::now()
849
+ .duration_since(std::time::UNIX_EPOCH)
850
+ .unwrap_or_default()
851
+ .as_secs() as i64;
852
+
853
+ // Days since the epoch to a civil date, per Howard Hinnant's
854
+ // days_from_civil inverse. Shifting the era to March makes the leap
855
+ // day the last day of the year, which removes every special case.
856
+ let days = secs.div_euclid(86_400);
857
+ let secs_of_day = secs.rem_euclid(86_400);
858
+ let z = days + 719_468;
859
+ let era = z.div_euclid(146_097);
860
+ let doe = z.rem_euclid(146_097);
861
+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
862
+ let y = yoe + era * 400;
863
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
864
+ let mp = (5 * doy + 2) / 153;
865
+ let d = doy - (153 * mp + 2) / 5 + 1;
866
+ let m = if mp < 10 { mp + 3 } else { mp - 9 };
867
+ let y = if m <= 2 { y + 1 } else { y };
868
+
869
+ format!(
870
+ "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
871
+ y,
872
+ m,
873
+ d,
874
+ secs_of_day / 3600,
875
+ (secs_of_day % 3600) / 60,
876
+ secs_of_day % 60
877
+ )
878
+}
879
+
880
+async fn run_certificates(
881
+ client: &Client,
882
+ command: CertificatesCommand,
883
+ dry_run: bool,
884
+) -> Result<()> {
885
+ match command {
886
+ CertificatesCommand::List { r#type } => {
887
+ let mut certificates = client.list_certificates(r#type.map(Into::into)).await?;
888
+ certificates.sort_by(|a, b| {
889
+ a.attributes
890
+ .expiration_date
891
+ .cmp(&b.attributes.expiration_date)
892
+ });
893
+ let summaries: Vec<_> = certificates.iter().map(summarize).collect();
894
+ print_json(&summaries)
895
+ }
896
+
897
+ CertificatesCommand::Create {
898
+ r#type,
899
+ out_dir,
900
+ common_name,
901
+ } => {
902
+ let certificate_type: CertificateType = r#type.into();
903
+
904
+ if dry_run {
905
+ // Stop before generating a key: an unused RSA key pair on
906
+ // disk is a small liability, and a dry run should have no
907
+ // side effects at all.
908
+ println!("# dry run — would generate an RSA 2048 key pair and request:");
909
+ print_json(&serde_json::json!({
910
+ "certificateType": certificate_type.as_api_str(),
911
+ "commonName": common_name,
912
+ "outDir": out_dir,
913
+ }))?;
914
+ return Ok(());
915
+ }
916
+
917
+ let request = generate_certificate_request(&common_name)
918
+ .context("generating the RSA 2048 key pair and CSR")?;
919
+
920
+ std::fs::create_dir_all(&out_dir)
921
+ .with_context(|| format!("creating {}", out_dir.display()))?;
922
+
923
+ // Write the private key BEFORE calling Apple. If the request
924
+ // fails we are left with an unused key, which is harmless; the
925
+ // other order risks Apple issuing a certificate whose key we
926
+ // then fail to persist, which is unrecoverable.
927
+ let stem = certificate_type.as_api_str().to_lowercase();
928
+ let key_path = out_dir.join(format!("{stem}.key.pem"));
929
+ write_private_key(&key_path, request.private_key_pem())
930
+ .with_context(|| format!("writing the private key to {}", key_path.display()))?;
931
+
932
+ let attributes = CertificateCreateAttributes {
933
+ csr_content: request.csr_pem().to_string(),
934
+ certificate_type,
935
+ };
936
+ let certificate = client.create_certificate(attributes).await?;
937
+
938
+ let cer_path = out_dir.join(format!("{stem}.cer"));
939
+ if let Some(content) = certificate.attributes.certificate_content.as_deref() {
940
+ let der =
941
+ base64_decode(content).context("decoding the certificate Apple returned")?;
942
+ std::fs::write(&cer_path, der)
943
+ .with_context(|| format!("writing {}", cer_path.display()))?;
944
+ }
945
+
946
+ print_json(&serde_json::json!({
947
+ "certificate": summarize(&certificate),
948
+ "privateKey": key_path,
949
+ "certificatePath": cer_path,
950
+ "nextSteps": [
951
+ format!(
952
+ "openssl pkcs12 -export -inkey {} -in {} -out {}.p12",
953
+ key_path.display(), cer_path.display(), stem
954
+ ),
955
+ format!("security import {stem}.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign"),
956
+ "security find-identity -v -p codesigning".to_string(),
957
+ ],
958
+ }))
959
+ }
960
+
961
+ CertificatesCommand::Revoke { id, confirm } => {
962
+ if confirm != id {
963
+ anyhow::bail!(
964
+ "refusing to revoke: pass --confirm {id} to acknowledge that this breaks \
965
+ every provisioning profile embedding this certificate, for the whole team"
966
+ );
967
+ }
968
+ if dry_run {
969
+ println!("# dry run — would revoke certificate {id}");
970
+ return Ok(());
971
+ }
972
+ client.revoke_certificate(&id).await?;
973
+ print_json(&serde_json::json!({ "revoked": id }))
974
+ }
975
+ }
976
+}
977
+
978
+/// Write a private key with owner-only permissions, set at creation time
979
+/// rather than chmod'ed afterwards so the key is never briefly readable.
980
+fn write_private_key(path: &std::path::Path, pem: &str) -> std::io::Result<()> {
981
+ use std::io::Write;
982
+
983
+ let mut options = std::fs::OpenOptions::new();
984
+ options.write(true).create(true).truncate(true);
985
+ #[cfg(unix)]
986
+ {
987
+ use std::os::unix::fs::OpenOptionsExt;
988
+ options.mode(0o600);
989
+ }
990
+ let mut file = options.open(path)?;
991
+ file.write_all(pem.as_bytes())?;
992
+ file.sync_all()
993
+}
994
+
995
+fn base64_decode(input: &str) -> Result<Vec<u8>> {
996
+ use base64::Engine;
997
+ Ok(base64::engine::general_purpose::STANDARD.decode(input.trim())?)
998
+}