1use std::path::{Path, PathBuf};
37
38use async_trait::async_trait;
39use base64::Engine;
40use serde::Deserialize;
41
42use crate::provider::{ProviderContext, ProviderReport, ReleaseProvider};
43use crate::runner::RunnerError;
44use crate::types::ProducedArtifact;
45
46const SLOT_CERT: &str = "AUTHENTICODE_CERT";
49const SLOT_CERT_PASSWORD: &str = "AUTHENTICODE_CERT_PASSWORD";
50
51const DEFAULT_TSA: &str = "http://timestamp.digicert.com";
55
56const DEFAULT_DIGEST: &str = "sha256";
59
60const SIGNABLE_EXTS: &[&str] = &["exe", "msi", "dll", "sys", "cab", "cat", "ps1"];
64
65#[derive(Debug, Clone, Deserialize)]
67#[serde(default)]
68struct AuthenticodeConfig {
69 artifacts: Vec<String>,
74 timestamp_url: String,
76 digest: String,
79}
80
81impl Default for AuthenticodeConfig {
82 fn default() -> Self {
83 Self {
84 artifacts: Vec::new(),
85 timestamp_url: DEFAULT_TSA.to_string(),
86 digest: DEFAULT_DIGEST.to_string(),
87 }
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95enum Signer {
96 Osslsigncode,
97 Signtool,
98}
99
100#[derive(Debug, Default)]
102pub struct AuthenticodeProvider {
103 tool_bin: Option<String>,
107}
108
109impl AuthenticodeProvider {
110 #[allow(dead_code)]
113 fn with_tool(tool: impl Into<String>) -> Self {
114 Self {
115 tool_bin: Some(tool.into()),
116 }
117 }
118
119 fn tool(&self) -> &str {
121 self.tool_bin.as_deref().unwrap_or("osslsigncode")
122 }
123
124 fn signer(&self) -> Signer {
130 let tool = self.tool();
131 let base = tool
132 .rsplit(['/', '\\'])
133 .next()
134 .unwrap_or(tool)
135 .trim_end_matches(".exe")
136 .trim_end_matches(".EXE");
137 if base.eq_ignore_ascii_case("signtool") {
138 Signer::Signtool
139 } else {
140 Signer::Osslsigncode
141 }
142 }
143}
144
145#[async_trait]
146impl ReleaseProvider for AuthenticodeProvider {
147 fn name(&self) -> &str {
148 "authenticode"
149 }
150
151 fn required_slots(&self) -> Vec<&str> {
152 vec![SLOT_CERT, SLOT_CERT_PASSWORD]
153 }
154
155 async fn dispatch(&self, ctx: &ProviderContext<'_>) -> Result<ProviderReport, RunnerError> {
156 let cfg: AuthenticodeConfig = parse_config(ctx.config)?;
157
158 let cert_b64 = ctx.require_secret(SLOT_CERT)?;
162 let password = ctx.require_secret(SLOT_CERT_PASSWORD)?;
163 let cert_der = decode_cert(&cert_b64)?;
164
165 let selected = select_artifacts(ctx.artifacts, &cfg.artifacts)?;
166
167 let mut report = ProviderReport::default();
168
169 if ctx.dry_run {
170 for art in &selected {
171 report.actions.push(format!(
172 "would sign {} ({}, ts {})",
173 basename(&art.path),
174 cfg.digest,
175 cfg.timestamp_url,
176 ));
177 }
178 return Ok(report);
179 }
180
181 let cert_path = write_cert(ctx.work_dir, &cert_der)?;
184
185 for art in selected {
186 sign_one(self.tool(), self.signer(), &art, &cert_path, &password, &cfg).await?;
187 report
188 .actions
189 .push(format!("signed {} ({})", basename(&art.path), cfg.digest));
190 report.produced.push(art);
194 }
195
196 Ok(report)
197 }
198}
199
200fn parse_config(value: &serde_json::Value) -> Result<AuthenticodeConfig, RunnerError> {
204 if value.is_null() {
205 return Ok(AuthenticodeConfig::default());
206 }
207 serde_json::from_value(value.clone())
208 .map_err(|e| RunnerError::Outcome(format!("authenticode: invalid `with` config: {e}")))
209}
210
211fn decode_cert(b64: &str) -> Result<Vec<u8>, RunnerError> {
215 let cleaned: String = b64.split_whitespace().collect();
216 base64::engine::general_purpose::STANDARD
217 .decode(cleaned.as_bytes())
218 .map_err(|e| {
219 RunnerError::Outcome(format!(
220 "authenticode: `{SLOT_CERT}` is not valid base64 \
221 (expected a base64-encoded PKCS#12/.pfx blob): {e}"
222 ))
223 })
224}
225
226fn select_artifacts(
231 artifacts: &[ProducedArtifact],
232 globs: &[String],
233) -> Result<Vec<ProducedArtifact>, RunnerError> {
234 let selected: Vec<ProducedArtifact> = artifacts
235 .iter()
236 .filter(|a| is_signable(&a.path))
237 .filter(|a| globs.is_empty() || globs.iter().any(|g| artifact_matches(a, g)))
238 .cloned()
239 .collect();
240
241 if selected.is_empty() {
242 let detail = if globs.is_empty() {
243 format!(
244 "no signable artifact (.exe/.msi/.dll/.sys/.cab/.cat/.ps1) among {} produced",
245 artifacts.len()
246 )
247 } else {
248 format!(
249 "no signable artifact matched config globs {globs:?} (of {} produced)",
250 artifacts.len()
251 )
252 };
253 return Err(RunnerError::Outcome(format!("authenticode: {detail}")));
254 }
255 Ok(selected)
256}
257
258fn is_signable(path: &str) -> bool {
260 Path::new(path)
261 .extension()
262 .and_then(|e| e.to_str())
263 .map(|e| SIGNABLE_EXTS.iter().any(|n| e.eq_ignore_ascii_case(n)))
264 .unwrap_or(false)
265}
266
267fn artifact_matches(art: &ProducedArtifact, glob: &str) -> bool {
270 glob_match(glob, &art.binary) || glob_match(glob, basename(&art.path))
271}
272
273fn basename(path: &str) -> &str {
275 Path::new(path)
276 .file_name()
277 .and_then(|s| s.to_str())
278 .unwrap_or(path)
279}
280
281fn glob_match(pattern: &str, text: &str) -> bool {
284 let (p, t): (Vec<char>, Vec<char>) = (pattern.chars().collect(), text.chars().collect());
286 let (mut pi, mut ti) = (0usize, 0usize);
287 let (mut star, mut star_t): (Option<usize>, usize) = (None, 0);
288 while ti < t.len() {
289 if pi < p.len() && (p[pi] == t[ti]) {
290 pi += 1;
291 ti += 1;
292 } else if pi < p.len() && p[pi] == '*' {
293 star = Some(pi);
294 star_t = ti;
295 pi += 1;
296 } else if let Some(s) = star {
297 pi = s + 1;
298 star_t += 1;
299 ti = star_t;
300 } else {
301 return false;
302 }
303 }
304 while pi < p.len() && p[pi] == '*' {
305 pi += 1;
306 }
307 pi == p.len()
308}
309
310fn write_cert(work_dir: &Path, der: &[u8]) -> Result<PathBuf, RunnerError> {
313 let path = work_dir.join("authenticode-cert.pfx");
314 std::fs::write(&path, der)
315 .map_err(|e| RunnerError::Outcome(format!("authenticode: writing cert file: {e}")))?;
316 #[cfg(unix)]
317 {
318 use std::os::unix::fs::PermissionsExt;
319 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
320 .map_err(|e| RunnerError::Outcome(format!("authenticode: chmod 600 cert file: {e}")))?;
321 }
322 Ok(path)
323}
324
325async fn sign_one(
329 tool: &str,
330 signer: Signer,
331 art: &ProducedArtifact,
332 cert_path: &Path,
333 password: &str,
334 cfg: &AuthenticodeConfig,
335) -> Result<(), RunnerError> {
336 match signer {
337 Signer::Osslsigncode => {
338 let signed = sibling_signed_path(&art.path);
339 let out = tokio::process::Command::new(tool)
340 .arg("sign")
341 .arg("-pkcs12")
342 .arg(cert_path)
343 .arg("-pass")
344 .arg(password)
345 .arg("-h")
346 .arg(&cfg.digest)
347 .arg("-t")
348 .arg(&cfg.timestamp_url)
349 .arg("-in")
350 .arg(&art.path)
351 .arg("-out")
352 .arg(&signed)
353 .output()
354 .await
355 .map_err(|e| {
356 RunnerError::Outcome(format!("authenticode: spawning `{tool} sign`: {e}"))
357 })?;
358 if !out.status.success() {
359 return Err(RunnerError::Outcome(format!(
360 "authenticode: osslsigncode failed on {} (status {}): {}",
361 basename(&art.path),
362 out.status,
363 String::from_utf8_lossy(&out.stderr).trim(),
364 )));
365 }
366 std::fs::rename(&signed, &art.path).map_err(|e| {
367 RunnerError::Outcome(format!(
368 "authenticode: replacing {} with signed output: {e}",
369 basename(&art.path)
370 ))
371 })?;
372 Ok(())
373 }
374 Signer::Signtool => {
375 let out = tokio::process::Command::new(tool)
377 .arg("sign")
378 .arg("/fd")
379 .arg(&cfg.digest)
380 .arg("/f")
381 .arg(cert_path)
382 .arg("/p")
383 .arg(password)
384 .arg("/tr")
385 .arg(&cfg.timestamp_url)
386 .arg("/td")
387 .arg(&cfg.digest)
388 .arg(&art.path)
389 .output()
390 .await
391 .map_err(|e| {
392 RunnerError::Outcome(format!("authenticode: spawning `{tool} sign`: {e}"))
393 })?;
394 if !out.status.success() {
395 return Err(RunnerError::Outcome(format!(
396 "authenticode: signtool failed on {} (status {}): {}",
397 basename(&art.path),
398 out.status,
399 String::from_utf8_lossy(&out.stderr).trim(),
400 )));
401 }
402 Ok(())
403 }
404 }
405}
406
407fn sibling_signed_path(path: &str) -> PathBuf {
410 PathBuf::from(format!("{path}.signed"))
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use crate::provider::MapSecrets;
417 use std::collections::BTreeMap;
418
419 fn art(binary: &str, path: &str) -> ProducedArtifact {
420 ProducedArtifact {
421 binary: binary.into(),
422 path: path.into(),
423 triple: Some("windows-x86_64".into()),
424 }
425 }
426
427 fn cert_b64() -> String {
430 base64::engine::general_purpose::STANDARD.encode(b"fake-pkcs12-der")
431 }
432
433 fn full_secrets() -> MapSecrets {
434 let mut m = BTreeMap::new();
435 m.insert(SLOT_CERT.into(), cert_b64());
436 m.insert(SLOT_CERT_PASSWORD.into(), "hunter2".into());
437 MapSecrets(m)
438 }
439
440 fn ctx<'a>(
441 secrets: &'a dyn crate::provider::SecretSource,
442 work: &'a Path,
443 cfg: &'a serde_json::Value,
444 artifacts: &'a [ProducedArtifact],
445 dry_run: bool,
446 ) -> ProviderContext<'a> {
447 ProviderContext {
448 version: "1.2.3",
449 artifacts,
450 base_url: None,
451 config: cfg,
452 work_dir: work,
453 secrets,
454 dry_run,
455 }
456 }
457
458 #[test]
459 fn declares_cert_slots() {
460 let p = AuthenticodeProvider::default();
461 assert_eq!(p.name(), "authenticode");
462 assert_eq!(p.required_slots(), vec![SLOT_CERT, SLOT_CERT_PASSWORD]);
463 }
464
465 #[test]
466 fn signer_inferred_from_tool_basename() {
467 assert_eq!(AuthenticodeProvider::default().signer(), Signer::Osslsigncode);
468 assert_eq!(
469 AuthenticodeProvider::with_tool("/usr/bin/osslsigncode").signer(),
470 Signer::Osslsigncode
471 );
472 assert_eq!(
473 AuthenticodeProvider::with_tool("signtool").signer(),
474 Signer::Signtool
475 );
476 assert_eq!(
477 AuthenticodeProvider::with_tool(r"C:\sdk\signtool.exe").signer(),
478 Signer::Signtool
479 );
480 }
481
482 #[tokio::test]
483 async fn dry_run_plans_per_artifact_without_spawning_signer() {
484 let work = tempfile::tempdir().unwrap();
485 let secrets = full_secrets();
486 let artifacts = vec![
487 art("desktop", "out/Desktop.exe"),
488 art("yah", "out/yah"),
489 art("installer", "out/Setup.msi"),
490 ];
491 let cfg = serde_json::Value::Null;
492 let report = AuthenticodeProvider::default()
493 .dispatch(&ctx(&secrets, work.path(), &cfg, &artifacts, true))
494 .await
495 .unwrap();
496 assert_eq!(report.actions.len(), 2);
498 assert!(report.actions[0].contains("would sign Desktop.exe"));
499 assert!(report.actions[0].contains("sha256"));
500 assert!(report.actions[0].contains(DEFAULT_TSA));
501 assert!(report.actions[1].contains("would sign Setup.msi"));
502 assert!(report.produced.is_empty(), "dry run mutates nothing");
503 assert!(report.published.is_empty());
504 assert!(!work.path().join("authenticode-cert.pfx").exists());
506 }
507
508 #[tokio::test]
509 async fn dry_run_missing_slot_is_typed_error_naming_slot() {
510 let work = tempfile::tempdir().unwrap();
511 let mut m = BTreeMap::new();
512 m.insert(SLOT_CERT.into(), cert_b64());
513 let secrets = MapSecrets(m);
515 let artifacts = vec![art("desktop", "out/Desktop.exe")];
516 let cfg = serde_json::Value::Null;
517 let err = AuthenticodeProvider::default()
518 .dispatch(&ctx(&secrets, work.path(), &cfg, &artifacts, true))
519 .await
520 .unwrap_err();
521 assert!(
522 format!("{err}").contains(SLOT_CERT_PASSWORD),
523 "names the slot: {err}"
524 );
525 }
526
527 #[tokio::test]
528 async fn dry_run_bad_base64_cert_is_typed_error() {
529 let work = tempfile::tempdir().unwrap();
530 let mut m = BTreeMap::new();
531 m.insert(SLOT_CERT.into(), "not base64!!!@@@".into());
532 m.insert(SLOT_CERT_PASSWORD.into(), "hunter2".into());
533 let secrets = MapSecrets(m);
534 let artifacts = vec![art("desktop", "out/Desktop.exe")];
535 let cfg = serde_json::Value::Null;
536 let err = AuthenticodeProvider::default()
537 .dispatch(&ctx(&secrets, work.path(), &cfg, &artifacts, true))
538 .await
539 .unwrap_err();
540 assert!(format!("{err}").contains("not valid base64"), "{err}");
541 }
542
543 #[test]
544 fn whitespace_in_cert_blob_decodes() {
545 let raw = b"fake-pkcs12-der";
546 let b64 = base64::engine::general_purpose::STANDARD.encode(raw);
547 let multiline = format!(" {}\n {}\n", &b64[..4], &b64[4..]);
549 assert_eq!(decode_cert(&multiline).unwrap(), raw);
550 }
551
552 #[tokio::test]
553 async fn no_signable_artifact_is_a_config_error() {
554 let work = tempfile::tempdir().unwrap();
555 let secrets = full_secrets();
556 let artifacts = vec![art("yah", "out/yah"), art("desktop", "out/Desktop.dmg")];
557 let cfg = serde_json::Value::Null;
558 let err = AuthenticodeProvider::default()
559 .dispatch(&ctx(&secrets, work.path(), &cfg, &artifacts, true))
560 .await
561 .unwrap_err();
562 assert!(format!("{err}").contains("no signable artifact"));
563 }
564
565 #[tokio::test]
566 async fn config_glob_filters_to_named_binary() {
567 let work = tempfile::tempdir().unwrap();
568 let secrets = full_secrets();
569 let artifacts = vec![
570 art("desktop", "out/Desktop.exe"),
571 art("helper", "out/Helper.exe"),
572 ];
573 let cfg = serde_json::json!({ "artifacts": ["desktop"] });
574 let report = AuthenticodeProvider::default()
575 .dispatch(&ctx(&secrets, work.path(), &cfg, &artifacts, true))
576 .await
577 .unwrap();
578 assert_eq!(report.actions.len(), 1);
579 assert!(report.actions[0].contains("Desktop.exe"));
580 }
581
582 #[tokio::test]
583 async fn config_overrides_digest_and_tsa() {
584 let work = tempfile::tempdir().unwrap();
585 let secrets = full_secrets();
586 let artifacts = vec![art("desktop", "out/Desktop.exe")];
587 let cfg = serde_json::json!({
588 "digest": "sha384",
589 "timestamp_url": "http://tsa.example/rfc3161",
590 });
591 let report = AuthenticodeProvider::default()
592 .dispatch(&ctx(&secrets, work.path(), &cfg, &artifacts, true))
593 .await
594 .unwrap();
595 assert!(report.actions[0].contains("sha384"));
596 assert!(report.actions[0].contains("http://tsa.example/rfc3161"));
597 }
598
599 #[tokio::test]
600 async fn config_glob_with_no_match_errors() {
601 let work = tempfile::tempdir().unwrap();
602 let secrets = full_secrets();
603 let artifacts = vec![art("desktop", "out/Desktop.exe")];
604 let cfg = serde_json::json!({ "artifacts": ["nonexistent-*"] });
605 let err = AuthenticodeProvider::default()
606 .dispatch(&ctx(&secrets, work.path(), &cfg, &artifacts, true))
607 .await
608 .unwrap_err();
609 assert!(format!("{err}").contains("no signable artifact matched"));
610 }
611
612 #[test]
613 fn signable_extension_is_case_insensitive() {
614 assert!(is_signable("App.EXE"));
615 assert!(is_signable("Setup.msi"));
616 assert!(is_signable("driver.SYS"));
617 assert!(!is_signable("yah"));
618 assert!(!is_signable("Desktop.dmg"));
619 }
620
621 #[test]
622 fn glob_match_supports_star() {
623 assert!(glob_match("*.exe", "Desktop.exe"));
624 assert!(glob_match("desktop", "desktop"));
625 assert!(glob_match("yah-*", "yah-helper"));
626 assert!(glob_match("*", "anything"));
627 assert!(!glob_match("*.exe", "Desktop.msi"));
628 assert!(!glob_match("desktop", "desktop-helper"));
629 }
630
631 #[test]
632 fn sibling_signed_path_appends_suffix() {
633 assert_eq!(
634 sibling_signed_path("out/Desktop.exe"),
635 PathBuf::from("out/Desktop.exe.signed")
636 );
637 }
638}