From 6d175ea63e07063f348e1ef4b09f6a9d9ba897f0 Mon Sep 17 00:00:00 2001 From: Marc-Andre Moreau Date: Thu, 25 Jun 2026 13:56:10 -0400 Subject: [PATCH] Add automatic AuthRoot cache Add a portable AuthRoot cache resolver with stale refresh and environment overrides, wire it into portable trust verification, and align the PowerShell module cache behavior. Normalize exact duplicate certificates in Authenticode SignedData certificate bags so Windows-valid Azure Artifact Signing signatures parse in the strict Rust CMS stack. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 13 + PowerShell/README.md | 2 +- README.md | 4 +- crates/psign-authenticode-trust/Cargo.toml | 3 +- .../src/authroot_cache.rs | 431 ++++++++++++++++++ crates/psign-authenticode-trust/src/lib.rs | 1 + crates/psign-authenticode-trust/src/online.rs | 43 +- .../src/trust_verify_pe.rs | 4 +- crates/psign-digest-cli/src/main.rs | 22 +- crates/psign-sip-digest/src/pkcs7_wire.rs | 235 +++++++++- crates/psign-sip-digest/src/verify_pe.rs | 9 +- docs/authenticode-trust-stack.md | 4 +- docs/authroot-linux-verify.md | 26 +- docs/gap-analysis-signing-platforms.md | 2 +- docs/migration-artifact-signing.md | 4 +- docs/migration-azuresigntool.md | 2 +- docs/psign-cli-matrix.json | 2 +- docs/roadmap-authenticode-linux.md | 2 +- .../Trust/AuthRootCache.cs | 40 +- src/lib.rs | 87 +++- tests/cli_pe_digest.rs | 14 + 21 files changed, 881 insertions(+), 69 deletions(-) create mode 100644 crates/psign-authenticode-trust/src/authroot_cache.rs diff --git a/Cargo.lock b/Cargo.lock index 8b5ad3b..9171b22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2062,6 +2062,7 @@ dependencies = [ "rcgen", "rsa 0.9.10", "serde", + "serde_json", "sha1 0.10.6", "sha2 0.10.9", "tempfile", @@ -3042,12 +3043,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -3056,6 +3059,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" diff --git a/PowerShell/README.md b/PowerShell/README.md index 87aede7..065d02e 100644 --- a/PowerShell/README.md +++ b/PowerShell/README.md @@ -95,7 +95,7 @@ New-PSDrive -Name certs -PSProvider PortableCertStore -Root ./project-certs ## Trust Model -By default, `Get-PsignSignature` automatically downloads and caches the Microsoft AuthRoot CAB (~350KB) for trust evaluation. The cache lives at `~/.psign/authroot/`. +By default, `Get-PsignSignature`, `Test-PsignFileCatalog`, and `Test-PsignModule` automatically download and cache the Microsoft AuthRoot CAB for trust evaluation when no explicit trust anchors are supplied. The cache lives at `~/.psign/authroot/` and is refreshed when it is older than 7 days. Set `PSIGN_AUTHROOT_MAX_AGE_DAYS`, `PSIGN_AUTHROOT_CACHE_DIR`, or `PSIGN_AUTHROOT_URL` to override the stale window, cache directory, or source URL. ```powershell # Disable auto-trust diff --git a/README.md b/README.md index ef342c6..88f3e03 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,9 @@ cargo build -p psign --bin psign-tool --locked # Portable PE signing with a local RSA key: # psign-tool portable sign-pe --cert cert.der --key key.pk8 --output signed.exe unsigned.exe # Existing PE signatures are replaced by default; add --append-signature to match signtool /as. -# Portable trust verification with explicit anchors: +# Portable trust verification downloads/caches Microsoft AuthRoot automatically when no anchors are supplied: +# psign-tool portable trust-verify-pe signed.exe +# Explicit anchors still override auto trust: # psign-tool portable trust-verify-pe signed.exe --anchor-dir anchors # Portable custom ZIP Authenticode verification: # psign-tool portable trust-verify-zip archive.zip --anchor-dir anchors diff --git a/crates/psign-authenticode-trust/Cargo.toml b/crates/psign-authenticode-trust/Cargo.toml index c4cdd3e..df632e8 100644 --- a/crates/psign-authenticode-trust/Cargo.toml +++ b/crates/psign-authenticode-trust/Cargo.toml @@ -10,6 +10,7 @@ repository.workspace = true anyhow = "1" base64 = "0.22" serde = { version = "1", features = ["derive"] } +serde_json = "1" cms = "0.2.3" der = { version = "0.7", features = ["derive"] } digest = "0.10" @@ -20,7 +21,7 @@ authenticode = { version = "0.5.0", features = ["std", "object"] } psign-sip-digest = { path = "../psign-sip-digest" } picky = { version = "7.0.0-rc.23", features = ["pkcs7", "time_conversion"] } picky-asn1-x509 = "0.15.4" -time = "0.3" +time = { version = "0.3", features = ["formatting", "parsing"] } x509-cert = "0.2.5" cab = "0.6" diff --git a/crates/psign-authenticode-trust/src/authroot_cache.rs b/crates/psign-authenticode-trust/src/authroot_cache.rs new file mode 100644 index 0000000..9bb0801 --- /dev/null +++ b/crates/psign-authenticode-trust/src/authroot_cache.rs @@ -0,0 +1,431 @@ +//! Automatic Microsoft AuthRoot CAB cache for portable trust verification. + +use crate::policy::OnlineTrustOptions; +use anyhow::{Context, Result, anyhow}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +pub const AUTHROOT_CAB_URL: &str = + "http://ctldl.windowsupdate.com/msdownload/update/v3/static/trustedr/en/authrootstl.cab"; +pub const AUTHROOT_CAB_FILE_NAME: &str = "authrootstl.cab"; +pub const AUTHROOT_META_FILE_NAME: &str = "authrootstl.cab.json"; +pub const DEFAULT_MAX_AGE_DAYS: u64 = 7; +pub const DEFAULT_TIMEOUT_SECS: u64 = 30; +pub const DEFAULT_MAX_DOWNLOAD_BYTES: usize = 2 * 1024 * 1024; + +const NO_AUTO_TRUST_ENV: &str = "PSIGN_NO_AUTO_TRUST"; +const MAX_AGE_DAYS_ENV: &str = "PSIGN_AUTHROOT_MAX_AGE_DAYS"; +const CACHE_DIR_ENV: &str = "PSIGN_AUTHROOT_CACHE_DIR"; +const SOURCE_URL_ENV: &str = "PSIGN_AUTHROOT_URL"; + +#[derive(Debug, Clone)] +pub struct AuthRootCacheOptions { + pub cache_dir: PathBuf, + pub source_url: String, + pub max_age: Duration, + pub timeout: Duration, + pub max_download_bytes: usize, +} + +impl AuthRootCacheOptions { + pub fn from_env() -> Result { + Ok(Self { + cache_dir: authroot_cache_dir_from_env()?, + source_url: std::env::var(SOURCE_URL_ENV) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| AUTHROOT_CAB_URL.to_string()), + max_age: Duration::from_secs(max_age_days_from_env() * 24 * 60 * 60), + timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS), + max_download_bytes: DEFAULT_MAX_DOWNLOAD_BYTES, + }) + } +} + +#[derive(Debug, Clone)] +pub struct AuthRootCacheResolution { + pub path: PathBuf, + pub refreshed: bool, + pub stale_fallback: bool, + pub refresh_error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AuthRootMeta { + #[serde(default)] + downloaded_at_utc: Option, + #[serde(default)] + downloaded_at_unix_secs: Option, + #[serde(default)] + source_url: String, + #[serde(default)] + size_bytes: u64, + #[serde(default)] + sha256: Option, +} + +pub fn is_auto_trust_disabled() -> bool { + std::env::var(NO_AUTO_TRUST_ENV) + .ok() + .is_some_and(|value| is_auto_trust_disabled_value(&value)) +} + +pub fn get_or_download_authroot_cab_from_env() -> Result> { + if is_auto_trust_disabled() { + return Ok(None); + } + let options = AuthRootCacheOptions::from_env()?; + Ok(Some(get_or_download_authroot_cab(&options)?)) +} + +pub fn get_or_download_authroot_cab( + options: &AuthRootCacheOptions, +) -> Result { + get_or_download_authroot_cab_with(options, fetch_authroot_cab_bytes) +} + +fn get_or_download_authroot_cab_with( + options: &AuthRootCacheOptions, + fetch: F, +) -> Result +where + F: Fn(&AuthRootCacheOptions) -> Result>, +{ + let cab_path = options.cache_dir.join(AUTHROOT_CAB_FILE_NAME); + let meta_path = options.cache_dir.join(AUTHROOT_META_FILE_NAME); + + if cab_path.exists() && !is_stale(&cab_path, &meta_path, options.max_age)? { + return Ok(AuthRootCacheResolution { + path: cab_path, + refreshed: false, + stale_fallback: false, + refresh_error: None, + }); + } + + let cab_bytes = match fetch(options) + .with_context(|| format!("download AuthRoot CAB from {}", options.source_url)) + .and_then(|bytes| { + if bytes.is_empty() { + Err(anyhow!( + "download AuthRoot CAB from {} returned an empty response", + options.source_url + )) + } else { + Ok(bytes) + } + }) { + Ok(bytes) => bytes, + Err(error) if cab_path.exists() => { + return Ok(AuthRootCacheResolution { + path: cab_path, + refreshed: false, + stale_fallback: true, + refresh_error: Some(error.to_string()), + }); + } + Err(error) => return Err(error), + }; + + cache_authroot_cab_bytes(options, &cab_path, &meta_path, &cab_bytes)?; + Ok(AuthRootCacheResolution { + path: cab_path, + refreshed: true, + stale_fallback: false, + refresh_error: None, + }) +} + +fn is_auto_trust_disabled_value(value: &str) -> bool { + let value = value.trim(); + value == "1" || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes") +} + +fn max_age_days_from_env() -> u64 { + std::env::var(MAX_AGE_DAYS_ENV) + .ok() + .and_then(|value| max_age_days_from_value(&value)) + .unwrap_or(DEFAULT_MAX_AGE_DAYS) +} + +fn max_age_days_from_value(value: &str) -> Option { + let days = value.trim().parse::().ok()?; + (days > 0).then_some(days) +} + +fn authroot_cache_dir_from_env() -> Result { + if let Some(dir) = std::env::var_os(CACHE_DIR_ENV).filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(dir)); + } + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .ok_or_else(|| { + anyhow!("could not determine home directory for AuthRoot cache (set {CACHE_DIR_ENV})") + })?; + Ok(PathBuf::from(home).join(".psign").join("authroot")) +} + +fn is_stale(cab_path: &Path, meta_path: &Path, max_age: Duration) -> Result { + if !cab_path.exists() || !meta_path.exists() { + return Ok(true); + } + let Ok(meta) = read_meta(meta_path) else { + return Ok(true); + }; + let Some(downloaded_at) = meta_downloaded_at(&meta) else { + return Ok(true); + }; + let Ok(age) = SystemTime::now().duration_since(downloaded_at) else { + return Ok(false); + }; + Ok(age > max_age) +} + +fn read_meta(meta_path: &Path) -> Result { + let text = std::fs::read_to_string(meta_path) + .with_context(|| format!("read AuthRoot cache metadata {}", meta_path.display()))?; + serde_json::from_str(&text) + .with_context(|| format!("parse AuthRoot cache metadata {}", meta_path.display())) +} + +fn meta_downloaded_at(meta: &AuthRootMeta) -> Option { + if let Some(secs) = meta.downloaded_at_unix_secs + && secs >= 0 + { + return Some(UNIX_EPOCH + Duration::from_secs(secs as u64)); + } + let downloaded_at = meta.downloaded_at_utc.as_deref()?; + let parsed = OffsetDateTime::parse(downloaded_at, &Rfc3339).ok()?; + let secs = parsed.unix_timestamp(); + (secs >= 0).then_some(UNIX_EPOCH + Duration::from_secs(secs as u64)) +} + +fn cache_authroot_cab_bytes( + options: &AuthRootCacheOptions, + cab_path: &Path, + meta_path: &Path, + cab_bytes: &[u8], +) -> Result<()> { + std::fs::create_dir_all(&options.cache_dir).with_context(|| { + format!( + "create AuthRoot cache directory {}", + options.cache_dir.display() + ) + })?; + + let digest = Sha256::digest(cab_bytes); + let tmp_path = cab_path.with_file_name(format!( + "{AUTHROOT_CAB_FILE_NAME}.tmp-{}", + std::process::id() + )); + std::fs::write(&tmp_path, cab_bytes) + .with_context(|| format!("write temporary AuthRoot CAB {}", tmp_path.display()))?; + replace_file(&tmp_path, cab_path) + .with_context(|| format!("cache AuthRoot CAB at {}", cab_path.display()))?; + + let meta = AuthRootMeta { + downloaded_at_utc: Some(rfc3339_now()), + downloaded_at_unix_secs: Some(current_unix_secs()), + source_url: options.source_url.clone(), + size_bytes: cab_bytes.len() as u64, + sha256: Some(hex_lower(&digest)), + }; + let meta_json = + serde_json::to_string_pretty(&meta).context("serialize AuthRoot cache metadata")?; + std::fs::write(meta_path, meta_json) + .with_context(|| format!("write AuthRoot cache metadata {}", meta_path.display()))?; + Ok(()) +} + +fn fetch_authroot_cab_bytes(options: &AuthRootCacheOptions) -> Result> { + let online = OnlineTrustOptions { + timeout: options.timeout, + max_download_bytes: options.max_download_bytes, + ..OnlineTrustOptions::default() + }; + crate::online::http_get_limited(&options.source_url, &online) +} + +fn replace_file(tmp_path: &Path, dest_path: &Path) -> Result<()> { + match std::fs::rename(tmp_path, dest_path) { + Ok(()) => Ok(()), + Err(first_error) if dest_path.exists() => { + std::fs::remove_file(dest_path) + .with_context(|| format!("remove old {}", dest_path.display()))?; + std::fs::rename(tmp_path, dest_path).map_err(|second_error| { + anyhow!( + "rename {} to {} failed after removing old file (first: {first_error}; second: {second_error})", + tmp_path.display(), + dest_path.display() + ) + }) + } + Err(error) => Err(error) + .with_context(|| format!("rename {} to {}", tmp_path.display(), dest_path.display())), + } +} + +fn current_unix_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +fn rfc3339_now() -> String { + OffsetDateTime::from_unix_timestamp(current_unix_secs()) + .ok() + .and_then(|instant| instant.format(&Rfc3339).ok()) + .unwrap_or_else(|| current_unix_secs().to_string()) +} + +fn hex_lower(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn options(cache_dir: PathBuf, source_url: String) -> AuthRootCacheOptions { + AuthRootCacheOptions { + cache_dir, + source_url, + max_age: Duration::from_secs(24 * 60 * 60), + timeout: Duration::from_secs(2), + max_download_bytes: 1024 * 1024, + } + } + + fn test_url() -> String { + "http://example.invalid/authrootstl.cab".to_string() + } + + fn write_meta(path: &Path, downloaded_at: SystemTime) { + let secs = downloaded_at + .duration_since(UNIX_EPOCH) + .expect("downloaded_at before epoch") + .as_secs() as i64; + let meta = AuthRootMeta { + downloaded_at_utc: Some( + OffsetDateTime::from_unix_timestamp(secs) + .expect("timestamp") + .format(&Rfc3339) + .expect("format"), + ), + downloaded_at_unix_secs: Some(secs), + source_url: AUTHROOT_CAB_URL.to_string(), + size_bytes: 3, + sha256: Some("00".repeat(32)), + }; + std::fs::write( + path, + serde_json::to_string_pretty(&meta).expect("meta json"), + ) + .expect("write meta"); + } + + #[test] + fn auto_trust_disabled_value_matches_powershell_values() { + assert!(is_auto_trust_disabled_value("1")); + assert!(is_auto_trust_disabled_value("true")); + assert!(is_auto_trust_disabled_value("YES")); + assert!(!is_auto_trust_disabled_value("0")); + assert!(!is_auto_trust_disabled_value("false")); + } + + #[test] + fn invalid_max_age_values_are_ignored() { + assert_eq!(max_age_days_from_value("7"), Some(7)); + assert_eq!(max_age_days_from_value("0"), None); + assert_eq!(max_age_days_from_value("-1"), None); + assert_eq!(max_age_days_from_value("abc"), None); + } + + #[test] + fn fresh_cache_is_used_without_download() { + let dir = tempfile::tempdir().expect("tempdir"); + let cab_path = dir.path().join(AUTHROOT_CAB_FILE_NAME); + let meta_path = dir.path().join(AUTHROOT_META_FILE_NAME); + std::fs::write(&cab_path, b"old").expect("write cab"); + write_meta(&meta_path, SystemTime::now()); + + let resolved = get_or_download_authroot_cab_with( + &options(dir.path().to_path_buf(), test_url()), + |_| panic!("fresh cache should not download"), + ) + .expect("resolve"); + + assert_eq!(resolved.path, cab_path); + assert!(!resolved.refreshed); + assert!(!resolved.stale_fallback); + assert_eq!(std::fs::read(&resolved.path).expect("read cab"), b"old"); + } + + #[test] + fn stale_cache_refreshes_from_source_url() { + let dir = tempfile::tempdir().expect("tempdir"); + let cab_path = dir.path().join(AUTHROOT_CAB_FILE_NAME); + let meta_path = dir.path().join(AUTHROOT_META_FILE_NAME); + std::fs::write(&cab_path, b"old").expect("write cab"); + write_meta( + &meta_path, + SystemTime::now() - Duration::from_secs(2 * 24 * 60 * 60), + ); + + let resolved = get_or_download_authroot_cab_with( + &options(dir.path().to_path_buf(), test_url()), + |_| Ok(b"new".to_vec()), + ) + .expect("resolve"); + + assert!(resolved.refreshed); + assert!(!resolved.stale_fallback); + assert_eq!(std::fs::read(&resolved.path).expect("read cab"), b"new"); + } + + #[test] + fn malformed_metadata_is_treated_as_stale() { + let dir = tempfile::tempdir().expect("tempdir"); + let cab_path = dir.path().join(AUTHROOT_CAB_FILE_NAME); + let meta_path = dir.path().join(AUTHROOT_META_FILE_NAME); + std::fs::write(&cab_path, b"old").expect("write cab"); + std::fs::write(&meta_path, b"not json").expect("write bad meta"); + + let resolved = get_or_download_authroot_cab_with( + &options(dir.path().to_path_buf(), test_url()), + |_| Ok(b"new".to_vec()), + ) + .expect("resolve"); + + assert!(resolved.refreshed); + assert!(!resolved.stale_fallback); + assert_eq!(std::fs::read(&resolved.path).expect("read cab"), b"new"); + } + + #[test] + fn stale_cache_falls_back_when_refresh_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let cab_path = dir.path().join(AUTHROOT_CAB_FILE_NAME); + let meta_path = dir.path().join(AUTHROOT_META_FILE_NAME); + std::fs::write(&cab_path, b"old").expect("write cab"); + write_meta( + &meta_path, + SystemTime::now() - Duration::from_secs(2 * 24 * 60 * 60), + ); + + let resolved = get_or_download_authroot_cab_with( + &options(dir.path().to_path_buf(), test_url()), + |_| Err(anyhow::anyhow!("download failed")), + ) + .expect("resolve"); + + assert!(!resolved.refreshed); + assert!(resolved.stale_fallback); + assert!(resolved.refresh_error.is_some()); + assert_eq!(std::fs::read(&resolved.path).expect("read cab"), b"old"); + } +} diff --git a/crates/psign-authenticode-trust/src/lib.rs b/crates/psign-authenticode-trust/src/lib.rs index 2803455..67734fc 100644 --- a/crates/psign-authenticode-trust/src/lib.rs +++ b/crates/psign-authenticode-trust/src/lib.rs @@ -8,6 +8,7 @@ pub mod anchor; pub mod authroot_cab; +pub mod authroot_cache; pub mod authroot_ctl; pub mod chain; pub mod inspect; diff --git a/crates/psign-authenticode-trust/src/online.rs b/crates/psign-authenticode-trust/src/online.rs index 60984f1..aab9a7f 100644 --- a/crates/psign-authenticode-trust/src/online.rs +++ b/crates/psign-authenticode-trust/src/online.rs @@ -632,8 +632,8 @@ fn parse_http_uri_general_names(input: &[u8]) -> Result> { Ok(urls) } -fn http_get_limited(url: &str, options: &OnlineTrustOptions) -> Result> { - http_request_limited(url, "GET", None, options) +pub(crate) fn http_get_limited(url: &str, options: &OnlineTrustOptions) -> Result> { + http_request_limited(url, "GET", None, options, "portable online trust") } fn http_post_limited( @@ -643,7 +643,13 @@ fn http_post_limited( body: &[u8], options: &OnlineTrustOptions, ) -> Result> { - http_request_limited(url, "POST", Some((content_type, accept, body)), options) + http_request_limited( + url, + "POST", + Some((content_type, accept, body)), + options, + "portable online trust", + ) } fn http_request_limited( @@ -651,10 +657,11 @@ fn http_request_limited( method: &str, body: Option<(&str, &str, &[u8])>, options: &OnlineTrustOptions, + context: &str, ) -> Result> { let without_scheme = url .strip_prefix("http://") - .ok_or_else(|| anyhow!("only http:// AIA URLs are supported by portable online trust"))?; + .ok_or_else(|| anyhow!("only http:// URLs are supported by {context}"))?; let (authority, path) = without_scheme .split_once('/') .map(|(a, p)| (a, format!("/{p}"))) @@ -664,17 +671,17 @@ fn http_request_limited( .and_then(|(h, p)| Some((h, p.parse::().ok()?))) .unwrap_or((authority, 80)); if host.is_empty() { - return Err(anyhow!("AIA URL host is empty")); + return Err(anyhow!("{context} URL host is empty")); } let mut stream = TcpStream::connect((host, port)).with_context(|| format!("connect {host}:{port}"))?; stream .set_read_timeout(Some(options.timeout)) - .context("set AIA read timeout")?; + .with_context(|| format!("set {context} read timeout"))?; stream .set_write_timeout(Some(options.timeout)) - .context("set AIA write timeout")?; + .with_context(|| format!("set {context} write timeout"))?; match body { Some((content_type, accept, body)) => { write!( @@ -699,35 +706,37 @@ fn http_request_limited( let mut response = Vec::new(); let mut tmp = [0u8; 8192]; loop { - let n = stream.read(&mut tmp).context("read AIA HTTP response")?; + let n = stream + .read(&mut tmp) + .with_context(|| format!("read {context} HTTP response"))?; if n == 0 { break; } response.extend_from_slice(&tmp[..n]); if response.len() > options.max_download_bytes + 64 * 1024 { - return Err(anyhow!("AIA HTTP response exceeds configured size limit")); + return Err(anyhow!( + "{context} HTTP response exceeds configured size limit" + )); } } let header_end = find_header_end(&response) - .ok_or_else(|| anyhow!("AIA HTTP response has no header terminator"))?; - let headers = - std::str::from_utf8(&response[..header_end]).context("AIA HTTP headers are not UTF-8")?; + .ok_or_else(|| anyhow!("{context} HTTP response has no header terminator"))?; + let headers = std::str::from_utf8(&response[..header_end]) + .with_context(|| format!("{context} HTTP headers are not UTF-8"))?; let status = headers .lines() .next() .and_then(|line| line.split_whitespace().nth(1)) .and_then(|s| s.parse::().ok()) - .ok_or_else(|| anyhow!("AIA HTTP response has no status code"))?; + .ok_or_else(|| anyhow!("{context} HTTP response has no status code"))?; if status != 200 { - return Err(anyhow!("AIA HTTP GET returned status {status}")); + return Err(anyhow!("{context} HTTP GET returned status {status}")); } let body_start = header_end + 4; let body = response[body_start..].to_vec(); if body.len() > options.max_download_bytes { - return Err(anyhow!( - "AIA issuer certificate exceeds configured size limit" - )); + return Err(anyhow!("{context} download exceeds configured size limit")); } Ok(body) } diff --git a/crates/psign-authenticode-trust/src/trust_verify_pe.rs b/crates/psign-authenticode-trust/src/trust_verify_pe.rs index df0357b..1eb1d61 100644 --- a/crates/psign-authenticode-trust/src/trust_verify_pe.rs +++ b/crates/psign-authenticode-trust/src/trust_verify_pe.rs @@ -10,6 +10,7 @@ use picky::x509::certificate::Cert; use picky::x509::date::UtcDate; use picky::x509::pkcs7::authenticode::AuthenticodeSignature; use psign_sip_digest::pe_digest::{PeAuthenticodeHashKind, pe_authenticode_digest}; +use psign_sip_digest::pkcs7_wire::normalize_pkcs7_der_for_authenticode; use psign_sip_digest::verify_pe::for_each_pe_pkcs7_signed_data; use sha2::Digest; @@ -109,7 +110,8 @@ fn verify_one_pkcs7( anchor_certs: &[Cert], opts: &TrustVerifyPeOptions, ) -> Result<()> { - let sig_authenticode = authenticode::AuthenticodeSignature::from_bytes(pkcs7_der) + let normalized = normalize_pkcs7_der_for_authenticode(pkcs7_der); + let sig_authenticode = authenticode::AuthenticodeSignature::from_bytes(normalized.as_ref()) .map_err(|e| anyhow!("authenticode-rs PKCS#7 parse (digest probe): {e}"))?; let embedded_digest = sig_authenticode.digest(); let kind = PeAuthenticodeHashKind::from_digest_byte_len(embedded_digest.len())?; diff --git a/crates/psign-digest-cli/src/main.rs b/crates/psign-digest-cli/src/main.rs index 138c9d6..8723730 100644 --- a/crates/psign-digest-cli/src/main.rs +++ b/crates/psign-digest-cli/src/main.rs @@ -154,13 +154,14 @@ fn trust_verify_options_from_shared(a: &TrustVerifySharedArgs) -> Result Some(parse_verification_date_ymd(s)?), None => None, }; + let authroot_cab = resolve_authroot_cab_for_shared(a)?; // When using authroot_cab, automatically enable AIA fetching so the chain builder // can download missing root certificates from intermediate cert AIA extensions. - let effective_aia = a.online_aia || a.authroot_cab.is_some(); + let effective_aia = a.online_aia || authroot_cab.is_some(); Ok(TrustVerifyPeOptions { anchor_dir: a.anchor_dir.clone(), trusted_ca_files: a.trusted_ca.clone(), - authroot_cab: a.authroot_cab.clone(), + authroot_cab, expect_authroot_cab_sha256, verification_instant_override, verbose_chain: a.verbose_chain, @@ -182,6 +183,19 @@ fn trust_verify_options_from_shared(a: &TrustVerifySharedArgs) -> Result Result> { + if let Some(cab) = &a.authroot_cab { + return Ok(Some(cab.clone())); + } + if a.anchor_dir.is_some() || !a.trusted_ca.is_empty() { + return Ok(None); + } + Ok( + psign_authenticode_trust::authroot_cache::get_or_download_authroot_cab_from_env()? + .map(|resolution| resolution.path), + ) +} + fn trust_verify_args_present(a: &TrustVerifySharedArgs) -> bool { a.anchor_dir.is_some() || !a.trusted_ca.is_empty() @@ -1864,9 +1878,9 @@ enum Command { }, /// Require embedded PKCS#7; compare indirect digest to Rust PE recomputation for each Authenticode cert. VerifyPe { path: PathBuf }, - /// Verify PE Authenticode **trust**: PKCS#7 CMS validation + certificate chain to **explicit** anchors (no OS store). + /// Verify PE Authenticode **trust**: PKCS#7 CMS validation + certificate chain to portable anchors (no OS store). /// - /// Supply **`--anchor-dir`** (Phase A: `.crt`/`.cer`/`.pem`) and/or **`--authroot-cab`** (extract certs + CTL thumbs from AuthRoot-style CAB `.stl` payloads). **`verify-pe`** remains digest-only; this subcommand adds chain + policy checks. + /// Uses the automatic Microsoft AuthRoot CAB cache when no anchors are supplied. Supply **`--anchor-dir`** (Phase A: `.crt`/`.cer`/`.pem`) and/or **`--authroot-cab`** (extract certs + CTL thumbs from AuthRoot-style CAB `.stl` payloads) for explicit trust inputs. **`verify-pe`** remains digest-only; this subcommand adds chain + policy checks. TrustVerifyPe { path: PathBuf, #[command(flatten)] diff --git a/crates/psign-sip-digest/src/pkcs7_wire.rs b/crates/psign-sip-digest/src/pkcs7_wire.rs index c91921f..47eedf6 100644 --- a/crates/psign-sip-digest/src/pkcs7_wire.rs +++ b/crates/psign-sip-digest/src/pkcs7_wire.rs @@ -23,6 +23,28 @@ fn der_encode_definite_length(len: usize) -> Vec { out } +fn der_encode_tlv(tag: u8, content: &[u8]) -> Vec { + let mut out = Vec::with_capacity(1 + 8 + content.len()); + out.push(tag); + out.extend(der_encode_definite_length(content.len())); + out.extend_from_slice(content); + out +} + +#[derive(Clone, Copy, Debug)] +struct DerTlv { + tag: u8, + tag_start: usize, + value_start: usize, + value_end: usize, +} + +impl DerTlv { + fn end(self) -> usize { + self.value_end + } +} + /// First TLV is `SEQUENCE`; return payload bytes inside it (excluding tag and length). fn tlv_outer_sequence_payload(data: &[u8]) -> Option<&[u8]> { if data.first().copied()? != 0x30 { @@ -68,6 +90,120 @@ fn pkcs7_content_info_signed_data(signed_data_der: &[u8]) -> Vec { out } +fn der_tlv_at(data: &[u8], offset: usize, limit: usize) -> Option { + if offset >= limit || limit > data.len() { + return None; + } + let tag = *data.get(offset)?; + let (len, hdr) = parse_der_definite_length(data.get(offset + 1..limit)?)?; + let value_start = offset + 1 + hdr; + let value_end = value_start.checked_add(len)?; + if value_end > limit { + return None; + } + Some(DerTlv { + tag, + tag_start: offset, + value_start, + value_end, + }) +} + +fn der_tlv_children(data: &[u8], start: usize, end: usize) -> Option> { + let mut out = Vec::new(); + let mut offset = start; + while offset < end { + let tlv = der_tlv_at(data, offset, end)?; + offset = tlv.end(); + out.push(tlv); + } + (offset == end).then_some(out) +} + +fn replace_child_tlv(parent: DerTlv, child: DerTlv, replacement: &[u8], data: &[u8]) -> Vec { + let mut content = Vec::with_capacity( + parent.value_end - parent.value_start - (child.end() - child.tag_start) + replacement.len(), + ); + content.extend_from_slice(&data[parent.value_start..child.tag_start]); + content.extend_from_slice(replacement); + content.extend_from_slice(&data[child.end()..parent.value_end]); + der_encode_tlv(parent.tag, &content) +} + +fn dedupe_signed_data_certificate_set(content_info_der: &[u8]) -> Option> { + let outer = der_tlv_at(content_info_der, 0, content_info_der.len())?; + if outer.tag != 0x30 { + return None; + } + let outer_children = der_tlv_children(content_info_der, outer.value_start, outer.value_end)?; + let content_type = *outer_children.first()?; + if &content_info_der[content_type.tag_start..content_type.end()] != PKCS7_SIGNED_DATA_OID_DER { + return None; + } + let explicit = *outer_children.get(1)?; + if explicit.tag != 0xa0 { + return None; + } + let explicit_children = + der_tlv_children(content_info_der, explicit.value_start, explicit.value_end)?; + let signed_data = *explicit_children.first()?; + if signed_data.tag != 0x30 { + return None; + } + + let signed_children = der_tlv_children( + content_info_der, + signed_data.value_start, + signed_data.value_end, + )?; + let certificates = signed_children + .iter() + .copied() + .skip(3) + .find(|child| child.tag == 0xa0)?; + let certificate_children = der_tlv_children( + content_info_der, + certificates.value_start, + certificates.value_end, + )?; + + let mut unique = Vec::<&[u8]>::new(); + let mut cert_content = Vec::with_capacity(certificates.value_end - certificates.value_start); + let mut removed_duplicate = false; + for child in certificate_children { + let encoded = &content_info_der[child.tag_start..child.end()]; + if unique.contains(&encoded) { + removed_duplicate = true; + continue; + } + unique.push(encoded); + cert_content.extend_from_slice(encoded); + } + if !removed_duplicate { + return None; + } + + let certificates_deduped = der_encode_tlv(certificates.tag, &cert_content); + let signed_data_deduped = replace_child_tlv( + signed_data, + certificates, + &certificates_deduped, + content_info_der, + ); + let explicit_deduped = replace_child_tlv( + explicit, + signed_data, + &signed_data_deduped, + content_info_der, + ); + Some(replace_child_tlv( + outer, + explicit, + &explicit_deduped, + content_info_der, + )) +} + /// Total byte length of a definite-length DER TLV whose tag is **`data[0]`** (used for PKCS#7 **`SEQUENCE`** / **`0x30`**). pub fn der_tlv_total_len_from_start(data: &[u8]) -> Option { if data.first().copied()? != 0x30 { @@ -96,9 +232,106 @@ pub fn normalize_pkcs7_der_for_authenticode(sig_blob: &[u8]) -> Cow<'_, [u8]> { let Some(inner) = tlv_outer_sequence_payload(sig_blob) else { return Cow::Borrowed(sig_blob); }; - match inner.first().copied() { + let normalized = match inner.first().copied() { Some(0x06) => Cow::Borrowed(sig_blob), Some(0x02) => Cow::Owned(pkcs7_content_info_signed_data(sig_blob)), _ => Cow::Borrowed(sig_blob), + }; + match dedupe_signed_data_certificate_set(normalized.as_ref()) { + Some(deduped) => Cow::Owned(deduped), + None => normalized, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn len_bytes(len: usize) -> Vec { + der_encode_definite_length(len) + } + + fn tlv(tag: u8, content: &[u8]) -> Vec { + der_encode_tlv(tag, content) + } + + #[test] + fn normalize_removes_exact_duplicate_signed_data_certificates() { + let cert_a = tlv(0x30, b"cert-a"); + let cert_b = tlv(0x30, b"cert-b"); + let certificates = tlv( + 0xa0, + &[cert_a.as_slice(), cert_b.as_slice(), cert_a.as_slice()].concat(), + ); + let signed_data = tlv( + 0x30, + &[ + tlv(0x02, &[1]).as_slice(), + tlv(0x31, &[]).as_slice(), + tlv(0x30, &[]).as_slice(), + certificates.as_slice(), + tlv(0x31, &[]).as_slice(), + ] + .concat(), + ); + let oid = [ + 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x07, 0x02, + ]; + let content_info = tlv( + 0x30, + &[oid.as_slice(), tlv(0xa0, &signed_data).as_slice()].concat(), + ); + + let normalized = normalize_pkcs7_der_for_authenticode(&content_info); + assert!(matches!(normalized, Cow::Owned(_))); + assert_eq!( + normalized + .as_ref() + .windows(cert_a.len()) + .filter(|w| *w == cert_a) + .count(), + 1 + ); + assert_eq!( + normalized + .as_ref() + .windows(cert_b.len()) + .filter(|w| *w == cert_b) + .count(), + 1 + ); + } + + #[test] + fn normalize_preserves_pkcs7_without_duplicate_certificates() { + let cert_a = tlv(0x30, b"cert-a"); + let certificates = tlv(0xa0, &cert_a); + let signed_data = tlv( + 0x30, + &[ + tlv(0x02, &[1]).as_slice(), + tlv(0x31, &[]).as_slice(), + tlv(0x30, &[]).as_slice(), + certificates.as_slice(), + tlv(0x31, &[]).as_slice(), + ] + .concat(), + ); + let oid = [ + 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x07, 0x02, + ]; + let content_info = tlv( + 0x30, + &[oid.as_slice(), tlv(0xa0, &signed_data).as_slice()].concat(), + ); + + let normalized = normalize_pkcs7_der_for_authenticode(&content_info); + assert!(matches!(normalized, Cow::Borrowed(_))); + assert_eq!(normalized.as_ref(), content_info); + } + + #[test] + fn definite_length_helper_keeps_short_lengths_short() { + assert_eq!(len_bytes(3), vec![3]); } } diff --git a/crates/psign-sip-digest/src/verify_pe.rs b/crates/psign-sip-digest/src/verify_pe.rs index 3dfbf63..6ea7694 100644 --- a/crates/psign-sip-digest/src/verify_pe.rs +++ b/crates/psign-sip-digest/src/verify_pe.rs @@ -1,6 +1,9 @@ use super::pe_digest::{ParsedPe, PeAuthenticodeHashKind, pe_authenticode_digest}; +use crate::pkcs7_wire::normalize_pkcs7_der_for_authenticode; use anyhow::{Result, anyhow}; -use authenticode::{AttributeCertificateIterator, WIN_CERT_TYPE_PKCS_SIGNED_DATA}; +use authenticode::{ + AttributeCertificateIterator, AuthenticodeSignature, WIN_CERT_TYPE_PKCS_SIGNED_DATA, +}; #[derive(Debug, Clone)] pub struct PeDigestConsistencyResult { @@ -35,8 +38,8 @@ pub fn verify_pe_authenticode_digest_consistency( if attr.certificate_type != WIN_CERT_TYPE_PKCS_SIGNED_DATA { continue; } - let sig = attr - .get_authenticode_signature() + let normalized = normalize_pkcs7_der_for_authenticode(attr.data); + let sig = AuthenticodeSignature::from_bytes(normalized.as_ref()) .map_err(|e| anyhow!("PKCS#7 Authenticode parse failed: {e}"))?; pkcs7_count += 1; let embedded = sig.digest(); diff --git a/docs/authenticode-trust-stack.md b/docs/authenticode-trust-stack.md index db2a1f7..9f7b928 100644 --- a/docs/authenticode-trust-stack.md +++ b/docs/authenticode-trust-stack.md @@ -9,9 +9,9 @@ This describes how **`crates/psign-authenticode-trust`** composes crates for **L | PKCS#7 shell, **`SignerInfo`**, authenticated attributes | **`cms`**, **`der`** (via **`authenticode`** / **`picky`**) | Parse **`SignedData`**, locate **`messageDigest`**, carry DER blobs. | | PE layout, indirect **`SpcIndirectData`**, image digest | **`authenticode`**, **`psign-sip-digest`** | Enumerate embedded PKCS#7 from the PE certificate table; recompute **`pe_authenticode_digest`** for the embedded hash algorithm. | | CMS Authenticode rules + X.509 chain verification | **`picky`** (`AuthenticodeSignature`, `authenticode_verifier`, `Cert::verifier`) | Validate **`messageDigest`** vs provided digest, signature over authenticated attributes, TBSCertificate signatures along **`issuer_chain`**, Basic Constraints / dates / EKU policy hooks. | -| Trust anchors | This crate (**`anchor`**, **`authroot_cab`**, **`authroot_ctl`**) | Phase A: load **`*.crt`/`*.cer`/`*.pem`** from **`--anchor-dir`** or repeatable **`--trusted-ca`** files. Phase B: CAB **`*.stl`** → PKCS#7 **`SignedData`** **`eContent`** CTL parse for **SHA-1 subject identifiers** plus PKCS#7-embedded certs. | +| Trust anchors | This crate (**`anchor`**, **`authroot_cache`**, **`authroot_cab`**, **`authroot_ctl`**) | Phase A: load **`*.crt`** / **`*.cer`** / **`*.pem`** from **`--anchor-dir`** or repeatable **`--trusted-ca`** files. Phase B: automatically cache Microsoft **`authrootstl.cab`** at **`~/.psign/authroot/`** when no explicit anchors are supplied, then parse CAB **`*.stl`** → PKCS#7 **`SignedData`** **`eContent`** CTL **SHA-1 subject identifiers** plus PKCS#7-embedded certs. | | Policy knobs | **`policy::AuthenticodeTrustPolicy`** | Default **strict** code-signing EKU; CLI **`allow-loose-signing-cert`**, **`--prefer-timestamp-signing-time`** / **`--require-valid-timestamp`** (see [**Verification instant / timestamps**](#verification-instant--timestamps)), **`--as-of YYYY-MM-DD`** for **`exact_date`**. | -| Portable CLI | **`psign-tool portable`** | **`trust-verify-pe`**, **`trust-verify-cab`**, **`trust-verify-catalog`**, **`trust-verify-detached`** share anchor, AuthRoot CAB, AIA, OCSP, CRL revocation, timestamp-policy, and chain-diagnostic flags; detached uses [`pkcs7_wire::normalize_pkcs7_der_for_authenticode`](../crates/psign-sip-digest/src/pkcs7_wire.rs). **`inspect-authenticode`** emits JSON for PKCS#7 signers, timestamp-related OIDs, and nested signatures (**`1.3.6.1.4.1.311.2.4.1`**). Unified **`psign-tool --mode portable verify`** switches from digest-only verification to these trust commands when trust inputs such as **`--trusted-ca`**, **`--anchor-dir`**, **`--authroot-cab`**, **`--online-aia`**, **`--online-ocsp`**, **`--revocation-mode`**, or **`--require-valid-timestamp`** are present. | +| Portable CLI | **`psign-tool portable`** | **`trust-verify-pe`**, **`trust-verify-cab`**, **`trust-verify-catalog`**, **`trust-verify-detached`** share anchor, AuthRoot CAB/cache, AIA, OCSP, CRL revocation, timestamp-policy, and chain-diagnostic flags; detached uses [`pkcs7_wire::normalize_pkcs7_der_for_authenticode`](../crates/psign-sip-digest/src/pkcs7_wire.rs). **`inspect-authenticode`** emits JSON for PKCS#7 signers, timestamp-related OIDs, and nested signatures (**`1.3.6.1.4.1.311.2.4.1`**). Unified **`psign-tool --mode portable verify`** uses the portable trust commands by default for supported formats when automatic AuthRoot is enabled; **`PSIGN_NO_AUTO_TRUST=1`** restores digest-only routing unless explicit trust inputs are present. | | CMS inspection (no trust decision) | This crate **`inspect`** | Uses **`cms`** **`SignedData`** + **`authenticode`** digest probe; complements picky **`trust_*`** paths. See [**psa-interoperability.md**](psa-interoperability.md). | ## Verification order (per PKCS#7 blob) diff --git a/docs/authroot-linux-verify.md b/docs/authroot-linux-verify.md index e187cf4..b08f42e 100644 --- a/docs/authroot-linux-verify.md +++ b/docs/authroot-linux-verify.md @@ -1,6 +1,10 @@ # AuthRoot-style anchors on Linux -Windows resolves Authenticode chains against machine/user certificate stores plus **Microsoft Authenticode roots**. On Linux, **`psign-tool portable trust-verify-pe`** requires you to supply **explicit roots** (and optionally intermediates embedded in the PE PKCS#7). +Windows resolves Authenticode chains against machine/user certificate stores plus **Microsoft Authenticode roots**. On Linux/macOS, portable trust verification can use explicit roots, or it can automatically download and cache Microsoft’s **`authrootstl.cab`** when no explicit roots are supplied. + +By default, **`psign-tool portable trust-verify-*`** and bare **`psign-tool --mode portable verify`** download **`authrootstl.cab`** from Microsoft’s Windows Update distribution URL when the cache is missing or stale. The cache lives at **`~/.psign/authroot/authrootstl.cab`** with metadata in **`authrootstl.cab.json`**. The default refresh window is **7 days**. + +Set **`PSIGN_NO_AUTO_TRUST=1`** (also accepts `true` or `yes`) to disable automatic AuthRoot use. Set **`PSIGN_AUTHROOT_MAX_AGE_DAYS=`** to change the staleness window. Advanced/offline environments can set **`PSIGN_AUTHROOT_CACHE_DIR`** or **`PSIGN_AUTHROOT_URL`** for an alternate cache location or mirror. Explicit **`--authroot-cab`**, **`--anchor-dir`**, or repeatable **`--trusted-ca`** inputs take precedence and suppress automatic AuthRoot resolution. ## Phase A — anchor directory (recommended first ship) @@ -20,14 +24,26 @@ Keep separate what you **trust as an anchor** vs what happens to be embedded in 1. Harvests **X.509 certificates** from PKCS#7 blobs (`Pkcs7::from_der` on each member). 2. Parses PKCS#7 **`ContentInfo` → `SignedData`** when present and extracts CTL **`eContent`** **TrustedSubject** SHA-1 **`SubjectIdentifier`** octets into the anchor thumbprint set (alongside cert-derived thumbs). -Pass **`--authroot-cab /path/to/authrootstl.cab`** on any **`trust-verify-*`** subcommand. +Pass **`--authroot-cab /path/to/authrootstl.cab`** on any **`trust-verify-*`** subcommand to use a pinned or mirrored CAB instead of the automatic cache. ### Bootstrap integrity -- **CI / reproducibility:** pin a **SHA-256** of the CAB; pass **`--expect-authroot-cab-sha256 <64-hex>`** so ingest aborts on mismatch. +- **CI / reproducibility:** pin a **SHA-256** of the CAB; pass **`--authroot-cab`** and **`--expect-authroot-cab-sha256 <64-hex>`** so ingest aborts on mismatch. - **Future hardening:** verify the **outer** Authenticode signature / CTL semantics on the STL (see technical plan). -## Example +## Examples + +Default portable trust using the automatic AuthRoot cache: + +```bash +psign-tool portable trust-verify-pe ./signed.exe +``` + +Bare portable verify also routes to trust verification for supported formats when auto trust is enabled: + +```bash +psign-tool --mode portable verify ./signed.exe +``` ```bash psign-tool portable trust-verify-pe \ @@ -43,7 +59,7 @@ psign-tool portable trust-verify-pe \ ./signed.exe ``` -The unified CLI can use the same trust path without writing to the Windows or Linux OS trust store. With **`--mode portable verify`**, trust inputs such as **`--trusted-ca`**, **`--anchor-dir`**, **`--authroot-cab`**, AIA/OCSP/CRL flags, and timestamp policy flags route to the corresponding portable **`trust-verify-*`** command inferred from the subject file: +The unified CLI uses the same trust path without writing to the Windows or Linux OS trust store. With **`--mode portable verify`**, supported formats route to the corresponding portable **`trust-verify-*`** command by default when automatic AuthRoot is enabled. Explicit trust inputs such as **`--trusted-ca`**, **`--anchor-dir`**, **`--authroot-cab`**, AIA/OCSP/CRL flags, and timestamp policy flags still route to the same trust commands: ```bash psign-tool --mode portable verify \ diff --git a/docs/gap-analysis-signing-platforms.md b/docs/gap-analysis-signing-platforms.md index 76fef19..08156e0 100644 --- a/docs/gap-analysis-signing-platforms.md +++ b/docs/gap-analysis-signing-platforms.md @@ -197,7 +197,7 @@ Portable support is intentionally split by lifecycle stage. This keeps Linux/mac |-----------------|-----------------------------------|----------------------------|---------------| | Digest computation | Routed through `verify` only when it can infer a supported subject format | `pe-digest`, `cab-digest`, and format-specific `verify-*` commands | Supported for PE/WinMD, CAB, MSI/MSP, WIM/ESD, cleartext MSIX/AppX, catalogs, and scripts | | PKCS#7 inspection / extraction | `inspect-signature` routes to `inspect-authenticode` | `inspect-authenticode`, `inspect-pkcs7`, `extract-pkcx-pkcs7`, `extract-pe-pkcs7`, `extract-cab-pkcs7`, `extract-msi-pkcs7`, `list-pe-pkcs7` | Supported diagnostics; no trust decision by itself | -| Explicit-anchor trust verification | `verify` routes only when portable trust inputs are present and the inferred format has a trust command | `trust-verify-pe`, `trust-verify-cab`, `trust-verify-msi`, `trust-verify-esd`, `trust-verify-catalog`, `trust-verify-detached` | Supported with explicit anchors and bounded online AIA/OCSP/CRL; not OS store policy | +| Portable trust verification | `verify` routes to portable trust commands by default when automatic AuthRoot is enabled; explicit trust inputs still force trust routing | `trust-verify-pe`, `trust-verify-cab`, `trust-verify-msi`, `trust-verify-esd`, `trust-verify-catalog`, `trust-verify-detached` | Supported with automatic AuthRoot CAB cache or explicit anchors plus bounded online AIA/OCSP/CRL; not OS store policy | | Remote hash/signing | PE Key Vault signing through top-level `sign`; other remote helpers are not routed | `sign-pe --azure-key-vault-*`, `artifact-signing-submit`, `azure-key-vault-sign-digest`, signer prehash commands | PE Key Vault signing embeds Authenticode; other remote helpers are digest-in/signature-out only | | Local-key signing | Top-level `sign` returns an explicit portable-not-implemented error | `sign-pe`, `sign-cab`, `sign-msi`, `sign-catalog`, `rdp` | Supported for PE, unsigned single-volume CAB, MSI/MSP, generic catalogs, and RDP local RSA signing; other Authenticode SIP subjects remain backlog | | CMS creation from scratch | Not exposed through the native-shaped verb | PE/CAB/MSI Authenticode CMS creation through `sign-pe`, `sign-cab`, `sign-msi`, generic CTL/catalog CMS creation through `sign-catalog`, and `psign-sip-digest` helpers | Supported for PE, CAB, MSI, and generic catalog RSA/SHA-2; reusable CMS work remains to extend MSIX | diff --git a/docs/migration-artifact-signing.md b/docs/migration-artifact-signing.md index e243ac4..f3e5241 100644 --- a/docs/migration-artifact-signing.md +++ b/docs/migration-artifact-signing.md @@ -199,14 +199,14 @@ For migrating from **AzureSignTool** (KV-focused CLI), see [`migration-azuresign On Linux/macOS (or Windows without the dlib), use **`psign-tool portable`** after the signed artifact exists: 1. **`verify-pe`** — PKCS#7 indirect digest vs recomputed PE digest (no trust anchors). -2. **`trust-verify-pe`** — CMS validation **plus** explicit anchor trust (**`--anchor-dir`**, **`--authroot-cab`**) and policy options. +2. **`trust-verify-pe`** — CMS validation **plus** portable trust using the automatic AuthRoot cache or explicit anchors (**`--anchor-dir`**, **`--authroot-cab`**) and policy options. Short-lived signing certificates **require a valid RFC3161 timestamp** for verification long after profile expiry. Combine digest verification with trust verification options such as: - **`--prefer-timestamp-signing-time`** — prefer timestamp token time for **`exact_date`**-style checks. - **`--require-valid-timestamp`** — fail if portable extraction finds neither a nested RFC3161 **`TSTInfo.genTime`** nor PKCS#9 **`signing-time`** (use with **`--prefer-timestamp-signing-time`**). With **`--as-of`**, the verification instant is pinned and **timestamp presence is not enforced** on that path (see **`authenticode-trust-stack.md`**). - **`--as-of YYYY-MM-DD`** — reproducible verification date. -- **`--anchor-dir`** / **`--authroot-cab`** — supply roots explicitly (portable path does not use the OS store). +- **`--anchor-dir`** / **`--authroot-cab`** — supply roots explicitly for enterprise anchors or reproducible pinned CABs (portable path does not use the OS store). Example: diff --git a/docs/migration-azuresigntool.md b/docs/migration-azuresigntool.md index b7c2568..ec38a52 100644 --- a/docs/migration-azuresigntool.md +++ b/docs/migration-azuresigntool.md @@ -130,7 +130,7 @@ AzureSignTool does not verify signatures. After signing on Windows, use portable psign-tool portable verify-pe -- ``` -For **trust** validation with **explicit anchors** (no OS certificate store), use **`trust-verify-pe`** (or format-specific **`trust-verify-*`** commands). Short-lived signing certificates—common with Artifact Signing profiles—**need RFC3161 timestamping** at sign time so signatures remain verifiable after the leaf expires; combine digest checks with timestamp-aware trust options when applicable: +For **trust** validation without the OS certificate store, use **`trust-verify-pe`** (or format-specific **`trust-verify-*`** commands). Portable trust uses the automatic AuthRoot cache when no anchors are supplied; pass **`--anchor-dir`** / **`--authroot-cab`** for enterprise anchors or pinned CI inputs. Short-lived signing certificates—common with Artifact Signing profiles—**need RFC3161 timestamping** at sign time so signatures remain verifiable after the leaf expires; combine digest checks with timestamp-aware trust options when applicable: ```text psign-tool portable trust-verify-pe ./artifact.exe \ diff --git a/docs/psign-cli-matrix.json b/docs/psign-cli-matrix.json index 4f8b647..d87b104 100644 --- a/docs/psign-cli-matrix.json +++ b/docs/psign-cli-matrix.json @@ -122,7 +122,7 @@ {"native": "/p7content", "rust": "--detached-pkcs7-content", "tier": "P0", "status": "implemented", "notes": "Content file for detached PKCS#7 verify"}, {"native": "(detached sig file)", "rust": "--detached-pkcs7 (--p7s)", "tier": "P0", "status": "implemented", "notes": "Alias p7s for detached PKCS#7 path"}, {"native": "(allow test roots)", "rust": "--allow-test-root (--testroot)", "tier": "P1", "status": "implemented", "notes": "Windows argv /testroot supported"}, - {"native": "(portable explicit root)", "rust": "--trusted-ca", "tier": "P1", "status": "implemented", "notes": "Portable trust only; repeatable PEM/DER root files, no OS trust-store writes. In `--mode portable verify`, presence of this flag routes supported formats to portable trust verification."}, + {"native": "(portable explicit root)", "rust": "--trusted-ca", "tier": "P1", "status": "implemented", "notes": "Portable trust only; repeatable PEM/DER root files, no OS trust-store writes. In `--mode portable verify`, supported formats route to portable trust by default when automatic AuthRoot is enabled; this flag supplies explicit roots and suppresses auto AuthRoot resolution."}, {"native": "(portable anchor directory)", "rust": "--anchor-dir", "tier": "P1", "status": "implemented", "notes": "Portable trust only; loads .crt/.cer/.pem files as anchors without elevation or persistent store changes."}, {"native": "(portable AIA)", "rust": "--online-aia", "tier": "P2", "status": "partial", "notes": "Portable trust only; explicit in-memory HTTP AIA caIssuers fetch for missing issuers. Revocation is available through OCSP/CRL HTTP overrides and CRL Distribution Points."}, {"native": "(portable AIA test override)", "rust": "--aia-url-override", "tier": "P2", "status": "partial", "notes": "Portable trust only; deterministic local test override used before certificate AIA URLs."}, diff --git a/docs/roadmap-authenticode-linux.md b/docs/roadmap-authenticode-linux.md index 15adac7..41e8d2c 100644 --- a/docs/roadmap-authenticode-linux.md +++ b/docs/roadmap-authenticode-linux.md @@ -68,7 +68,7 @@ Already aligned in Rust for **cleartext** subjects: | Surface | `PSIGN_*` / related | Notes | |---------|---------------------------|--------| -| **`psign-tool portable`** (Linux/macOS) | None required | Subcommands take **paths on the argv** only (`verify-pe`, **`trust-verify-pe`** + **`--anchor-dir` / `--authroot-cab`**, `verify-msix`, …). | +| **`psign-tool portable`** (Linux/macOS) | **`PSIGN_NO_AUTO_TRUST`**, **`PSIGN_AUTHROOT_MAX_AGE_DAYS`**, **`PSIGN_AUTHROOT_CACHE_DIR`**, **`PSIGN_AUTHROOT_URL`** optional | Trust subcommands can run with paths only by auto-caching Microsoft **`authrootstl.cab`**; explicit **`--anchor-dir`** / **`--authroot-cab`** inputs override auto trust. Digest-only commands such as **`verify-pe`** remain available. | | **`psign-tool --mode portable`** (non-Windows) | **`PSIGN_TOOL_MODE=portable`** optional | Uses portable Rust paths where implemented; Win32-only commands fail explicitly. | | **`psign-tool --mode windows`** (Windows) | **`PSIGN_TOOL_MODE=windows`**, **`PSIGN_RUST_SIP`**, **`SIGNTOOL_PAGE_HASHES`** (via **`--no-page-hashes`**) | Win32 backend, post-sign Rust SIP digest gates, and **`SignerSignEx3`** page-hash hint — see [`psign-cli-matrix.json`](psign-cli-matrix.json), [`rust-sip-architecture.md`](rust-sip-architecture.md). | | **Parity scripts / CI** | **`SIGNTOOL_EXE`**, **`PSIGN_TEST_PFX`**, **`PSIGN_MSIX_*`**, … | Full matrix and semantics in [`ci-parity.md`](ci-parity.md). | diff --git a/dotnet/Devolutions.Psign.PowerShell/Trust/AuthRootCache.cs b/dotnet/Devolutions.Psign.PowerShell/Trust/AuthRootCache.cs index be743ca..ae80627 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Trust/AuthRootCache.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Trust/AuthRootCache.cs @@ -13,9 +13,11 @@ internal static class AuthRootCache private const string AuthRootCabUrl = "http://ctldl.windowsupdate.com/msdownload/update/v3/static/trustedr/en/authrootstl.cab"; private const string CabFileName = "authrootstl.cab"; private const string MetaFileName = "authrootstl.cab.json"; - private const int DefaultMaxAgeDays = 30; + private const int DefaultMaxAgeDays = 7; private const string MaxAgeEnvVar = "PSIGN_AUTHROOT_MAX_AGE_DAYS"; private const string NoAutoTrustEnvVar = "PSIGN_NO_AUTO_TRUST"; + private const string CacheDirEnvVar = "PSIGN_AUTHROOT_CACHE_DIR"; + private const string SourceUrlEnvVar = "PSIGN_AUTHROOT_URL"; private static readonly HttpClient SharedClient = new() { @@ -27,8 +29,10 @@ internal static class AuthRootCache /// internal static bool IsAutoTrustDisabled() { - string? value = Environment.GetEnvironmentVariable(NoAutoTrustEnvVar); - return value is "1" or "true" or "yes"; + string? value = Environment.GetEnvironmentVariable(NoAutoTrustEnvVar)?.Trim(); + return value == "1" + || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase); } /// @@ -55,8 +59,9 @@ internal static bool IsAutoTrustDisabled() try { Directory.CreateDirectory(cacheDir); - writeVerbose?.Invoke($"Downloading AuthRoot CAB from {AuthRootCabUrl}..."); - DownloadCab(cabPath, metaPath); + string sourceUrl = GetSourceUrl(); + writeVerbose?.Invoke($"Downloading AuthRoot CAB from {sourceUrl}..."); + DownloadCab(cabPath, metaPath, sourceUrl); writeVerbose?.Invoke($"AuthRoot CAB cached at: {cabPath}"); return cabPath; } @@ -83,16 +88,23 @@ internal static bool IsAutoTrustDisabled() string cacheDir = GetCacheDirectory(); string cabPath = Path.Combine(cacheDir, CabFileName); string metaPath = Path.Combine(cacheDir, MetaFileName); + string sourceUrl = GetSourceUrl(); Directory.CreateDirectory(cacheDir); - writeVerbose?.Invoke($"Downloading AuthRoot CAB from {AuthRootCabUrl}..."); - DownloadCab(cabPath, metaPath); + writeVerbose?.Invoke($"Downloading AuthRoot CAB from {sourceUrl}..."); + DownloadCab(cabPath, metaPath, sourceUrl); writeVerbose?.Invoke($"AuthRoot CAB cached at: {cabPath}"); return cabPath; } private static string GetCacheDirectory() { + string? configured = Environment.GetEnvironmentVariable(CacheDirEnvVar); + if (!string.IsNullOrWhiteSpace(configured)) + { + return configured; + } + string home = Environment.GetEnvironmentVariable("HOME") ?? Environment.GetEnvironmentVariable("USERPROFILE") ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); @@ -100,6 +112,14 @@ private static string GetCacheDirectory() return Path.Combine(home, ".psign", "authroot"); } + private static string GetSourceUrl() + { + string? configured = Environment.GetEnvironmentVariable(SourceUrlEnvVar); + return string.IsNullOrWhiteSpace(configured) + ? AuthRootCabUrl + : configured; + } + private static bool IsStale(string metaPath) { if (!File.Exists(metaPath)) @@ -135,9 +155,9 @@ private static int GetMaxAgeDays() return DefaultMaxAgeDays; } - private static void DownloadCab(string cabPath, string metaPath) + private static void DownloadCab(string cabPath, string metaPath, string sourceUrl) { - using HttpResponseMessage response = SharedClient.GetAsync(AuthRootCabUrl).GetAwaiter().GetResult(); + using HttpResponseMessage response = SharedClient.GetAsync(sourceUrl).GetAwaiter().GetResult(); response.EnsureSuccessStatusCode(); byte[] bytes = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult(); @@ -149,7 +169,7 @@ private static void DownloadCab(string cabPath, string metaPath) AuthRootMeta meta = new() { DownloadedAtUtc = DateTime.UtcNow, - SourceUrl = AuthRootCabUrl, + SourceUrl = sourceUrl, SizeBytes = bytes.Length, }; diff --git a/src/lib.rs b/src/lib.rs index 4db385f..c3544c0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -193,7 +193,7 @@ fn portable_verify_unsupported(args: &crate::cli::VerifyArgs) -> bool { || args.enclave_policy } -fn portable_verify_trust_requested(args: &crate::cli::VerifyArgs) -> bool { +fn portable_verify_explicit_trust_requested(args: &crate::cli::VerifyArgs) -> bool { args.anchor_dir.is_some() || !args.trusted_ca.is_empty() || args.authroot_cab.is_some() @@ -213,29 +213,41 @@ fn portable_verify_trust_requested(args: &crate::cli::VerifyArgs) -> bool { || args.online_max_download_bytes != 1024 * 1024 } +fn portable_auto_trust_enabled() -> bool { + !psign_authenticode_trust::authroot_cache::is_auto_trust_disabled() +} + +fn portable_trust_command_for_verify_command(command: &str) -> Option<&'static str> { + match command { + "verify-pe" => Some("trust-verify-pe"), + "verify-cab" => Some("trust-verify-cab"), + "verify-msi" => Some("trust-verify-msi"), + "verify-esd" => Some("trust-verify-esd"), + "verify-catalog" => Some("trust-verify-catalog"), + "verify-zip" => Some("trust-verify-zip"), + _ => None, + } +} + fn execute_portable_verify(args: &crate::cli::VerifyArgs) -> anyhow::Result { if portable_verify_unsupported(args) { return Err(anyhow::anyhow!( - "--mode portable verify currently supports bare file digest-consistency verification; use `psign-tool portable ...` for portable trust/diagnostic commands" + "--mode portable verify supports file verification and portable trust inputs; use `psign-tool portable ...` for lower-level diagnostic commands" )); } for path in &args.files { - let command = if portable_verify_trust_requested(args) { - match portable_command_for_path(path)? { - "verify-pe" => "trust-verify-pe", - "verify-cab" => "trust-verify-cab", - "verify-msi" => "trust-verify-msi", - "verify-esd" => "trust-verify-esd", - "verify-catalog" => "trust-verify-catalog", - "verify-zip" => "trust-verify-zip", - other => { - return Err(anyhow::anyhow!( - "--mode portable verify trust options are not supported for inferred command {other}" - )); - } - } + let inferred_command = portable_command_for_path(path)?; + let explicit_trust = portable_verify_explicit_trust_requested(args); + let command = if explicit_trust { + portable_trust_command_for_verify_command(inferred_command).ok_or_else(|| { + anyhow::anyhow!( + "--mode portable verify trust options are not supported for inferred command {inferred_command}" + ) + })? + } else if portable_auto_trust_enabled() { + portable_trust_command_for_verify_command(inferred_command).unwrap_or(inferred_command) } else { - portable_command_for_path(path)? + inferred_command }; let mut argv = Vec::new(); argv.push(std::ffi::OsString::from(command)); @@ -444,3 +456,44 @@ pub fn run_tool_cli() -> ! { std::process::exit(batch_exit); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn portable_trust_command_mapping_covers_supported_verify_formats() { + assert_eq!( + portable_trust_command_for_verify_command("verify-pe"), + Some("trust-verify-pe") + ); + assert_eq!( + portable_trust_command_for_verify_command("verify-cab"), + Some("trust-verify-cab") + ); + assert_eq!( + portable_trust_command_for_verify_command("verify-msi"), + Some("trust-verify-msi") + ); + assert_eq!( + portable_trust_command_for_verify_command("verify-esd"), + Some("trust-verify-esd") + ); + assert_eq!( + portable_trust_command_for_verify_command("verify-catalog"), + Some("trust-verify-catalog") + ); + assert_eq!( + portable_trust_command_for_verify_command("verify-zip"), + Some("trust-verify-zip") + ); + assert_eq!( + portable_trust_command_for_verify_command("verify-msix"), + None + ); + assert_eq!( + portable_trust_command_for_verify_command("verify-script"), + None + ); + } +} diff --git a/tests/cli_pe_digest.rs b/tests/cli_pe_digest.rs index 0f72f34..a43f573 100644 --- a/tests/cli_pe_digest.rs +++ b/tests/cli_pe_digest.rs @@ -2703,6 +2703,17 @@ fn unified_verify_mode_portable_accepts_trusted_ca_without_os_store() { .stdout(predicate::str::contains("trust-verify-pe: ok")); } +#[test] +fn unified_verify_mode_portable_uses_digest_only_when_auto_trust_disabled() { + let mut cmd = Command::cargo_bin("psign-tool").unwrap(); + cmd.env("PSIGN_NO_AUTO_TRUST", "1") + .arg("--mode") + .arg("portable") + .arg("verify") + .arg(tiny32_fixture()); + cmd.assert().success(); +} + #[test] fn trust_verify_pe_ok_with_prefer_timestamp_signing_time_and_as_of() { let fixture = tiny32_fixture(); @@ -2772,6 +2783,7 @@ fn trust_verify_pe_require_valid_timestamp_rejects_pkcs9_only_tiny64() { #[test] fn trust_verify_pe_errors_without_configured_anchors() { let mut cmd = portable_cmd(); + cmd.env("PSIGN_NO_AUTO_TRUST", "1"); cmd.arg("trust-verify-pe").arg(tiny32_fixture()); cmd.assert() .failure() @@ -3461,6 +3473,7 @@ fn portable_verify_negative_cab_unsigned_cli() { #[test] fn portable_verify_negative_trust_cab_no_anchors_cli() { let mut cmd = portable_cmd(); + cmd.env("PSIGN_NO_AUTO_TRUST", "1"); cmd.arg("trust-verify-cab").arg(tiny_signed_cab_fixture()); cmd.assert() .failure() @@ -3759,6 +3772,7 @@ fn portable_verify_negative_trust_detached_no_anchors_cli() { std::fs::write(&work_pe, &pe_bytes).expect("copy pe"); let mut cmd = portable_cmd(); + cmd.env("PSIGN_NO_AUTO_TRUST", "1"); cmd.arg("trust-verify-detached") .arg(&work_pe) .arg(dir.path().join("sig.p7"));