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