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//! - `CODE_SIGN_CERTIFICATE_PATH`: path to a `.pfx` certificate file
8//! - `CODE_SIGN_CERTIFICATE_PASSWORD`: password for the `.pfx`
9//!
10//! ## Azure Trusted Signing (cloud HSM)
11//!
12//! - `CODE_SIGN_AZURE_DLIB_PATH`: path to `Azure.CodeSigning.Dlib.dll`
13//! - `CODE_SIGN_AZURE_ENDPOINT`: Artifact Signing endpoint (e.g. `https://eus.codesigning.azure.net`)
14//! - `CODE_SIGN_AZURE_ACCOUNT`: `CodeSigningAccountName`
15//! - `CODE_SIGN_AZURE_CERTIFICATE_PROFILE`: `CertificateProfileName`
16//! - `CODE_SIGN_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//! - `CODE_SIGN_TIMESTAMP_URL`: (optional) RFC 3161 timestamp server URL.
24//!   Defaults to `http://timestamp.acs.microsoft.com` for Azure Trusted Signing.
25//! - `CODE_SIGN_TOOL_PATH`: (optional) explicit path to `signtool.exe`
26//! - `CODE_SIGN_DESCRIPTION`: (optional) description shown in UAC prompts (`/d`)
27//!
28
29use 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
38/// Default timestamp server for Azure Trusted Signing.
39///
40/// Azure certificates have a 3-day validity, so timestamping is mandatory for signatures to
41/// remain valid after the certificate expires.
42const 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/// The signing method — either a local certificate or Azure Trusted Signing.
73#[derive(Debug)]
74enum SigningMethod {
75    /// Local `.pfx` certificate file.
76    Certificate {
77        certificate_path: PathBuf,
78        certificate_password: Secret<String>,
79    },
80    /// Azure Trusted Signing via the dlib plugin.
81    Azure {
82        dlib_path: PathBuf,
83        /// Temporary directory holding the generated `metadata.json`.
84        /// Kept alive for the lifetime of the signer.
85        _metadata_dir: tempfile::TempDir,
86        metadata_path: PathBuf,
87    },
88}
89
90/// Configuration for Windows signtool signing.
91#[derive(Debug)]
92pub struct WindowsSigner {
93    signtool_path: PathBuf,
94    method: SigningMethod,
95    timestamp_url: Option<String>,
96    /// Description shown in UAC prompts (signtool `/d` flag).
97    description: Option<String>,
98}
99
100impl WindowsSigner {
101    /// Construct from environment variables.
102    ///
103    /// Checks for certificate-based signing first, then Azure Trusted Signing.
104    ///
105    /// Returns `Ok(None)` when no signing variables are set.
106    ///
107    /// # Errors
108    ///
109    /// - [`SigntoolConfigError::IncompleteCertificateConfiguration`] when only some certificate
110    ///   variables are set.
111    /// - [`SigntoolConfigError::IncompleteAzureConfiguration`] when only some Azure variables
112    ///   are set.
113    /// - [`SigntoolConfigError::AzureMetadataWrite`] when generating Azure metadata fails.
114    pub fn from_env() -> Result<Option<Self>, SigntoolConfigError> {
115        // Try certificate-based signing first.
116        if let Some(signer) = Self::from_env_certificate()? {
117            return Ok(Some(signer));
118        }
119        // Fall back to Azure Trusted Signing.
120        Self::from_env_azure()
121    }
122
123    /// Try to construct a certificate-based signer from environment variables.
124    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    /// Try to construct an Azure Trusted Signing signer from environment variables.
161    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    /// Sign a file with signtool.
237    ///
238    /// If the file is already Authenticode-signed, it is skipped. Unlike macOS `codesign --force`
239    /// which replaces existing signatures, `signtool` adds nested signatures — so repeatedly
240    /// signing the same file would accumulate signatures and grow the file.
241    ///
242    /// # Errors
243    ///
244    /// - [`SigntoolError::NonUtf8Path`] if a path argument is not valid UTF-8.
245    /// - [`SigntoolError::Sign`] if signtool cannot be spawned or exits with a non-zero status.
246    pub fn sign(&self, path: &Path) -> Result<(), SigntoolError> {
247        // Check if the file is already signed to avoid accumulating nested signatures.
248        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    /// Check whether a file already has a valid Authenticode signature.
313    ///
314    /// Returns `false` if verification fails or signtool cannot be run (e.g., freshly built
315    /// binaries). This is a best-effort check to avoid accumulating nested signatures.
316    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
329/// Build the Azure Trusted Signing `metadata.json` content.
330///
331/// We format this manually to avoid a serde dependency for four fields.
332fn build_azure_metadata(
333    endpoint: &str,
334    account: &str,
335    cert_profile: &str,
336    correlation_id: Option<&str>,
337) -> String {
338    // Escape JSON string values to handle any special characters.
339    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
357/// Escape a string for safe embedding in a JSON string value.
358fn 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                // Unicode escape for control characters.
370                let _ = write!(out, "\\u{:04x}", c as u32);
371            }
372            c => out.push(c),
373        }
374    }
375    out
376}
377
378/// Read `CODE_SIGN_TOOL_PATH` from the environment or fall back to `signtool.exe`.
379fn 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        // With no env vars set, strict parsing should return Ok(None).
392        // (This test assumes no CODE_SIGN_* vars are set in the test environment.)
393        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}