1use std::path::{Path, PathBuf};
29use std::process::Command;
30
31use thiserror::Error;
32
33use crate::secret::Secret;
34
35const SIGNTOOL_BIN: &str = "signtool.exe";
36
37const AZURE_TIMESTAMP_URL: &str = "http://timestamp.acs.microsoft.com";
42
43#[derive(Debug, Error)]
44pub enum SigntoolError {
45 #[error("signtool failed for `{}`: {source}", path.display())]
46 Sign {
47 path: PathBuf,
48 #[source]
49 source: crate::CommandError,
50 },
51 #[error("path contains non-UTF-8 characters: {}", path.display())]
52 NonUtf8Path { path: PathBuf },
53 #[error("failed to write Azure metadata file: {0}")]
54 AzureMetadataWrite(#[source] std::io::Error),
55}
56
57#[derive(Debug, Error)]
58pub enum SigntoolConfigError {
59 #[error(
60 "incomplete Windows signing configuration: set both SIGNTOOL_CERTIFICATE_PATH and SIGNTOOL_CERTIFICATE_PASSWORD (missing: {missing})"
61 )]
62 IncompleteCertificateConfiguration { missing: String },
63 #[error(
64 "incomplete Azure Trusted Signing configuration: set all of SIGNTOOL_AZURE_DLIB_PATH, SIGNTOOL_AZURE_ENDPOINT, SIGNTOOL_AZURE_ACCOUNT, and SIGNTOOL_AZURE_CERTIFICATE_PROFILE (missing: {missing})"
65 )]
66 IncompleteAzureConfiguration { missing: String },
67}
68
69#[derive(Debug)]
71enum SigningMethod {
72 Certificate {
74 certificate_path: PathBuf,
75 certificate_password: Secret<String>,
76 },
77 Azure {
79 dlib_path: PathBuf,
80 _metadata_dir: tempfile::TempDir,
83 metadata_path: PathBuf,
84 },
85}
86
87#[derive(Debug)]
89pub struct WindowsSigner {
90 signtool_path: PathBuf,
91 method: SigningMethod,
92 timestamp_url: Option<String>,
93 description: Option<String>,
95}
96
97impl WindowsSigner {
98 pub fn from_env() -> Result<Option<Self>, SigntoolConfigError> {
111 if let Some(signer) = Self::from_env_certificate()? {
113 return Ok(Some(signer));
114 }
115 Self::from_env_azure()
117 }
118
119 fn from_env_certificate() -> Result<Option<Self>, SigntoolConfigError> {
121 let certificate_path = std::env::var("SIGNTOOL_CERTIFICATE_PATH").ok();
122 let certificate_password = std::env::var("SIGNTOOL_CERTIFICATE_PASSWORD").ok();
123
124 match (certificate_path, certificate_password) {
125 (None, None) => Ok(None),
126 (Some(certificate_path), Some(certificate_password)) => {
127 let timestamp_url = std::env::var("SIGNTOOL_TIMESTAMP_URL").ok();
128 let signtool_path = signtool_path_from_env();
129 let description = std::env::var("SIGNTOOL_DESCRIPTION").ok();
130
131 Ok(Some(Self {
132 signtool_path,
133 method: SigningMethod::Certificate {
134 certificate_path: PathBuf::from(certificate_path),
135 certificate_password: Secret::new(certificate_password),
136 },
137 timestamp_url,
138 description,
139 }))
140 }
141 (path, password) => {
142 let mut missing = Vec::new();
143 if path.is_none() {
144 missing.push("SIGNTOOL_CERTIFICATE_PATH");
145 }
146 if password.is_none() {
147 missing.push("SIGNTOOL_CERTIFICATE_PASSWORD");
148 }
149 Err(SigntoolConfigError::IncompleteCertificateConfiguration {
150 missing: missing.join(", "),
151 })
152 }
153 }
154 }
155
156 fn from_env_azure() -> Result<Option<Self>, SigntoolConfigError> {
158 let dlib_path = std::env::var("SIGNTOOL_AZURE_DLIB_PATH").ok();
159 let endpoint = std::env::var("SIGNTOOL_AZURE_ENDPOINT").ok();
160 let account = std::env::var("SIGNTOOL_AZURE_ACCOUNT").ok();
161 let cert_profile = std::env::var("SIGNTOOL_AZURE_CERTIFICATE_PROFILE").ok();
162
163 match (&dlib_path, &endpoint, &account, &cert_profile) {
164 (None, None, None, None) => Ok(None),
165 (Some(_), Some(endpoint), Some(account), Some(cert_profile)) => {
166 let dlib_path = PathBuf::from(dlib_path.unwrap());
167 let correlation_id = std::env::var("SIGNTOOL_AZURE_CORRELATION_ID").ok();
168 let timestamp_url = std::env::var("SIGNTOOL_TIMESTAMP_URL")
169 .ok()
170 .or_else(|| Some(AZURE_TIMESTAMP_URL.to_string()));
171 let signtool_path = signtool_path_from_env();
172 let description = std::env::var("SIGNTOOL_DESCRIPTION").ok();
173
174 let metadata = build_azure_metadata(
175 endpoint,
176 account,
177 cert_profile,
178 correlation_id.as_deref(),
179 );
180
181 let metadata_dir =
182 tempfile::tempdir().map_err(|e| SigntoolConfigError::azure_metadata_io(&e))?;
183 let metadata_path = metadata_dir.path().join("metadata.json");
184 {
185 use std::io::Write;
186 let mut opts = fs_err::OpenOptions::new();
187 opts.write(true).create_new(true);
188 #[cfg(unix)]
189 {
190 use fs_err::os::unix::fs::OpenOptionsExt;
191 opts.mode(0o600);
192 }
193 let mut file = opts
194 .open(&metadata_path)
195 .map_err(|e| SigntoolConfigError::azure_metadata_io(&e))?;
196 file.write_all(metadata.as_bytes())
197 .map_err(|e| SigntoolConfigError::azure_metadata_io(&e))?;
198 }
199
200 Ok(Some(Self {
201 signtool_path,
202 method: SigningMethod::Azure {
203 dlib_path,
204 _metadata_dir: metadata_dir,
205 metadata_path,
206 },
207 timestamp_url,
208 description,
209 }))
210 }
211 _ => {
212 let mut missing = Vec::new();
213 if dlib_path.is_none() {
214 missing.push("SIGNTOOL_AZURE_DLIB_PATH");
215 }
216 if endpoint.is_none() {
217 missing.push("SIGNTOOL_AZURE_ENDPOINT");
218 }
219 if account.is_none() {
220 missing.push("SIGNTOOL_AZURE_ACCOUNT");
221 }
222 if cert_profile.is_none() {
223 missing.push("SIGNTOOL_AZURE_CERTIFICATE_PROFILE");
224 }
225 Err(SigntoolConfigError::IncompleteAzureConfiguration {
226 missing: missing.join(", "),
227 })
228 }
229 }
230 }
231
232 pub fn sign(&self, path: &Path) -> Result<(), SigntoolError> {
243 if self.is_signed(path) {
245 tracing::debug!("skipping already-signed {}", path.display());
246 return Ok(());
247 }
248
249 let mut cmd = Command::new(&self.signtool_path);
250 cmd.arg("sign");
251 cmd.args(["/fd", "sha256"]);
252
253 match &self.method {
254 SigningMethod::Certificate {
255 certificate_path,
256 certificate_password,
257 } => {
258 let cert_path_str =
259 certificate_path
260 .to_str()
261 .ok_or_else(|| SigntoolError::NonUtf8Path {
262 path: certificate_path.clone(),
263 })?;
264 cmd.args(["/f", cert_path_str]);
265 cmd.args(["/p", certificate_password.expose().as_str()]);
266 }
267 SigningMethod::Azure {
268 dlib_path,
269 metadata_path,
270 ..
271 } => {
272 let dlib_str = dlib_path
273 .to_str()
274 .ok_or_else(|| SigntoolError::NonUtf8Path {
275 path: dlib_path.clone(),
276 })?;
277 let metadata_str =
278 metadata_path
279 .to_str()
280 .ok_or_else(|| SigntoolError::NonUtf8Path {
281 path: metadata_path.clone(),
282 })?;
283 cmd.args(["/dlib", dlib_str]);
284 cmd.args(["/dmdf", metadata_str]);
285 }
286 }
287
288 if let Some(desc) = &self.description {
289 cmd.args(["/d", desc]);
290 }
291
292 if let Some(url) = &self.timestamp_url {
293 cmd.args(["/tr", url]);
294 cmd.args(["/td", "sha256"]);
295 }
296
297 cmd.arg(path);
298
299 crate::run_command(&mut cmd).map_err(|source| SigntoolError::Sign {
300 path: path.to_path_buf(),
301 source,
302 })?;
303
304 tracing::debug!("signtool signed {}", path.display());
305 Ok(())
306 }
307
308 fn is_signed(&self, path: &Path) -> bool {
313 let output = Command::new(&self.signtool_path)
314 .args(["verify", "/pa"])
315 .arg(path)
316 .output();
317
318 match output {
319 Ok(o) => o.status.success(),
320 Err(_) => false,
321 }
322 }
323}
324
325impl SigntoolConfigError {
326 fn azure_metadata_io(e: &std::io::Error) -> Self {
327 Self::IncompleteAzureConfiguration {
329 missing: format!("(failed to write metadata file: {e})"),
330 }
331 }
332}
333
334fn build_azure_metadata(
338 endpoint: &str,
339 account: &str,
340 cert_profile: &str,
341 correlation_id: Option<&str>,
342) -> String {
343 let endpoint = escape_json_string(endpoint);
345 let account = escape_json_string(account);
346 let cert_profile = escape_json_string(cert_profile);
347
348 let mut json = format!(
349 "{{\n \"Endpoint\": \"{endpoint}\",\n \"CodeSigningAccountName\": \"{account}\",\n \"CertificateProfileName\": \"{cert_profile}\""
350 );
351
352 if let Some(id) = correlation_id {
353 use std::fmt::Write;
354 let id = escape_json_string(id);
355 let _ = write!(json, ",\n \"CorrelationId\": \"{id}\"");
356 }
357
358 json.push_str("\n}");
359 json
360}
361
362fn escape_json_string(s: &str) -> String {
364 let mut out = String::with_capacity(s.len());
365 for c in s.chars() {
366 match c {
367 '"' => out.push_str("\\\""),
368 '\\' => out.push_str("\\\\"),
369 '\n' => out.push_str("\\n"),
370 '\r' => out.push_str("\\r"),
371 '\t' => out.push_str("\\t"),
372 c if c.is_control() => {
373 use std::fmt::Write;
374 let _ = write!(out, "\\u{:04x}", c as u32);
376 }
377 c => out.push(c),
378 }
379 }
380 out
381}
382
383fn signtool_path_from_env() -> PathBuf {
385 std::env::var("SIGNTOOL_PATH").map_or_else(|_| PathBuf::from(SIGNTOOL_BIN), PathBuf::from)
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
393 fn test_from_env_missing_vars() {
394 if std::env::var("SIGNTOOL_CERTIFICATE_PATH").is_err()
397 && std::env::var("SIGNTOOL_CERTIFICATE_PASSWORD").is_err()
398 && std::env::var("SIGNTOOL_AZURE_DLIB_PATH").is_err()
399 && std::env::var("SIGNTOOL_AZURE_ENDPOINT").is_err()
400 && std::env::var("SIGNTOOL_AZURE_ACCOUNT").is_err()
401 && std::env::var("SIGNTOOL_AZURE_CERTIFICATE_PROFILE").is_err()
402 {
403 assert!(WindowsSigner::from_env().unwrap().is_none());
404 }
405 }
406
407 #[test]
408 fn test_build_azure_metadata_basic() {
409 let json = build_azure_metadata(
410 "https://eus.codesigning.azure.net",
411 "my-account",
412 "my-profile",
413 None,
414 );
415 assert!(json.contains("\"Endpoint\": \"https://eus.codesigning.azure.net\""));
416 assert!(json.contains("\"CodeSigningAccountName\": \"my-account\""));
417 assert!(json.contains("\"CertificateProfileName\": \"my-profile\""));
418 assert!(!json.contains("CorrelationId"));
419 }
420
421 #[test]
422 fn test_build_azure_metadata_with_correlation_id() {
423 let json = build_azure_metadata(
424 "https://eus.codesigning.azure.net",
425 "my-account",
426 "my-profile",
427 Some("build-123"),
428 );
429 assert!(json.contains("\"CorrelationId\": \"build-123\""));
430 }
431
432 #[test]
433 fn test_escape_json_string() {
434 assert_eq!(escape_json_string("hello"), "hello");
435 assert_eq!(escape_json_string("say \"hi\""), "say \\\"hi\\\"");
436 assert_eq!(escape_json_string("a\\b"), "a\\\\b");
437 assert_eq!(escape_json_string("line\nnewline"), "line\\nnewline");
438 }
439}