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"));