| 1 | //! Certificate operations shared by both front ends. |
| 2 | |
| 3 | use serde::Serialize; |
| 4 | use smbcloud_ascapi_core::Client; |
| 5 | use smbcloud_ascapi_signing::certificate::CertificateType; |
| 6 | use smbcloud_ascapi_signing::prelude::*; |
| 7 | |
| 8 | use crate::time::{is_expired, now_iso8601}; |
| 9 | |
| 10 | /// Map a CLI/MCP certificate-type string onto the API enum. |
| 11 | pub fn certificate_type_from_str(value: &str) -> Result<CertificateType, String> { |
| 12 | use CertificateType; |
| 13 | match value { |
| 14 | "development" => Ok(CertificateType::Development), |
| 15 | "distribution" => Ok(CertificateType::Distribution), |
| 16 | "mac_app_distribution" => Ok(CertificateType::MacAppDistribution), |
| 17 | "mac_installer_distribution" => Ok(CertificateType::MacInstallerDistribution), |
| 18 | "developer_id_application" => Ok(CertificateType::DeveloperIdApplication), |
| 19 | other => Err(format!( |
| 20 | "unknown certificate type {other:?}; expected one of development, distribution, \ |
| 21 | mac_app_distribution, mac_installer_distribution, developer_id_application" |
| 22 | )), |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | /// Defines the MCP tool implementations on `$server`, with each tool's |
| 27 | /// wire name supplied by the embedder. |
| 28 | /// |
| 29 | /// Names are parameters rather than literals so a host that re-exposes |
| 30 | /// this contract can namespace them without forking the bodies, exactly as |
| 31 | /// `xcrs_mcp_tools!` allows in `smbcloud-cli`. |
| 32 | /// A certificate as reported to callers. |
| 33 | /// |
| 34 | /// Note what is missing: `certificateContent`. It is large, it is never |
| 35 | /// needed to decide what to do next, and keeping it out means a tool |
| 36 | /// result can never carry a certificate body into a model's context. |
| 37 | #[derive(Debug, Clone, Serialize, schemars::JsonSchema)] |
| 38 | pub struct CertificateSummary { |
| 39 | pub id: String, |
| 40 | pub name: Option<String>, |
| 41 | pub certificate_type: Option<String>, |
| 42 | pub serial_number: Option<String>, |
| 43 | pub expiration_date: Option<String>, |
| 44 | /// Computed locally: App Store Connect offers no filter for it and |
| 45 | /// returns expired certificates alongside valid ones. |
| 46 | pub expired: Option<bool>, |
| 47 | } |
| 48 | |
| 49 | impl CertificateSummary { |
| 50 | pub fn from_resource( |
| 51 | certificate: &smbcloud_ascapi_signing::certificate::Certificate, |
| 52 | now: &str, |
| 53 | ) -> Self { |
| 54 | let expiration_date = certificate.attributes.expiration_date.clone(); |
| 55 | let expired = expiration_date |
| 56 | .as_ref() |
| 57 | .map(|at| is_expired(at.as_str(), now)); |
| 58 | |
| 59 | Self { |
| 60 | id: certificate.id.clone(), |
| 61 | name: certificate.attributes.name.clone(), |
| 62 | certificate_type: certificate |
| 63 | .attributes |
| 64 | .certificate_type |
| 65 | .map(|t| t.as_api_str().to_string()), |
| 66 | serial_number: certificate.attributes.serial_number.clone(), |
| 67 | expiration_date, |
| 68 | expired, |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | /// The result of issuing a certificate. |
| 74 | /// |
| 75 | /// Carries paths, never key material. |
| 76 | #[derive(Debug, Clone, Serialize, schemars::JsonSchema)] |
| 77 | pub struct IssuedCertificate { |
| 78 | pub certificate: CertificateSummary, |
| 79 | /// Path the private key was written to. The key itself is never |
| 80 | /// included: this is the whole point of returning a path. |
| 81 | pub private_key_path: String, |
| 82 | pub certificate_path: String, |
| 83 | /// Copy-pasteable commands to get the pair into a keychain. |
| 84 | pub next_steps: Vec<String>, |
| 85 | } |
| 86 | |
| 87 | /// Generate a key pair, have Apple certify it, and write both halves. |
| 88 | /// |
| 89 | /// Shared by the CLI and the MCP tool so the ordering guarantee (key |
| 90 | /// written before the request is sent) has exactly one implementation. |
| 91 | pub async fn issue_certificate( |
| 92 | client: &Client, |
| 93 | certificate_type: CertificateType, |
| 94 | common_name: &str, |
| 95 | out_dir: &std::path::Path, |
| 96 | ) -> Result<IssuedCertificate, String> { |
| 97 | use smbcloud_ascapi_signing::certificate::CertificateCreateAttributes; |
| 98 | |
| 99 | let request = smbcloud_ascapi_signing::csr::generate_certificate_request(common_name) |
| 100 | .map_err(|error| format!("generating the RSA 2048 key pair and CSR: {error}"))?; |
| 101 | |
| 102 | std::fs::create_dir_all(out_dir) |
| 103 | .map_err(|error| format!("creating {}: {error}", out_dir.display()))?; |
| 104 | |
| 105 | let stem = certificate_type.as_api_str().to_lowercase(); |
| 106 | let key_path = out_dir.join(format!("{stem}.key.pem")); |
| 107 | |
| 108 | // Before the network call, deliberately. A failure after Apple has |
| 109 | // issued the certificate would leave a certificate whose key was never |
| 110 | // persisted, which is unrecoverable and has consumed one of the team's |
| 111 | // limited slots. A failure here leaves an unused key, which costs |
| 112 | // nothing. |
| 113 | write_private_key(&key_path, request.private_key_pem()) |
| 114 | .map_err(|error| format!("writing the private key to {}: {error}", key_path.display()))?; |
| 115 | |
| 116 | let certificate = client |
| 117 | .create_certificate(CertificateCreateAttributes { |
| 118 | csr_content: request.csr_pem().to_string(), |
| 119 | certificate_type, |
| 120 | }) |
| 121 | .await |
| 122 | .map_err(|error| error.to_string())?; |
| 123 | |
| 124 | let cer_path = out_dir.join(format!("{stem}.cer")); |
| 125 | if let Some(content) = certificate.attributes.certificate_content.as_deref() { |
| 126 | let der = base64_decode(content)?; |
| 127 | std::fs::write(&cer_path, der) |
| 128 | .map_err(|error| format!("writing {}: {error}", cer_path.display()))?; |
| 129 | } |
| 130 | |
| 131 | Ok(IssuedCertificate { |
| 132 | certificate: CertificateSummary::from_resource(&certificate, &now_iso8601()), |
| 133 | private_key_path: key_path.display().to_string(), |
| 134 | certificate_path: cer_path.display().to_string(), |
| 135 | next_steps: vec![ |
| 136 | format!( |
| 137 | "openssl pkcs12 -export -inkey {} -in {} -out {stem}.p12", |
| 138 | key_path.display(), |
| 139 | cer_path.display() |
| 140 | ), |
| 141 | format!( |
| 142 | "security import {stem}.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign" |
| 143 | ), |
| 144 | "security find-identity -v -p codesigning".to_string(), |
| 145 | ], |
| 146 | }) |
| 147 | } |
| 148 | |
| 149 | /// Write a private key with owner-only permissions, set at creation time |
| 150 | /// rather than chmod'ed afterwards so the key is never briefly readable by |
| 151 | /// anyone else. |
| 152 | pub fn write_private_key(path: &std::path::Path, pem: &str) -> std::io::Result<()> { |
| 153 | use std::io::Write; |
| 154 | |
| 155 | let mut options = std::fs::OpenOptions::new(); |
| 156 | options.write(true).create(true).truncate(true); |
| 157 | #[cfg(unix)] |
| 158 | { |
| 159 | use std::os::unix::fs::OpenOptionsExt; |
| 160 | options.mode(0o600); |
| 161 | } |
| 162 | let mut file = options.open(path)?; |
| 163 | file.write_all(pem.as_bytes())?; |
| 164 | file.sync_all() |
| 165 | } |
| 166 | |
| 167 | fn base64_decode(input: &str) -> Result<Vec<u8>, String> { |
| 168 | use base64::Engine; |
| 169 | base64::engine::general_purpose::STANDARD |
| 170 | .decode(input.trim()) |
| 171 | .map_err(|error| format!("decoding the certificate Apple returned: {error}")) |
| 172 | } |
| 173 | |
| 174 | #[cfg(test)] |
| 175 | mod tests { |
| 176 | use super::*; |
| 177 | |
| 178 | #[test] |
| 179 | fn expiry_comparison_matches_calendar_order() { |
| 180 | assert!(is_expired("2026-08-05T20:56:53", "2026-08-06T04:32:19")); |
| 181 | assert!(!is_expired("2027-02-01T22:12:15", "2026-08-06T04:32:19")); |
| 182 | // Same day, hours apart: the case that decides whether a |
| 183 | // certificate expiring later today reads as already dead. |
| 184 | assert!(!is_expired("2026-08-06T04:37:57", "2026-08-06T04:32:19")); |
| 185 | assert!(is_expired("2026-08-06T04:31:00", "2026-08-06T04:32:19")); |
| 186 | } |
| 187 | |
| 188 | #[test] |
| 189 | fn now_is_a_fixed_width_sortable_timestamp() { |
| 190 | let now = now_iso8601(); |
| 191 | assert_eq!(now.len(), 19, "expected YYYY-MM-DDTHH:MM:SS, got {now:?}"); |
| 192 | assert_eq!(&now[4..5], "-"); |
| 193 | assert_eq!(&now[10..11], "T"); |
| 194 | // Sanity bound: a wildly wrong civil-date conversion would land |
| 195 | // outside this window and silently mark every certificate expired. |
| 196 | assert!( |
| 197 | now.as_str() > "2024-01-01T00:00:00", |
| 198 | "clock looks wrong: {now}" |
| 199 | ); |
| 200 | assert!( |
| 201 | now.as_str() < "2100-01-01T00:00:00", |
| 202 | "clock looks wrong: {now}" |
| 203 | ); |
| 204 | } |
| 205 | |
| 206 | #[test] |
| 207 | fn certificate_type_strings_are_the_documented_set() { |
| 208 | for value in [ |
| 209 | "development", |
| 210 | "distribution", |
| 211 | "mac_app_distribution", |
| 212 | "mac_installer_distribution", |
| 213 | "developer_id_application", |
| 214 | ] { |
| 215 | certificate_type_from_str(value) |
| 216 | .unwrap_or_else(|_| panic!("{value} should be accepted")); |
| 217 | } |
| 218 | let error = certificate_type_from_str("apple_distribution").expect_err("should reject"); |
| 219 | // The error has to list the valid values: a model that guessed |
| 220 | // wrong needs to correct itself without another round trip. |
| 221 | assert!(error.contains("distribution"), "unhelpful error: {error}"); |
| 222 | } |
| 223 | |
| 224 | #[test] |
| 225 | fn issued_certificate_never_serializes_key_material() { |
| 226 | let issued = IssuedCertificate { |
| 227 | certificate: CertificateSummary { |
| 228 | id: "ABC123".to_string(), |
| 229 | name: Some("Apple Distribution: Example".to_string()), |
| 230 | certificate_type: Some("DISTRIBUTION".to_string()), |
| 231 | serial_number: Some("1A2B3C".to_string()), |
| 232 | expiration_date: Some("2027-08-06T00:00:00".to_string()), |
| 233 | expired: Some(false), |
| 234 | }, |
| 235 | private_key_path: "/tmp/out/distribution.key.pem".to_string(), |
| 236 | certificate_path: "/tmp/out/distribution.cer".to_string(), |
| 237 | next_steps: vec!["security find-identity -v -p codesigning".to_string()], |
| 238 | }; |
| 239 | |
| 240 | let json = serde_json::to_string(&issued).expect("serializes"); |
| 241 | assert!(!json.contains("BEGIN PRIVATE KEY")); |
| 242 | assert!(!json.contains("BEGIN CERTIFICATE")); |
| 243 | // The path is the point: it tells the caller where to look without |
| 244 | // putting the secret into a transcript. |
| 245 | assert!(json.contains("distribution.key.pem")); |
| 246 | } |
| 247 | } |