main
rs 247 lines 9.58 KB
Raw
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 }