From 45a11c7bf3e55c38c56c878c9771a2158b4d5179 Mon Sep 17 00:00:00 2001 From: Bryan De Houwer Date: Thu, 10 Sep 2026 02:00:46 +0200 Subject: [PATCH] feat(portable-trust): support additive trust anchors Add a repeatable --additional-trusted-ca option for augmenting the selected portable trust set without disabling the automatic Microsoft AuthRoot source. Keep --trusted-ca and --anchor-dir as replacing inputs, apply the behavior consistently across portable trust commands, and document and test the source-selection policy. --- .../src/trust_verify_pe.rs | 2 +- crates/psign-digest-cli/src/main.rs | 113 +++++++++++++++++- docs/authenticode-trust-stack.md | 2 +- docs/authroot-linux-verify.md | 12 +- docs/psign-cli-matrix.json | 1 + 5 files changed, 124 insertions(+), 6 deletions(-) diff --git a/crates/psign-authenticode-trust/src/trust_verify_pe.rs b/crates/psign-authenticode-trust/src/trust_verify_pe.rs index 1eb1d61..0be4e53 100644 --- a/crates/psign-authenticode-trust/src/trust_verify_pe.rs +++ b/crates/psign-authenticode-trust/src/trust_verify_pe.rs @@ -79,7 +79,7 @@ pub fn load_trust_material(opts: &TrustVerifyPeOptions) -> Result<(AnchorStore, if anchor_store.thumbprint_count() == 0 { return Err(anyhow!( - "no trust anchors configured (use --anchor-dir and/or --authroot-cab)" + "no trust anchors configured (use --anchor-dir, --trusted-ca, --additional-trusted-ca, and/or --authroot-cab)" )); } diff --git a/crates/psign-digest-cli/src/main.rs b/crates/psign-digest-cli/src/main.rs index cd7aaae..8662077 100644 --- a/crates/psign-digest-cli/src/main.rs +++ b/crates/psign-digest-cli/src/main.rs @@ -83,6 +83,10 @@ struct TrustVerifySharedArgs { /// Trust this CA certificate file as an anchor (repeatable, PEM or DER). #[arg(long, value_name = "PATH", action = clap::ArgAction::Append)] trusted_ca: Vec, + /// Add a CA without replacing the selected or automatically discovered trust anchors + /// (repeatable, PEM or DER). + #[arg(long, value_name = "PATH", action = clap::ArgAction::Append)] + additional_trusted_ca: Vec, #[arg(long, value_name = "PATH")] authroot_cab: Option, /// Require **`--authroot-cab`** file SHA-256 (64 lowercase hex chars) to match before ingest. @@ -160,7 +164,7 @@ fn trust_verify_options_from_shared(a: &TrustVerifySharedArgs) -> Result Result Result Vec { + trusted_ca + .iter() + .chain(additional_trusted_ca) + .cloned() + .collect() +} + +fn explicit_anchors_replace_automatic_authroot(a: &TrustVerifySharedArgs) -> bool { + a.anchor_dir.is_some() || !a.trusted_ca.is_empty() +} + +#[cfg(test)] +mod trust_source_tests { + use super::*; + + #[derive(Parser)] + struct SharedTrustArgsParser { + #[command(flatten)] + shared: TrustVerifySharedArgs, + } + + fn path(name: &str) -> PathBuf { + PathBuf::from(name) + } + + fn parse_shared_args(args: &[&str]) -> TrustVerifySharedArgs { + SharedTrustArgsParser::try_parse_from( + std::iter::once("trust-args").chain(args.iter().copied()), + ) + .expect("parse shared trust arguments") + .shared + } + + #[test] + fn additional_ca_does_not_replace_automatic_authroot() { + let additional_only = parse_shared_args(&[ + "--additional-trusted-ca", + "additional-root.cer", + ]); + assert!(!explicit_anchors_replace_automatic_authroot( + &additional_only + )); + + let with_trusted_ca = parse_shared_args(&[ + "--additional-trusted-ca", + "additional-root.cer", + "--trusted-ca", + "explicit-root.cer", + ]); + assert!(explicit_anchors_replace_automatic_authroot( + &with_trusted_ca + )); + + let with_anchor_dir = parse_shared_args(&[ + "--additional-trusted-ca", + "additional-root.cer", + "--anchor-dir", + "anchors", + ]); + assert!(explicit_anchors_replace_automatic_authroot( + &with_anchor_dir + )); + } + + #[test] + fn trusted_and_additional_ca_files_are_merged_in_argument_order() { + let trusted_ca = [path("explicit-a.cer"), path("explicit-b.cer")]; + let additional_trusted_ca = [path("additional-a.cer"), path("additional-b.cer")]; + + let merged = combined_trusted_ca_files(&trusted_ca, &additional_trusted_ca); + + assert_eq!( + merged, + vec![ + path("explicit-a.cer"), + path("explicit-b.cer"), + path("additional-a.cer"), + path("additional-b.cer") + ] + ); + } + + #[test] + fn additional_trusted_ca_is_repeatable_on_trust_commands() { + let args = parse_shared_args(&[ + "--additional-trusted-ca", + "test-root-a.cer", + "--additional-trusted-ca", + "test-root-b.cer", + ]); + + assert_eq!( + args.additional_trusted_ca, + vec![path("test-root-a.cer"), path("test-root-b.cer")] + ); + } +} + fn trust_verify_args_present(a: &TrustVerifySharedArgs) -> bool { a.anchor_dir.is_some() || !a.trusted_ca.is_empty() + || !a.additional_trusted_ca.is_empty() || a.authroot_cab.is_some() || a.expect_authroot_cab_sha256.is_some() || a.as_of.is_some() @@ -1913,7 +2020,7 @@ enum Command { VerifyPe { path: PathBuf }, /// Verify PE Authenticode **trust**: PKCS#7 CMS validation + certificate chain to portable anchors (no OS store). /// - /// 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. + /// Uses the automatic Microsoft AuthRoot CAB cache when no replacing 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, or **`--additional-trusted-ca`** to augment the selected trust set. **`verify-pe`** remains digest-only; this subcommand adds chain + policy checks. TrustVerifyPe { path: PathBuf, #[command(flatten)] diff --git a/docs/authenticode-trust-stack.md b/docs/authenticode-trust-stack.md index 678fedb..5530fe9 100644 --- a/docs/authenticode-trust-stack.md +++ b/docs/authenticode-trust-stack.md @@ -9,7 +9,7 @@ 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_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. | +| 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 replacing explicit anchors are supplied, then parse CAB **`*.stl`** → PKCS#7 **`SignedData`** **`eContent`** CTL **SHA-1 subject identifiers** plus PKCS#7-embedded certs. Repeatable **`--additional-trusted-ca`** files augment either selected trust set without suppressing automatic AuthRoot discovery. | | 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/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). | diff --git a/docs/authroot-linux-verify.md b/docs/authroot-linux-verify.md index b08f42e..36b37ff 100644 --- a/docs/authroot-linux-verify.md +++ b/docs/authroot-linux-verify.md @@ -6,6 +6,8 @@ By default, **`psign-tool portable trust-verify-*`** and bare **`psign-tool --mo 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. +Use repeatable **`--additional-trusted-ca`** inputs when a private or test root should augment, rather than replace, the selected trust set. With no explicit **`--authroot-cab`**, **`--anchor-dir`**, or **`--trusted-ca`**, automatic Microsoft AuthRoot discovery remains enabled. Because every additional certificate becomes a trust anchor for that invocation, obtain it from an authenticated source and validate its expected identity in security-sensitive automation. + ## Phase A — anchor directory (recommended first ship) 1. On a Windows machine with updates, sync roots you trust, for example: @@ -59,7 +61,15 @@ psign-tool portable trust-verify-pe \ ./signed.exe ``` -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: +Add a test or private root while retaining automatic Microsoft AuthRoot trust: + +```bash +psign-tool portable trust-verify-pe \ + --additional-trusted-ca ./downloaded-test-root.cer \ + ./signed.exe +``` + +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. Trust inputs such as **`--trusted-ca`**, **`--additional-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/psign-cli-matrix.json b/docs/psign-cli-matrix.json index 4b9ef9d..57f67b8 100644 --- a/docs/psign-cli-matrix.json +++ b/docs/psign-cli-matrix.json @@ -123,6 +123,7 @@ {"native": "(detached sig file)", "rust": "--detached-pkcs7 (--p7s)", "tier": "P0", "status": "implemented", "notes": "Alias p7s for detached PKCS#7 path. In `--mode portable verify`, this routes to one portable CMS/chain validation via `trust-verify-detached` and requires exactly one verify target."}, {"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`, supported formats route to portable trust by default when automatic AuthRoot is enabled; this flag supplies explicit roots and suppresses auto AuthRoot resolution, including detached PKCS#7 and explicit catalog routing."}, + {"native": "(portable additive root)", "rust": "--additional-trusted-ca", "tier": "P1", "status": "implemented", "notes": "Portable trust only; repeatable PEM/DER root files that augment the selected trust set without suppressing automatic Microsoft AuthRoot discovery. Each supplied certificate is trusted as an anchor for that invocation."}, {"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."},