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 CODE_SIGN_IDENTITY, CODE_SIGN_CERTIFICATE, and CODE_SIGN_CERTIFICATE_PASSWORD are required (missing: {missing})"
138 )]
139 IncompleteConfiguration { missing: String },
140 #[error("CODE_SIGN_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("CODE_SIGN_IDENTITY").ok();
173 let cert_b64 = std::env::var("CODE_SIGN_CERTIFICATE").ok();
174 let password = std::env::var("CODE_SIGN_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("CODE_SIGN_OPTIONS").ok();
190 let allow_untrusted = std::env::var("CODE_SIGN_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("CODE_SIGN_IDENTITY");
206 }
207 if cert_b64.is_none() {
208 missing.push("CODE_SIGN_CERTIFICATE");
209 }
210 if password.is_none() {
211 missing.push("CODE_SIGN_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 if !output.status.success() {
449 return Err(CodesignError::keychain(
450 KeychainStep::VerifyIdentity,
451 crate::CommandError::Failed {
452 status: output.status,
453 stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(),
454 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
455 },
456 ));
457 }
458
459 let stdout = String::from_utf8_lossy(&output.stdout);
460
461 if stdout.contains(identity) {
462 tracing::debug!("verified identity `{identity}` exists in keychain");
463 return Ok(());
464 }
465
466 let available: Vec<String> = stdout
470 .lines()
471 .filter(|line| line.contains('"'))
472 .map(|line| {
473 line.trim()
474 .split_once(") ")
475 .map_or(line.trim(), |x| x.1)
476 .to_string()
477 })
478 .collect();
479
480 Err(CodesignError::IdentityNotFound {
481 identity: identity.to_string(),
482 available,
483 })
484 }
485
486 fn import_certificate(
487 &self,
488 certificate: &[u8],
489 passphrase: &str,
490 ) -> Result<(), CodesignError> {
491 let keychain_str = self.path_str()?;
492
493 let cert_path = self.temp_dir.path().join("cert.p12");
495 {
496 use std::io::Write;
497 let mut opts = fs_err::OpenOptions::new();
498 opts.write(true).create_new(true);
499 #[cfg(unix)]
500 {
501 use fs_err::os::unix::fs::OpenOptionsExt;
502 opts.mode(0o600);
503 }
504 let mut file = opts
505 .open(&cert_path)
506 .map_err(|e| CodesignError::keychain(KeychainStep::WriteCertificate, e))?;
507 file.write_all(certificate)
508 .map_err(|e| CodesignError::keychain(KeychainStep::WriteCertificate, e))?;
509 }
510
511 let cert_path_str = cert_path.to_str().ok_or_else(|| {
512 CodesignError::non_utf8_path(KeychainStep::ImportCertificate, &cert_path)
513 })?;
514
515 run_security(
526 KeychainStep::ImportCertificate,
527 &[
528 "import",
529 cert_path_str,
530 "-k",
531 keychain_str,
532 "-P",
533 passphrase,
534 "-f",
535 "pkcs12",
536 "-T",
537 "/usr/bin/codesign",
538 "-T",
539 "/usr/bin/security",
540 "-T",
541 "/usr/bin/productbuild",
542 "-T",
543 "/usr/bin/pkgbuild",
544 ],
545 )?;
546
547 run_security(
552 KeychainStep::SetPartitionList,
553 &[
554 "set-key-partition-list",
555 "-S",
556 "apple-tool:,apple:,codesign:",
557 "-s",
558 "-k",
559 self.password.expose(),
560 keychain_str,
561 ],
562 )?;
563
564 Ok(())
565 }
566}
567
568fn acquire_keychain_lock() -> Result<fs_err::File, CodesignError> {
573 let lock_path = std::env::temp_dir().join("cargo-code-sign-keychain.lock");
574 let file = fs_err::OpenOptions::new()
575 .write(true)
576 .create(true)
577 .truncate(false)
578 .open(&lock_path)
579 .map_err(|e| CodesignError::keychain(KeychainStep::AcquireLock, e))?;
580 tracing::debug!("waiting for keychain lock at {}", lock_path.display());
581 file.lock()
582 .map_err(|e| CodesignError::keychain(KeychainStep::AcquireLock, e))?;
583 tracing::debug!("acquired keychain lock");
584 Ok(file)
585}
586
587fn get_keychain_search_list() -> Result<Vec<PathBuf>, CodesignError> {
589 let output = Command::new(SECURITY_BIN)
590 .args(["list-keychains", "-d", "user"])
591 .output()
592 .map_err(|e| CodesignError::keychain(KeychainStep::GetSearchList, e))?;
593
594 if !output.status.success() {
595 return Err(CodesignError::keychain(
596 KeychainStep::GetSearchList,
597 crate::CommandError::Failed {
598 status: output.status,
599 stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(),
600 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
601 },
602 ));
603 }
604
605 Ok(String::from_utf8_lossy(&output.stdout)
608 .lines()
609 .map(|line| line.trim().trim_matches('"'))
610 .filter(|s| !s.is_empty())
611 .map(PathBuf::from)
612 .collect())
613}
614
615fn run_codesign(cmd: &mut Command, path: &Path) -> Result<(), CodesignError> {
617 crate::run_command(cmd).map_err(|source| CodesignError::Sign {
618 path: path.to_path_buf(),
619 source,
620 })
621}
622
623fn run_security(step: KeychainStep, args: &[&str]) -> Result<(), CodesignError> {
625 crate::run_command(Command::new(SECURITY_BIN).args(args))
626 .map_err(|e| CodesignError::keychain(step, e))
627}
628
629fn random_hex_password() -> Result<Secret<String>, CodesignError> {
631 let mut buf = [0u8; 32];
632 getrandom::fill(&mut buf)
633 .map_err(|e| CodesignError::keychain(KeychainStep::GeneratePassword, e))?;
634 let mut hex = String::with_capacity(64);
635 for b in &buf {
636 use fmt::Write;
637 write!(hex, "{b:02x}").unwrap();
638 }
639 buf.zeroize();
640 Ok(Secret::new(hex))
641}
642
643#[cfg(all(test, target_os = "macos"))]
644mod tests {
645 use super::*;
646
647 #[cfg(target_os = "macos")]
648 fn require_command_or_skip(context: &str, command: &str) -> bool {
649 if Command::new(command).arg("--help").output().is_ok() {
650 return true;
651 }
652 eprintln!("skipping {context}: required command not found in PATH: {command}");
653 false
654 }
655
656 #[cfg(unix)]
657 #[test]
658 fn test_random_hex_password() {
659 let a = random_hex_password().unwrap();
660 let b = random_hex_password().unwrap();
661 let a = a.expose();
662 let b = b.expose();
663 assert_eq!(a.len(), 64, "expected 32 bytes = 64 hex chars");
664 assert_ne!(a, b, "two random passwords should differ");
665 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
666 }
667
668 #[cfg(target_os = "macos")]
669 #[test]
670 fn test_adhoc_sign_real_binary() {
671 if !require_command_or_skip("adhoc signing real binary", CODESIGN_BIN) {
672 return;
673 }
674
675 let tmp = tempfile::tempdir().unwrap();
676 let path = tmp.path().join("true_copy");
677 fs_err::copy("/usr/bin/true", &path).unwrap();
678 adhoc_sign(&path).unwrap();
679 }
680
681 #[cfg(target_os = "macos")]
682 #[test]
683 fn test_adhoc_sign_nonexistent_fails() {
684 if !require_command_or_skip("adhoc signing nonexistent file", CODESIGN_BIN) {
685 return;
686 }
687
688 let tmp = tempfile::tempdir().unwrap();
689 let path = tmp.path().join("nope");
690 assert!(adhoc_sign(&path).is_err());
692 }
693}