Skip to main content

native_code_sign/
windows.rs

1//! Windows code signing using Microsoft `signtool.exe`.
2//!
3//! Supports two signing modes:
4//!
5//! ## Certificate signing (local `.pfx`)
6//!
7//! - `SIGNTOOL_CERTIFICATE_PATH`: path to a `.pfx` certificate file
8//! - `SIGNTOOL_CERTIFICATE_PASSWORD`: password for the `.pfx`
9//!
10//! ## Azure Trusted Signing (cloud HSM)
11//!
12//! - `SIGNTOOL_AZURE_DLIB_PATH`: path to `Azure.CodeSigning.Dlib.dll`
13//! - `SIGNTOOL_AZURE_ENDPOINT`: Artifact Signing endpoint (e.g. `https://eus.codesigning.azure.net`)
14//! - `SIGNTOOL_AZURE_ACCOUNT`: `CodeSigningAccountName`
15//! - `SIGNTOOL_AZURE_CERTIFICATE_PROFILE`: `CertificateProfileName`
16//! - `SIGNTOOL_AZURE_CORRELATION_ID`: (optional) `CorrelationId` for tracking
17//!
18//! Azure auth is handled by the dlib via `DefaultAzureCredential` (supports
19//! `az login`, managed identity, environment variables, etc.).
20//!
21//! ## Shared options
22//!
23//! - `SIGNTOOL_TIMESTAMP_URL`: (optional) RFC 3161 timestamp server URL.
24//!   Defaults to `http://timestamp.acs.microsoft.com` for Azure Trusted Signing.
25//! - `SIGNTOOL_PATH`: (optional) explicit path to `signtool.exe`
26//! - `SIGNTOOL_DESCRIPTION`: (optional) description shown in UAC prompts (`/d`)
27
28use 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
37/// Default timestamp server for Azure Trusted Signing.
38///
39/// Azure certificates have a 3-day validity, so timestamping is mandatory for signatures to
40/// remain valid after the certificate expires.
41const 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/// The signing method — either a local certificate or Azure Trusted Signing.
70#[derive(Debug)]
71enum SigningMethod {
72    /// Local `.pfx` certificate file.
73    Certificate {
74        certificate_path: PathBuf,
75        certificate_password: Secret<String>,
76    },
77    /// Azure Trusted Signing via the dlib plugin.
78    Azure {
79        dlib_path: PathBuf,
80        /// Temporary directory holding the generated `metadata.json`.
81        /// Kept alive for the lifetime of the signer.
82        _metadata_dir: tempfile::TempDir,
83        metadata_path: PathBuf,
84    },
85}
86
87/// Configuration for Windows signtool signing.
88#[derive(Debug)]
89pub struct WindowsSigner {
90    signtool_path: PathBuf,
91    method: SigningMethod,
92    timestamp_url: Option<String>,
93    /// Description shown in UAC prompts (signtool `/d` flag).
94    description: Option<String>,
95}
96
97impl WindowsSigner {
98    /// Construct from environment variables.
99    ///
100    /// Checks for certificate-based signing first, then Azure Trusted Signing.
101    ///
102    /// Returns `Ok(None)` when no signing variables are set.
103    ///
104    /// # Errors
105    ///
106    /// - [`SigntoolConfigError::IncompleteCertificateConfiguration`] when only some certificate
107    ///   variables are set.
108    /// - [`SigntoolConfigError::IncompleteAzureConfiguration`] when only some Azure variables
109    ///   are set.
110    pub fn from_env() -> Result<Option<Self>, SigntoolConfigError> {
111        // Try certificate-based signing first.
112        if let Some(signer) = Self::from_env_certificate()? {
113            return Ok(Some(signer));
114        }
115        // Fall back to Azure Trusted Signing.
116        Self::from_env_azure()
117    }
118
119    /// Try to construct a certificate-based signer from environment variables.
120    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    /// Try to construct an Azure Trusted Signing signer from environment variables.
157    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    /// Sign a file with signtool.
233    ///
234    /// If the file is already Authenticode-signed, it is skipped. Unlike macOS `codesign --force`
235    /// which replaces existing signatures, `signtool` adds nested signatures — so repeatedly
236    /// signing the same file would accumulate signatures and grow the file.
237    ///
238    /// # Errors
239    ///
240    /// - [`SigntoolError::NonUtf8Path`] if a path argument is not valid UTF-8.
241    /// - [`SigntoolError::Sign`] if signtool cannot be spawned or exits with a non-zero status.
242    pub fn sign(&self, path: &Path) -> Result<(), SigntoolError> {
243        // Check if the file is already signed to avoid accumulating nested signatures.
244        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    /// Check whether a file already has a valid Authenticode signature.
309    ///
310    /// Returns `false` if verification fails or signtool cannot be run (e.g., freshly built
311    /// binaries). This is a best-effort check to avoid accumulating nested signatures.
312    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        // Surface metadata file I/O errors as incomplete config since they happen during setup.
328        Self::IncompleteAzureConfiguration {
329            missing: format!("(failed to write metadata file: {e})"),
330        }
331    }
332}
333
334/// Build the Azure Trusted Signing `metadata.json` content.
335///
336/// We format this manually to avoid a serde dependency for four fields.
337fn build_azure_metadata(
338    endpoint: &str,
339    account: &str,
340    cert_profile: &str,
341    correlation_id: Option<&str>,
342) -> String {
343    // Escape JSON string values to handle any special characters.
344    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
362/// Escape a string for safe embedding in a JSON string value.
363fn 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                // Unicode escape for control characters.
375                let _ = write!(out, "\\u{:04x}", c as u32);
376            }
377            c => out.push(c),
378        }
379    }
380    out
381}
382
383/// Read `SIGNTOOL_PATH` from the environment or fall back to `signtool.exe`.
384fn 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        // With no env vars set, strict parsing should return Ok(None).
395        // (This test assumes no SIGNTOOL_* vars are set in the test environment.)
396        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}