Skip to main content

smbcloud_ascapi_frontend/
certificates.rs

1//! Certificate operations shared by both front ends.
2
3use serde::Serialize;
4use smbcloud_ascapi_core::Client;
5use smbcloud_ascapi_signing::certificate::CertificateType;
6use smbcloud_ascapi_signing::prelude::*;
7
8use crate::time::{is_expired, now_iso8601};
9
10/// Map a CLI/MCP certificate-type string onto the API enum.
11pub 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)]
38pub 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
49impl 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)]
77pub 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.
91pub 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.
152pub 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
167fn 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)]
175mod 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}