use serde::Serialize;
use smbcloud_ascapi_core::Client;
use smbcloud_ascapi_signing::certificate::CertificateType;
use smbcloud_ascapi_signing::prelude::*;
use crate::time::{is_expired, now_iso8601};
pub fn certificate_type_from_str(value: &str) -> Result<CertificateType, String> {
use CertificateType;
match value {
"development" => Ok(CertificateType::Development),
"distribution" => Ok(CertificateType::Distribution),
"mac_app_distribution" => Ok(CertificateType::MacAppDistribution),
"mac_installer_distribution" => Ok(CertificateType::MacInstallerDistribution),
"developer_id_application" => Ok(CertificateType::DeveloperIdApplication),
other => Err(format!(
"unknown certificate type {other:?}; expected one of development, distribution, \
mac_app_distribution, mac_installer_distribution, developer_id_application"
)),
}
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct CertificateSummary {
pub id: String,
pub name: Option<String>,
pub certificate_type: Option<String>,
pub serial_number: Option<String>,
pub expiration_date: Option<String>,
pub expired: Option<bool>,
}
impl CertificateSummary {
pub fn from_resource(
certificate: &smbcloud_ascapi_signing::certificate::Certificate,
now: &str,
) -> Self {
let expiration_date = certificate.attributes.expiration_date.clone();
let expired = expiration_date
.as_ref()
.map(|at| is_expired(at.as_str(), now));
Self {
id: certificate.id.clone(),
name: certificate.attributes.name.clone(),
certificate_type: certificate
.attributes
.certificate_type
.map(|t| t.as_api_str().to_string()),
serial_number: certificate.attributes.serial_number.clone(),
expiration_date,
expired,
}
}
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct IssuedCertificate {
pub certificate: CertificateSummary,
pub private_key_path: String,
pub certificate_path: String,
pub next_steps: Vec<String>,
}
pub async fn issue_certificate(
client: &Client,
certificate_type: CertificateType,
common_name: &str,
out_dir: &std::path::Path,
) -> Result<IssuedCertificate, String> {
use smbcloud_ascapi_signing::certificate::CertificateCreateAttributes;
let request = smbcloud_ascapi_signing::csr::generate_certificate_request(common_name)
.map_err(|error| format!("generating the RSA 2048 key pair and CSR: {error}"))?;
std::fs::create_dir_all(out_dir)
.map_err(|error| format!("creating {}: {error}", out_dir.display()))?;
let stem = certificate_type.as_api_str().to_lowercase();
let key_path = out_dir.join(format!("{stem}.key.pem"));
write_private_key(&key_path, request.private_key_pem())
.map_err(|error| format!("writing the private key to {}: {error}", key_path.display()))?;
let certificate = client
.create_certificate(CertificateCreateAttributes {
csr_content: request.csr_pem().to_string(),
certificate_type,
})
.await
.map_err(|error| error.to_string())?;
let cer_path = out_dir.join(format!("{stem}.cer"));
if let Some(content) = certificate.attributes.certificate_content.as_deref() {
let der = base64_decode(content)?;
std::fs::write(&cer_path, der)
.map_err(|error| format!("writing {}: {error}", cer_path.display()))?;
}
Ok(IssuedCertificate {
certificate: CertificateSummary::from_resource(&certificate, &now_iso8601()),
private_key_path: key_path.display().to_string(),
certificate_path: cer_path.display().to_string(),
next_steps: vec![
format!(
"openssl pkcs12 -export -inkey {} -in {} -out {stem}.p12",
key_path.display(),
cer_path.display()
),
format!(
"security import {stem}.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign"
),
"security find-identity -v -p codesigning".to_string(),
],
})
}
pub fn write_private_key(path: &std::path::Path, pem: &str) -> std::io::Result<()> {
use std::io::Write;
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(path)?;
file.write_all(pem.as_bytes())?;
file.sync_all()
}
fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(input.trim())
.map_err(|error| format!("decoding the certificate Apple returned: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expiry_comparison_matches_calendar_order() {
assert!(is_expired("2026-08-05T20:56:53", "2026-08-06T04:32:19"));
assert!(!is_expired("2027-02-01T22:12:15", "2026-08-06T04:32:19"));
assert!(!is_expired("2026-08-06T04:37:57", "2026-08-06T04:32:19"));
assert!(is_expired("2026-08-06T04:31:00", "2026-08-06T04:32:19"));
}
#[test]
fn now_is_a_fixed_width_sortable_timestamp() {
let now = now_iso8601();
assert_eq!(now.len(), 19, "expected YYYY-MM-DDTHH:MM:SS, got {now:?}");
assert_eq!(&now[4..5], "-");
assert_eq!(&now[10..11], "T");
assert!(
now.as_str() > "2024-01-01T00:00:00",
"clock looks wrong: {now}"
);
assert!(
now.as_str() < "2100-01-01T00:00:00",
"clock looks wrong: {now}"
);
}
#[test]
fn certificate_type_strings_are_the_documented_set() {
for value in [
"development",
"distribution",
"mac_app_distribution",
"mac_installer_distribution",
"developer_id_application",
] {
certificate_type_from_str(value)
.unwrap_or_else(|_| panic!("{value} should be accepted"));
}
let error = certificate_type_from_str("apple_distribution").expect_err("should reject");
assert!(error.contains("distribution"), "unhelpful error: {error}");
}
#[test]
fn issued_certificate_never_serializes_key_material() {
let issued = IssuedCertificate {
certificate: CertificateSummary {
id: "ABC123".to_string(),
name: Some("Apple Distribution: Example".to_string()),
certificate_type: Some("DISTRIBUTION".to_string()),
serial_number: Some("1A2B3C".to_string()),
expiration_date: Some("2027-08-06T00:00:00".to_string()),
expired: Some(false),
},
private_key_path: "/tmp/out/distribution.key.pem".to_string(),
certificate_path: "/tmp/out/distribution.cer".to_string(),
next_steps: vec!["security find-identity -v -p codesigning".to_string()],
};
let json = serde_json::to_string(&issued).expect("serializes");
assert!(!json.contains("BEGIN PRIVATE KEY"));
assert!(!json.contains("BEGIN CERTIFICATE"));
assert!(json.contains("distribution.key.pem"));
}
}