1use std::path::{Path, PathBuf};
19use std::process::Command;
20use std::{fmt, io};
21
22use base64::Engine;
23use thiserror::Error;
24use zeroize::Zeroize;
25
26use crate::secret::Secret;
27
28const CODESIGN_BIN: &str = "codesign";
29const SECURITY_BIN: &str = "security";
30
31#[derive(Debug, Error)]
32pub enum CodesignError {
33 #[error("codesign failed for `{}`: {source}", path.display())]
34 Sign {
35 path: PathBuf,
36 #[source]
37 source: crate::CommandError,
38 },
39 #[error("failed to create ephemeral keychain: {source}")]
40 KeychainSetup {
41 step: KeychainStep,
42 #[source]
43 source: KeychainSetupError,
44 },
45 #[error(
46 "signing identity `{identity}` not found in keychain after certificate import\n\
47 available identities:\n{}",
48 format_available_identities(available)
49 )]
50 IdentityNotFound {
51 identity: String,
52 available: Vec<String>,
53 },
54}
55
56fn format_available_identities(identities: &[String]) -> String {
57 if identities.is_empty() {
58 return " (none)".to_string();
59 }
60 identities
61 .iter()
62 .map(|id| format!(" - {id}"))
63 .collect::<Vec<_>>()
64 .join("\n")
65}
66
67#[derive(Debug, Clone, Copy)]
69pub enum KeychainStep {
70 AcquireLock,
71 CreateTempdir,
72 CreateKeychain,
73 SetSettings,
74 Unlock,
75 SetSearchList,
76 GetSearchList,
77 WriteCertificate,
78 ImportCertificate,
79 SetPartitionList,
80 GeneratePassword,
81 VerifyIdentity,
82}
83
84impl fmt::Display for KeychainStep {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 match self {
87 Self::AcquireLock => write!(f, "acquire keychain lock"),
88 Self::CreateTempdir => write!(f, "create tempdir"),
89 Self::CreateKeychain => write!(f, "create keychain"),
90 Self::SetSettings => write!(f, "set keychain settings"),
91 Self::Unlock => write!(f, "unlock keychain"),
92 Self::SetSearchList => write!(f, "set keychain search list"),
93 Self::GetSearchList => write!(f, "get keychain search list"),
94 Self::WriteCertificate => write!(f, "write certificate"),
95 Self::ImportCertificate => write!(f, "import certificate"),
96 Self::SetPartitionList => write!(f, "set key partition list"),
97 Self::GeneratePassword => write!(f, "generate password"),
98 Self::VerifyIdentity => write!(f, "verify signing identity"),
99 }
100 }
101}
102
103#[derive(Debug, Error)]
105pub enum KeychainSetupError {
106 #[error("{0}")]
107 Io(#[from] io::Error),
108 #[error("{0}")]
109 Command(#[from] crate::CommandError),
110 #[error("failed to generate random bytes: {0}")]
111 Getrandom(#[from] getrandom::Error),
112 #[error("path contains non-UTF-8 characters: {}", path.display())]
113 NonUtf8Path { path: PathBuf },
114}
115
116impl CodesignError {
117 fn keychain(step: KeychainStep, source: impl Into<KeychainSetupError>) -> Self {
118 Self::KeychainSetup {
119 step,
120 source: source.into(),
121 }
122 }
123
124 fn non_utf8_path(step: KeychainStep, path: &Path) -> Self {
125 Self::KeychainSetup {
126 step,
127 source: KeychainSetupError::NonUtf8Path {
128 path: path.to_path_buf(),
129 },
130 }
131 }
132}
133
134#[derive(Debug, Error)]
135pub enum CodesignConfigError {
136 #[error(
137 "incomplete macOS signing configuration: all of CODESIGN_IDENTITY, CODESIGN_CERTIFICATE, and CODESIGN_CERTIFICATE_PASSWORD are required (missing: {missing})"
138 )]
139 IncompleteConfiguration { missing: String },
140 #[error("CODESIGN_CERTIFICATE is not valid base64: {0}")]
141 InvalidCertificate(#[source] base64::DecodeError),
142}
143
144#[derive(Debug)]
146pub struct MacOsSigner {
147 identity: String,
148 certificate: Secret<Vec<u8>>,
149 certificate_password: Secret<String>,
150 options: Option<String>,
152 allow_untrusted: bool,
158}
159
160impl MacOsSigner {
161 pub fn from_env() -> Result<Option<Self>, CodesignConfigError> {
172 let identity = std::env::var("CODESIGN_IDENTITY").ok();
173 let cert_b64 = std::env::var("CODESIGN_CERTIFICATE").ok();
174 let password = std::env::var("CODESIGN_CERTIFICATE_PASSWORD").ok();
175
176 match (identity, cert_b64, password) {
177 (None, None, None) => Ok(None),
178 (Some(identity), Some(cert_b64), Some(password)) => {
179 let cert_b64_clean: String = cert_b64
183 .chars()
184 .filter(|c| !c.is_ascii_whitespace())
185 .collect();
186 let certificate = base64::engine::general_purpose::STANDARD
187 .decode(&cert_b64_clean)
188 .map_err(CodesignConfigError::InvalidCertificate)?;
189 let options = std::env::var("CODESIGN_OPTIONS").ok();
190 let allow_untrusted = std::env::var("CODESIGN_ALLOW_UNTRUSTED")
191 .ok()
192 .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
193
194 Ok(Some(Self {
195 identity,
196 certificate: Secret::new(certificate),
197 certificate_password: Secret::new(password),
198 options,
199 allow_untrusted,
200 }))
201 }
202 (identity, cert_b64, password) => {
203 let mut missing = Vec::new();
204 if identity.is_none() {
205 missing.push("CODESIGN_IDENTITY");
206 }
207 if cert_b64.is_none() {
208 missing.push("CODESIGN_CERTIFICATE");
209 }
210 if password.is_none() {
211 missing.push("CODESIGN_CERTIFICATE_PASSWORD");
212 }
213 Err(CodesignConfigError::IncompleteConfiguration {
214 missing: missing.join(", "),
215 })
216 }
217 }
218 }
219
220 pub fn begin_session(&self) -> Result<MacOsSigningSession, CodesignError> {
231 let keychain = EphemeralKeychain::create()?;
232 keychain.import_certificate(
233 self.certificate.expose(),
234 self.certificate_password.expose(),
235 )?;
236 keychain.verify_identity(&self.identity, self.allow_untrusted)?;
237
238 Ok(MacOsSigningSession {
239 identity: self.identity.clone(),
240 options: self.options.clone(),
241 keychain,
242 })
243 }
244}
245
246#[derive(Debug)]
252pub struct MacOsSigningSession {
253 identity: String,
254 options: Option<String>,
255 keychain: EphemeralKeychain,
256}
257
258impl MacOsSigningSession {
259 pub fn sign(&self, path: &Path) -> Result<(), CodesignError> {
266 let keychain_str = self.keychain.path_str()?;
267
268 let mut cmd = Command::new(CODESIGN_BIN);
269 cmd.args(["--force", "--sign", &self.identity]);
270 if let Some(options) = &self.options {
271 cmd.args(["--options", options]);
272 }
273 cmd.args(["--keychain", keychain_str]);
274 cmd.arg(path);
275 run_codesign(&mut cmd, path)?;
276
277 tracing::debug!("identity-signed {}", path.display());
278 Ok(())
279 }
280}
281
282pub fn adhoc_sign(path: &Path) -> Result<(), CodesignError> {
289 let mut cmd = Command::new(CODESIGN_BIN);
290 cmd.args(["--force", "--sign", "-"]);
291 cmd.arg(path);
292 run_codesign(&mut cmd, path)?;
293
294 tracing::debug!("ad-hoc signed {}", path.display());
295 Ok(())
296}
297
298#[derive(Debug)]
310struct EphemeralKeychain {
311 temp_dir: tempfile::TempDir,
312 path: PathBuf,
313 password: Secret<String>,
314 _lock: fs_err::File,
320}
321
322impl Drop for EphemeralKeychain {
323 fn drop(&mut self) {
324 let result = Command::new(SECURITY_BIN)
328 .args(["delete-keychain"])
329 .arg(&self.path)
330 .output();
331
332 match result {
333 Ok(output) if output.status.success() => {
334 tracing::debug!("deleted ephemeral keychain {}", self.path.display());
335 }
336 Ok(output) => {
337 tracing::warn!(
338 "failed to delete ephemeral keychain {}: {}",
339 self.path.display(),
340 String::from_utf8_lossy(&output.stderr).trim()
341 );
342 }
343 Err(e) => {
344 tracing::warn!(
345 "failed to run `security delete-keychain` for {}: {e}",
346 self.path.display()
347 );
348 }
349 }
350 }
351}
352
353impl EphemeralKeychain {
354 fn create() -> Result<Self, CodesignError> {
355 let lock = acquire_keychain_lock()?;
361
362 let temp_dir = tempfile::tempdir()
363 .map_err(|e| CodesignError::keychain(KeychainStep::CreateTempdir, e))?;
364 let path = temp_dir.path().join("signing.keychain-db");
365
366 let password = random_hex_password()?;
368
369 let path_str = path
370 .to_str()
371 .ok_or_else(|| CodesignError::non_utf8_path(KeychainStep::CreateKeychain, &path))?;
372
373 run_security(
375 KeychainStep::CreateKeychain,
376 &["create-keychain", "-p", password.expose(), path_str],
377 )?;
378
379 run_security(
384 KeychainStep::SetSettings,
385 &["set-keychain-settings", "-t", "21600", "-u", path_str],
386 )?;
387
388 run_security(
390 KeychainStep::Unlock,
391 &["unlock-keychain", "-p", password.expose(), path_str],
392 )?;
393
394 let current_search_list = get_keychain_search_list()?;
396
397 {
401 let mut args = vec!["list-keychains", "-d", "user", "-s", path_str];
402 let prev_strs: Vec<&str> = current_search_list
403 .iter()
404 .filter_map(|p| p.to_str())
405 .collect();
406 args.extend(prev_strs);
407 run_security(KeychainStep::SetSearchList, &args)?;
408 }
409
410 Ok(Self {
411 temp_dir,
412 path,
413 password,
414 _lock: lock,
415 })
416 }
417
418 fn path_str(&self) -> Result<&str, CodesignError> {
420 self.path
421 .to_str()
422 .ok_or_else(|| CodesignError::non_utf8_path(KeychainStep::CreateKeychain, &self.path))
423 }
424
425 fn verify_identity(&self, identity: &str, allow_untrusted: bool) -> Result<(), CodesignError> {
435 let keychain_str = self.path_str()?;
436
437 let mut cmd = Command::new(SECURITY_BIN);
438 cmd.arg("find-identity");
439 if !allow_untrusted {
440 cmd.arg("-v");
441 }
442 cmd.args(["-p", "codesigning", keychain_str]);
443
444 let output = cmd
445 .output()
446 .map_err(|e| CodesignError::keychain(KeychainStep::VerifyIdentity, e))?;
447
448 let stdout = String::from_utf8_lossy(&output.stdout);
449
450 if stdout.contains(identity) {
451 tracing::debug!("verified identity `{identity}` exists in keychain");
452 return Ok(());
453 }
454
455 let available: Vec<String> = stdout
459 .lines()
460 .filter(|line| line.contains('"'))
461 .map(|line| {
462 line.trim()
463 .split_once(") ")
464 .map_or(line.trim(), |x| x.1)
465 .to_string()
466 })
467 .collect();
468
469 Err(CodesignError::IdentityNotFound {
470 identity: identity.to_string(),
471 available,
472 })
473 }
474
475 fn import_certificate(
476 &self,
477 certificate: &[u8],
478 passphrase: &str,
479 ) -> Result<(), CodesignError> {
480 let keychain_str = self.path_str()?;
481
482 let cert_path = self.temp_dir.path().join("cert.p12");
484 {
485 use std::io::Write;
486 let mut opts = fs_err::OpenOptions::new();
487 opts.write(true).create_new(true);
488 #[cfg(unix)]
489 {
490 use fs_err::os::unix::fs::OpenOptionsExt;
491 opts.mode(0o600);
492 }
493 let mut file = opts
494 .open(&cert_path)
495 .map_err(|e| CodesignError::keychain(KeychainStep::WriteCertificate, e))?;
496 file.write_all(certificate)
497 .map_err(|e| CodesignError::keychain(KeychainStep::WriteCertificate, e))?;
498 }
499
500 let cert_path_str = cert_path.to_str().ok_or_else(|| {
501 CodesignError::non_utf8_path(KeychainStep::ImportCertificate, &cert_path)
502 })?;
503
504 run_security(
515 KeychainStep::ImportCertificate,
516 &[
517 "import",
518 cert_path_str,
519 "-k",
520 keychain_str,
521 "-P",
522 passphrase,
523 "-f",
524 "pkcs12",
525 "-T",
526 "/usr/bin/codesign",
527 "-T",
528 "/usr/bin/security",
529 "-T",
530 "/usr/bin/productbuild",
531 "-T",
532 "/usr/bin/pkgbuild",
533 ],
534 )?;
535
536 run_security(
541 KeychainStep::SetPartitionList,
542 &[
543 "set-key-partition-list",
544 "-S",
545 "apple-tool:,apple:,codesign:",
546 "-s",
547 "-k",
548 self.password.expose(),
549 keychain_str,
550 ],
551 )?;
552
553 Ok(())
554 }
555}
556
557fn acquire_keychain_lock() -> Result<fs_err::File, CodesignError> {
562 let lock_path = std::env::temp_dir().join("cargo-code-sign-keychain.lock");
563 let file = fs_err::OpenOptions::new()
564 .write(true)
565 .create(true)
566 .truncate(false)
567 .open(&lock_path)
568 .map_err(|e| CodesignError::keychain(KeychainStep::AcquireLock, e))?;
569 tracing::debug!("waiting for keychain lock at {}", lock_path.display());
570 file.lock()
571 .map_err(|e| CodesignError::keychain(KeychainStep::AcquireLock, e))?;
572 tracing::debug!("acquired keychain lock");
573 Ok(file)
574}
575
576fn get_keychain_search_list() -> Result<Vec<PathBuf>, CodesignError> {
578 let output = Command::new(SECURITY_BIN)
579 .args(["list-keychains", "-d", "user"])
580 .output()
581 .map_err(|e| CodesignError::keychain(KeychainStep::GetSearchList, e))?;
582
583 if !output.status.success() {
584 return Err(CodesignError::keychain(
585 KeychainStep::GetSearchList,
586 crate::CommandError::Failed {
587 status: output.status,
588 stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(),
589 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
590 },
591 ));
592 }
593
594 Ok(String::from_utf8_lossy(&output.stdout)
597 .lines()
598 .map(|line| line.trim().trim_matches('"'))
599 .filter(|s| !s.is_empty())
600 .map(PathBuf::from)
601 .collect())
602}
603
604fn run_codesign(cmd: &mut Command, path: &Path) -> Result<(), CodesignError> {
606 crate::run_command(cmd).map_err(|source| CodesignError::Sign {
607 path: path.to_path_buf(),
608 source,
609 })
610}
611
612fn run_security(step: KeychainStep, args: &[&str]) -> Result<(), CodesignError> {
614 crate::run_command(Command::new(SECURITY_BIN).args(args))
615 .map_err(|e| CodesignError::keychain(step, e))
616}
617
618fn random_hex_password() -> Result<Secret<String>, CodesignError> {
620 let mut buf = [0u8; 32];
621 getrandom::fill(&mut buf)
622 .map_err(|e| CodesignError::keychain(KeychainStep::GeneratePassword, e))?;
623 let mut hex = String::with_capacity(64);
624 for b in &buf {
625 use fmt::Write;
626 write!(hex, "{b:02x}").unwrap();
627 }
628 buf.zeroize();
629 Ok(Secret::new(hex))
630}
631
632#[cfg(all(test, target_os = "macos"))]
633mod tests {
634 use super::*;
635
636 #[cfg(target_os = "macos")]
637 fn require_command_or_skip(context: &str, command: &str) -> bool {
638 if Command::new(command).arg("--help").output().is_ok() {
639 return true;
640 }
641 eprintln!("skipping {context}: required command not found in PATH: {command}");
642 false
643 }
644
645 #[cfg(unix)]
646 #[test]
647 fn test_random_hex_password() {
648 let a = random_hex_password().unwrap();
649 let b = random_hex_password().unwrap();
650 let a = a.expose();
651 let b = b.expose();
652 assert_eq!(a.len(), 64, "expected 32 bytes = 64 hex chars");
653 assert_ne!(a, b, "two random passwords should differ");
654 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
655 }
656
657 #[cfg(target_os = "macos")]
658 #[test]
659 fn test_adhoc_sign_real_binary() {
660 if !require_command_or_skip("adhoc signing real binary", CODESIGN_BIN) {
661 return;
662 }
663
664 let tmp = tempfile::tempdir().unwrap();
665 let path = tmp.path().join("true_copy");
666 fs_err::copy("/usr/bin/true", &path).unwrap();
667 adhoc_sign(&path).unwrap();
668 }
669
670 #[cfg(target_os = "macos")]
671 #[test]
672 fn test_adhoc_sign_nonexistent_fails() {
673 if !require_command_or_skip("adhoc signing nonexistent file", CODESIGN_BIN) {
674 return;
675 }
676
677 let tmp = tempfile::tempdir().unwrap();
678 let path = tmp.path().join("nope");
679 assert!(adhoc_sign(&path).is_err());
681 }
682}