From 8cb18568bdfaeb537e29c316236fabc6ebee5bf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 21 May 2026 13:28:59 -0400 Subject: [PATCH] Add ZIP Authenticode signing support Implement Devolutions ZIP Authenticode primitives, Windows sign/verify routing, portable verify/trust commands, fixtures, tests, and documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 4 +- crates/psign-authenticode-trust/src/lib.rs | 2 + .../src/trust_verify_zip.rs | 44 +++ crates/psign-digest-cli/src/main.rs | 26 ++ crates/psign-sip-digest/src/lib.rs | 1 + crates/psign-sip-digest/src/pkcs7.rs | 44 +++ crates/psign-sip-digest/src/ps_script.rs | 24 +- .../psign-sip-digest/src/zip_authenticode.rs | 321 ++++++++++++++++++ docs/zip-authenticode-signing.md | 98 ++++++ .../ci/build-zip-authenticode-fixtures.ps1 | 151 ++++++++ src/cert_store.rs | 2 +- src/lib.rs | 2 + src/win/mod.rs | 1 + src/win/sign.rs | 33 +- src/win/verify.rs | 43 ++- src/win/zip_authenticode.rs | 236 +++++++++++++ tests/cli_pe_digest.rs | 44 +++ tests/corpus_sign_verify.rs | 50 +++ tests/fixture_vector_manifest.rs | 38 +++ .../zip-authenticode/signed/sample.signed.zip | Bin 0 -> 2331 bytes .../zip-authenticode/unsigned/sample.zip | Bin 0 -> 311 bytes .../zip-authenticode-fixtures.json | 25 ++ 22 files changed, 1176 insertions(+), 13 deletions(-) create mode 100644 crates/psign-authenticode-trust/src/trust_verify_zip.rs create mode 100644 crates/psign-sip-digest/src/zip_authenticode.rs create mode 100644 docs/zip-authenticode-signing.md create mode 100644 scripts/ci/build-zip-authenticode-fixtures.ps1 create mode 100644 src/win/zip_authenticode.rs create mode 100644 tests/fixtures/zip-authenticode/signed/sample.signed.zip create mode 100644 tests/fixtures/zip-authenticode/unsigned/sample.zip create mode 100644 tests/fixtures/zip-authenticode/zip-authenticode-fixtures.json diff --git a/README.md b/README.md index 7a1c77d..5b59900 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Canonical repository: . ## CLI surface - `verify`, `remove`, `catdb`: Windows-compatible `signtool.exe` flows backed by WinTrust and CryptSIP where native APIs are required. -- `sign`: Rust mssign32 core (`SignerSignEx3`) with PFX/system-store cert selection, RFC3161 sign-time timestamping, and decoupled-digest bridge flow (`--dlib` or `--trusted-signing-dlib-root` + `--dmdf`) for MSIX parity and [Azure Artifact Signing / Trusted Signing](docs/migration-artifact-signing.md). +- `sign`: Rust mssign32 core (`SignerSignEx3`) with PFX/system-store cert selection, RFC3161 sign-time timestamping, first-class custom ZIP Authenticode support ([docs/zip-authenticode-signing.md](docs/zip-authenticode-signing.md)), and decoupled-digest bridge flow (`--dlib` or `--trusted-signing-dlib-root` + `--dmdf`) for MSIX parity and [Azure Artifact Signing / Trusted Signing](docs/migration-artifact-signing.md). - `inspect-signature`: JSON dump of PKCS#7 signers, timestamp OIDs, and nested signatures (`1.3.6.1.4.1.311.2.4.1`) — same parser as **`psign-tool portable inspect-authenticode`** ([docs/psa-interoperability.md](docs/psa-interoperability.md)). - `timestamp`: Rust mssign32 core (`SignerTimeStampEx3`/`SignerTimeStampEx2`) plus AppX restrictions. - `rdp`: Rust port of **`rdpsign.exe`** for `.rdp` files (`SignScope` / `Signature` records, detached PKCS#7 over the secure-settings blob). @@ -86,6 +86,8 @@ cargo build -p psign --bin psign-tool --locked # psign-tool portable sign-pe --cert cert.der --key key.pk8 --output signed.exe unsigned.exe # Portable trust verification with explicit anchors: # 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 # Portable unsigned CAB signing with a local RSA key: # psign-tool portable sign-cab --cert cert.der --key key.pk8 --output signed.cab unsigned.cab # Portable MSI/MSP signing with a local RSA key: diff --git a/crates/psign-authenticode-trust/src/lib.rs b/crates/psign-authenticode-trust/src/lib.rs index 486a2ea..6b6f1fe 100644 --- a/crates/psign-authenticode-trust/src/lib.rs +++ b/crates/psign-authenticode-trust/src/lib.rs @@ -21,6 +21,7 @@ pub mod trust_verify_detached; pub mod trust_verify_esd; pub mod trust_verify_msi; pub mod trust_verify_pe; +pub mod trust_verify_zip; pub mod verification_instant; pub use inspect::{ @@ -37,4 +38,5 @@ pub use trust_verify_pe::{ TrustVerifyPeOptions, TrustVerifyPeReport, load_trust_material, pe_first_pkcs7_terminal_root, pkcs7_signed_data_der_terminal_root, trust_verify_pe_bytes, }; +pub use trust_verify_zip::trust_verify_zip_bytes; pub use verification_instant::parse_verification_date_ymd; diff --git a/crates/psign-authenticode-trust/src/trust_verify_zip.rs b/crates/psign-authenticode-trust/src/trust_verify_zip.rs new file mode 100644 index 0000000..8445feb --- /dev/null +++ b/crates/psign-authenticode-trust/src/trust_verify_zip.rs @@ -0,0 +1,44 @@ +//! Custom ZIP Authenticode trust (Devolutions ZIP comment convention). + +use crate::trust_pkcs7::verify_authenticode_pkcs7_trust; +use crate::trust_verify_pe::{TrustVerifyPeOptions, TrustVerifyPeReport, load_trust_material}; +use crate::verification_instant::resolve_verification_instant_for_pkcs7_with_trust; +use anyhow::Result; +use psign_sip_digest::ps_script::powershell_class_digest_report; +use psign_sip_digest::zip_authenticode; + +pub fn trust_verify_zip_bytes( + data: &[u8], + opts: &TrustVerifyPeOptions, +) -> Result { + let (anchors, anchor_certs) = load_trust_material(opts)?; + let sig = zip_authenticode::verify_zip_digest_binding(data)?; + let script = zip_authenticode::signature_script_from_parts(&sig.digest, &sig.pkcs7_base64); + let script_report = powershell_class_digest_report(script.as_bytes(), "ps1")?; + + let verification_instant = resolve_verification_instant_for_pkcs7_with_trust( + &script_report.pkcs7_der, + &opts.policy, + opts.verification_instant_override.as_ref(), + &anchors, + &anchor_certs, + &opts.online, + opts.verbose_chain, + )?; + verify_authenticode_pkcs7_trust( + &script_report.pkcs7_der, + 0, + &script_report.computed_digest, + &anchors, + &anchor_certs, + &opts.policy, + &opts.online, + &verification_instant, + opts.verbose_chain, + )?; + + Ok(TrustVerifyPeReport { + pkcs7_entries_verified: 1, + anchor_thumbprints: anchors.thumbprint_count(), + }) +} diff --git a/crates/psign-digest-cli/src/main.rs b/crates/psign-digest-cli/src/main.rs index c0e3bf6..6363379 100644 --- a/crates/psign-digest-cli/src/main.rs +++ b/crates/psign-digest-cli/src/main.rs @@ -13,6 +13,7 @@ use psign_authenticode_trust::{ policy::{OnlineTrustOptions, RevocationMode}, trust_verify_cab_bytes, trust_verify_catalog_bytes, trust_verify_detached_bytes, trust_verify_msi_bytes, trust_verify_pe_bytes, trust_verify_wim_esd_path, + trust_verify_zip_bytes, }; #[cfg(feature = "azure-kv-sign-portable")] use psign_azure_kv_rest::{ @@ -48,6 +49,7 @@ use psign_sip_digest::timestamp::{ }; use psign_sip_digest::verify_pe; use psign_sip_digest::verify_script_digest_consistency; +use psign_sip_digest::zip_authenticode; use serde::Deserialize; use sha1::Sha1; use sha2::{Digest as _, Sha256, Sha384, Sha512}; @@ -596,6 +598,12 @@ enum Command { #[command(flatten)] shared: TrustVerifySharedArgs, }, + /// Custom ZIP Authenticode comment signature: verify ZIP digest binding plus PKCS#7 chain to anchors. + TrustVerifyZip { + path: PathBuf, + #[command(flatten)] + shared: TrustVerifySharedArgs, + }, /// Print whether embedded PKCS#7 bytes contain **SPC_PE_IMAGE_PAGE_HASHES** attribute OIDs (V1/V2 DER scan). /// /// Outputs `yes` or `no` (does **not** validate page segments vs file bytes — use **`verify-pe-page-hashes`** for the experimental Rust check). @@ -884,6 +892,8 @@ enum Command { }, /// CAB with embedded PKCS#7: compare indirect digest to Rust CAB hash. VerifyCab { path: PathBuf }, + /// Custom ZIP Authenticode comment signature: compare ZIP digest binding and reconstructed script digest. + VerifyZip { path: PathBuf }, /// Write **`\\u{5}DigitalSignature`** stream (**raw PKCS#7 DER**) from an **`.msi`** to stdout or **`--output`**. /// /// Same blob as **`pkcs7-signer-rs256-prehash`** input for that signature. For real signed MSIs only; see **`tests/fixtures/msi-authenticode-upstream/README.md`** for the PKCS#7-only stub used in CI. @@ -2088,6 +2098,13 @@ where .with_context(|| format!("trust-verify-detached {}", content.display()))?; print_trust_ok("trust-verify-detached", &report); } + Command::TrustVerifyZip { path, shared } => { + let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let opts = trust_verify_options_from_shared(&shared)?; + let report = trust_verify_zip_bytes(&bytes, &opts) + .with_context(|| format!("trust-verify-zip {}", path.display()))?; + print_trust_ok("trust-verify-zip", &report); + } Command::PeHasPageHashes { path } => { let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; let present = page_hashes::pe_embedded_pkcs7_contains_page_hash_attribute(&bytes) @@ -2748,6 +2765,15 @@ where verify_cab_digest_consistency(&path) .with_context(|| format!("verify-cab {}", path.display()))?; } + Command::VerifyZip { path } => { + let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let sig = zip_authenticode::verify_zip_digest_binding(&bytes) + .with_context(|| format!("verify-zip {}", path.display()))?; + let script = + zip_authenticode::signature_script_from_parts(&sig.digest, &sig.pkcs7_base64); + verify_script_digest_consistency(script.as_bytes(), "ps1") + .with_context(|| format!("verify-zip reconstructed signature {}", path.display()))?; + } Command::ExtractMsiPkcs7 { path, output } => { use std::io::Write; let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; diff --git a/crates/psign-sip-digest/src/lib.rs b/crates/psign-sip-digest/src/lib.rs index c5a53df..ce743ad 100644 --- a/crates/psign-sip-digest/src/lib.rs +++ b/crates/psign-sip-digest/src/lib.rs @@ -19,6 +19,7 @@ pub mod rdp; pub mod timestamp; pub mod verify_pe; pub mod wsh_script; +pub mod zip_authenticode; use anyhow::Result; diff --git a/crates/psign-sip-digest/src/pkcs7.rs b/crates/psign-sip-digest/src/pkcs7.rs index ad93b46..a3e01c4 100644 --- a/crates/psign-sip-digest/src/pkcs7.rs +++ b/crates/psign-sip-digest/src/pkcs7.rs @@ -62,6 +62,12 @@ const SPC_MSI_SIGINFO_VALUE_DER: &[u8] = &[ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x02, 0x01, 0x00, 0x02, 0x01, 0x00, 0x02, 0x01, 0x00, 0x02, 0x01, 0x00, 0x02, 0x01, 0x00, ]; +/// Stable PowerShell `SpcSigInfo` value DER observed in `signtool sign` script signatures. +const SPC_SCRIPT_SIGINFO_VALUE_DER: &[u8] = &[ + 0x30, 0x26, 0x02, 0x03, 0x01, 0x00, 0x00, 0x04, 0x10, 0x1f, 0xcc, 0x3b, 0x60, 0x59, 0x4b, 0x08, + 0x4e, 0xb7, 0x24, 0xd2, 0xc6, 0x29, 0x7e, 0xf3, 0x51, 0x02, 0x01, 0x00, 0x02, 0x01, 0x00, 0x02, + 0x01, 0x00, 0x02, 0x01, 0x00, 0x02, 0x01, 0x00, +]; /// **`SignerInfo.digestAlgorithm`** / **`DigestInfo.digestAlgorithm`** SHA-1 OID. const DIGEST_OID_SHA1: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.14.3.2.26"); @@ -184,6 +190,21 @@ pub fn msi_spc_indirect_data( ) } +/// Build the Authenticode `SpcIndirectDataContent` for a PowerShell-class script digest. +pub fn script_spc_indirect_data( + digest_algorithm: AuthenticodeSigningDigest, + script_digest: &[u8], +) -> Result { + spc_indirect_data( + digest_algorithm, + script_digest, + SPC_MSI_SIGINFO_OBJID, + Any::from_der(SPC_SCRIPT_SIGINFO_VALUE_DER) + .map_err(|e| anyhow!("SPC_SCRIPT_SIGINFO Any: {e}"))?, + "script", + ) +} + fn spc_indirect_data( digest_algorithm: AuthenticodeSigningDigest, subject_digest: &[u8], @@ -277,6 +298,29 @@ pub fn create_pe_authenticode_pkcs7_der_rsa( ) } +/// Create PKCS#7 `ContentInfo(SignedData)` DER for a PowerShell-class Authenticode script. +pub fn create_script_authenticode_pkcs7_der_rsa( + script: &[u8], + digest_algorithm: AuthenticodeSigningDigest, + signer_cert: Certificate, + chain_certs: Vec, + private_key: RsaPrivateKey, +) -> Result> { + let units = crate::ps_script::file_utf16_units(script); + let script_digest = crate::ps_script::hash_payload( + digest_algorithm.pe_hash_kind(), + &crate::ps_script::utf16le_bytes(&units), + )?; + let indirect = script_spc_indirect_data(digest_algorithm, &script_digest)?; + create_authenticode_pkcs7_der_rsa( + indirect, + digest_algorithm, + signer_cert, + chain_certs, + private_key, + ) +} + /// Create PKCS#7 `ContentInfo(SignedData)` DER for an Authenticode `SpcIndirectDataContent`. pub fn create_authenticode_pkcs7_der_rsa( indirect: SpcIndirectDataContent, diff --git a/crates/psign-sip-digest/src/ps_script.rs b/crates/psign-sip-digest/src/ps_script.rs index 90955f9..e942c36 100644 --- a/crates/psign-sip-digest/src/ps_script.rs +++ b/crates/psign-sip-digest/src/ps_script.rs @@ -11,6 +11,13 @@ use digest::Digest; use sha1::Sha1; use sha2::{Sha256, Sha384, Sha512}; +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ScriptAuthenticodeDigestReport { + pub pkcs7_der: Vec, + pub embedded_digest: Vec, + pub computed_digest: Vec, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum MarkerFamily { Hash, @@ -142,7 +149,7 @@ pub(crate) fn hash_payload(kind: PeAuthenticodeHashKind, payload: &[u8]) -> Resu } fn looks_like_b64_token(s: &str) -> bool { - s.len() >= 16 + s.len() >= 4 && s.chars() .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=') } @@ -236,7 +243,10 @@ pub fn is_wsh_extension(ext: &str) -> bool { } /// Compare PKCS#7 indirect digest with a heuristic UTF-16 hash over the file excluding the sig block. -pub(crate) fn verify_powershell_class_digest(raw: &[u8], ext: &str) -> Result<()> { +pub fn powershell_class_digest_report( + raw: &[u8], + ext: &str, +) -> Result { let ext_l = ext.to_ascii_lowercase(); if !extension_supported(&ext_l) { return Err(anyhow!( @@ -261,6 +271,16 @@ pub(crate) fn verify_powershell_class_digest(raw: &[u8], ext: &str) -> Result<() "script Authenticode digest mismatch (experimental UTF-16 strip heuristic vs PKCS#7)" )); } + Ok(ScriptAuthenticodeDigestReport { + pkcs7_der: pkcs7, + embedded_digest: embedded.to_vec(), + computed_digest: computed, + }) +} + +/// Compare PKCS#7 indirect digest with a heuristic UTF-16 hash over the file excluding the sig block. +pub(crate) fn verify_powershell_class_digest(raw: &[u8], ext: &str) -> Result<()> { + powershell_class_digest_report(raw, ext)?; Ok(()) } diff --git a/crates/psign-sip-digest/src/zip_authenticode.rs b/crates/psign-sip-digest/src/zip_authenticode.rs new file mode 100644 index 0000000..3d44e42 --- /dev/null +++ b/crates/psign-sip-digest/src/zip_authenticode.rs @@ -0,0 +1,321 @@ +//! Devolutions ZIP Authenticode convention. +//! +//! This is not a Windows SIP. The ZIP EOCD comment stores a single +//! `ZipAuthenticode=,` line. + +use anyhow::{Context as _, Result, anyhow}; +use base64::Engine as _; +use sha2::{Digest as _, Sha256}; + +const EOCD_SIG: [u8; 4] = [0x50, 0x4b, 0x05, 0x06]; +const EOCD_LEN: usize = 22; +const EOCD_COMMENT_LEN_OFFSET: usize = 20; +const MAX_ZIP_COMMENT_LEN: usize = 65_535; +const ZIP_AUTHENTICODE_PREFIX: &str = "ZipAuthenticode="; +const SHA256_DIGEST_PREFIX: &str = "sha256:"; +const BEGIN_SIG_BLOCK: &str = "# SIG # Begin signature block"; +const END_SIG_BLOCK: &str = "# SIG # End signature block"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ZipEocd { + pub offset: usize, + pub comment_offset: usize, + pub comment_len: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ZipAuthenticodeSignature { + pub digest: String, + pub pkcs7_base64: String, +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &byte in bytes { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0f) as usize] as char); + } + out +} + +fn read_u16_le(bytes: &[u8], offset: usize) -> Result { + let raw = bytes + .get(offset..offset + 2) + .ok_or_else(|| anyhow!("ZIP EOCD field out of range"))?; + Ok(u16::from_le_bytes([raw[0], raw[1]])) +} + +pub fn find_eocd(bytes: &[u8]) -> Result { + if bytes.len() < EOCD_LEN { + return Err(anyhow!("ZIP file is too short to contain an EOCD record")); + } + + let last_possible = bytes.len() - EOCD_LEN; + let first_possible = bytes.len().saturating_sub(EOCD_LEN + MAX_ZIP_COMMENT_LEN); + + for offset in (first_possible..=last_possible).rev() { + if bytes.get(offset..offset + 4) != Some(&EOCD_SIG) { + continue; + } + let comment_len = read_u16_le(bytes, offset + EOCD_COMMENT_LEN_OFFSET)? as usize; + let comment_offset = offset + EOCD_LEN; + if comment_offset + comment_len == bytes.len() { + return Ok(ZipEocd { + offset, + comment_offset, + comment_len, + }); + } + } + + Err(anyhow!( + "could not find a valid ZIP end-of-central-directory record" + )) +} + +pub fn zip_comment(bytes: &[u8]) -> Result<&[u8]> { + let eocd = find_eocd(bytes)?; + Ok(&bytes[eocd.comment_offset..eocd.comment_offset + eocd.comment_len]) +} + +pub fn compute_zip_authenticode_digest(bytes: &[u8]) -> Result<[u8; 32]> { + let eocd = find_eocd(bytes)?; + let mut tbs = bytes[..eocd.comment_offset].to_vec(); + tbs[eocd.offset + EOCD_COMMENT_LEN_OFFSET] = 0; + tbs[eocd.offset + EOCD_COMMENT_LEN_OFFSET + 1] = 0; + Ok(Sha256::digest(&tbs).into()) +} + +pub fn zip_authenticode_digest_string(bytes: &[u8]) -> Result { + let digest = compute_zip_authenticode_digest(bytes)?; + Ok(format!("{SHA256_DIGEST_PREFIX}{}", hex_lower(&digest))) +} + +pub fn signature_comment_line(bytes: &[u8]) -> Result { + let comment = zip_comment(bytes)?; + let text = std::str::from_utf8(comment).context("ZIP comment is not UTF-8")?; + text.lines() + .map(str::trim) + .find(|line| line.starts_with(ZIP_AUTHENTICODE_PREFIX)) + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow!("ZIP Authenticode signature comment not found")) +} + +fn is_sha256_digest_string(value: &str) -> bool { + value.len() == SHA256_DIGEST_PREFIX.len() + 64 + && value.starts_with(SHA256_DIGEST_PREFIX) + && value[SHA256_DIGEST_PREFIX.len()..] + .bytes() + .all(|b| b.is_ascii_hexdigit()) +} + +pub fn parse_signature_comment_line(line: &str) -> Result { + let rest = line + .trim() + .strip_prefix(ZIP_AUTHENTICODE_PREFIX) + .ok_or_else(|| { + anyhow!("ZIP signature comment must start with {ZIP_AUTHENTICODE_PREFIX}") + })?; + let (digest, pkcs7_base64) = rest + .split_once(',') + .ok_or_else(|| anyhow!("ZIP signature comment is missing PKCS#7 separator"))?; + if !is_sha256_digest_string(digest) { + return Err(anyhow!( + "ZIP signature digest must use sha256:<64 hex chars>" + )); + } + if pkcs7_base64.trim().is_empty() { + return Err(anyhow!("ZIP signature PKCS#7 payload is empty")); + } + base64::engine::general_purpose::STANDARD + .decode(pkcs7_base64.trim()) + .context("ZIP signature PKCS#7 base64 decode")?; + Ok(ZipAuthenticodeSignature { + digest: digest.to_ascii_lowercase(), + pkcs7_base64: pkcs7_base64.trim().to_owned(), + }) +} + +pub fn signature_pkcs7_der(sig: &ZipAuthenticodeSignature) -> Result> { + base64::engine::general_purpose::STANDARD + .decode(sig.pkcs7_base64.trim()) + .context("ZIP signature PKCS#7 base64 decode") +} + +pub fn signature_script_from_parts(digest: &str, pkcs7_base64: &str) -> String { + let mut out = String::new(); + out.push_str(digest); + out.push_str("\r\n"); + out.push_str(BEGIN_SIG_BLOCK); + out.push_str("\r\n"); + for chunk in pkcs7_base64.as_bytes().chunks(64) { + out.push_str("# "); + out.push_str(std::str::from_utf8(chunk).expect("base64 chunk is ASCII")); + out.push_str("\r\n"); + } + out.push_str(END_SIG_BLOCK); + out.push_str("\r\n"); + out +} + +pub fn signature_comment_line_from_pkcs7_der(digest: &str, pkcs7_der: &[u8]) -> Result { + if !is_sha256_digest_string(digest) { + return Err(anyhow!( + "ZIP signature digest must use sha256:<64 hex chars>" + )); + } + let pkcs7_base64 = base64::engine::general_purpose::STANDARD.encode(pkcs7_der); + let line = format!( + "{ZIP_AUTHENTICODE_PREFIX}{},{}", + digest.to_ascii_lowercase(), + pkcs7_base64 + ); + parse_signature_comment_line(&line)?; + Ok(line) +} + +pub fn signature_script_from_comment_line(line: &str) -> Result { + let sig = parse_signature_comment_line(line)?; + Ok(signature_script_from_parts(&sig.digest, &sig.pkcs7_base64)) +} + +pub fn unsigned_signature_script_bytes(digest: &str) -> Vec { + digest.as_bytes().to_vec() +} + +pub fn signature_comment_line_from_script(script: &[u8]) -> Result { + let text = std::str::from_utf8(script).context("signed ZIP .sig.ps1 is not UTF-8")?; + let mut lines = text.lines(); + let digest = lines + .next() + .map(str::trim) + .ok_or_else(|| anyhow!("signed ZIP .sig.ps1 is empty"))?; + if !is_sha256_digest_string(digest) { + return Err(anyhow!( + "signed ZIP .sig.ps1 first line must be sha256:<64 hex chars>" + )); + } + + let mut in_block = false; + let mut pkcs7_base64 = String::new(); + for line in lines { + let trimmed = line.trim(); + if trimmed == BEGIN_SIG_BLOCK { + in_block = true; + continue; + } + if trimmed == END_SIG_BLOCK { + break; + } + if in_block && let Some(rest) = trimmed.strip_prefix("# ") { + pkcs7_base64.push_str(rest.trim()); + } + } + + let line = format!( + "{ZIP_AUTHENTICODE_PREFIX}{},{}", + digest.to_ascii_lowercase(), + pkcs7_base64 + ); + parse_signature_comment_line(&line)?; + Ok(line) +} + +pub fn set_zip_comment(bytes: &[u8], comment: &[u8]) -> Result> { + if comment.len() > MAX_ZIP_COMMENT_LEN { + return Err(anyhow!( + "ZIP comment is too large for EOCD comment field ({} > {MAX_ZIP_COMMENT_LEN})", + comment.len() + )); + } + let eocd = find_eocd(bytes)?; + let mut out = bytes[..eocd.comment_offset].to_vec(); + let len = comment.len() as u16; + out[eocd.offset + EOCD_COMMENT_LEN_OFFSET..eocd.offset + EOCD_COMMENT_LEN_OFFSET + 2] + .copy_from_slice(&len.to_le_bytes()); + out.extend_from_slice(comment); + Ok(out) +} + +pub fn embed_signature_comment_line(bytes: &[u8], line: &str) -> Result> { + parse_signature_comment_line(line)?; + set_zip_comment(bytes, line.as_bytes()) +} + +pub fn verify_zip_digest_binding(bytes: &[u8]) -> Result { + let line = signature_comment_line(bytes)?; + let sig = parse_signature_comment_line(&line)?; + let computed = zip_authenticode_digest_string(bytes)?; + if sig.digest != computed { + return Err(anyhow!( + "ZIP Authenticode digest mismatch: embedded {} computed {}", + sig.digest, + computed + )); + } + Ok(sig) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tiny_zip(comment: &[u8]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&[ + 0x50, 0x4b, 0x03, 0x04, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, + ]); + let central_offset = out.len() as u32; + out.extend_from_slice(&[ + 0x50, 0x4b, 0x01, 0x02, 20, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]); + out.extend_from_slice(&EOCD_SIG); + out.extend_from_slice(&[0, 0, 0, 0, 1, 0, 1, 0]); + out.extend_from_slice(&46u32.to_le_bytes()); + out.extend_from_slice(¢ral_offset.to_le_bytes()); + out.extend_from_slice(&(comment.len() as u16).to_le_bytes()); + out.extend_from_slice(comment); + out + } + + #[test] + fn digest_ignores_existing_comment() { + let unsigned = tiny_zip(b""); + let commented = tiny_zip(b"hello"); + assert_eq!( + zip_authenticode_digest_string(&unsigned).unwrap(), + zip_authenticode_digest_string(&commented).unwrap() + ); + } + + #[test] + fn eocd_scan_rejects_false_signature_in_comment() { + let zip = tiny_zip(b"prefix PK\x05\x06 fake"); + let eocd = find_eocd(&zip).unwrap(); + assert_eq!(eocd.comment_len, b"prefix PK\x05\x06 fake".len()); + } + + #[test] + fn signature_comment_roundtrips_script_format() { + let pkcs7 = base64::engine::general_purpose::STANDARD.encode([1u8, 2, 3, 4]); + let line = format!("ZipAuthenticode=sha256:{},{}", "a".repeat(64), pkcs7); + let script = signature_script_from_comment_line(&line).unwrap(); + assert!(script.contains(BEGIN_SIG_BLOCK)); + assert_eq!( + signature_comment_line_from_script(script.as_bytes()).unwrap(), + line + ); + } + + #[test] + fn embed_replaces_zip_comment() { + let zip = tiny_zip(b"old"); + let pkcs7 = base64::engine::general_purpose::STANDARD.encode([1u8, 2, 3]); + let line = format!("ZipAuthenticode=sha256:{},{}", "b".repeat(64), pkcs7); + let signed = embed_signature_comment_line(&zip, &line).unwrap(); + assert_eq!(zip_comment(&signed).unwrap(), line.as_bytes()); + } +} diff --git a/docs/zip-authenticode-signing.md b/docs/zip-authenticode-signing.md new file mode 100644 index 0000000..f133814 --- /dev/null +++ b/docs/zip-authenticode-signing.md @@ -0,0 +1,98 @@ +# ZIP Authenticode signing + +`psign` supports the custom ZIP Authenticode convention used by +[`Devolutions/devolutions-authenticode`](https://github.com/Devolutions/devolutions-authenticode). +This is not a Windows SIP and native `signtool verify` does not verify the ZIP +file directly. Instead, the ZIP comment stores an Authenticode-signed +PowerShell-script representation of the ZIP digest. + +## Format + +Signing computes SHA-256 over the ZIP bytes from the beginning of the file +through the end-of-central-directory (EOCD) record. Before hashing, the two-byte +EOCD comment length field is treated as zero and the existing ZIP comment bytes +are excluded. The digest string is: + +```text +sha256:<64 lowercase hex chars> +``` + +That string is written to a temporary UTF-8 `.sig.ps1` file with no byte-order +mark and no trailing newline. The temporary script is Authenticode-signed, then +the PKCS#7 payload from the script signature block is embedded as the ZIP +comment: + +```text +ZipAuthenticode=sha256:<64 lowercase hex chars>, +``` + +The ZIP comment is limited by the ZIP format to 65,535 bytes. Signing replaces +the entire pre-existing ZIP comment with the `ZipAuthenticode=` payload, matching +the upstream convention. + +## Signing and verifying + +On Windows, `.zip` files are first-class inputs to top-level `sign` and `verify`: + +```powershell +psign-tool sign --pfx cert.pfx --password "pfx-password" --digest sha256 archive.zip +psign-tool verify --allow-test-root archive.zip +``` + +For direct PFX input, `psign` builds the same Authenticode CMS signature that +Windows script signing would embed in the temporary `.sig.ps1` bridge file, then +stores that PKCS#7 in the ZIP comment. Other signing sources are routed through +the existing Windows script-signing bridge when the local signing stack supports +PowerShell script subjects. Options that depend on native SIP features or +multi-signature storage are rejected for ZIP input, including append-signature, +page-hash, sealing, detached PKCS#7 output, and split-digest flows. + +Verification extracts the ZIP comment, validates the digest binding against the +current ZIP bytes, reconstructs the `.sig.ps1` file, and verifies that script's +Authenticode signature. A successful result means the ZIP bytes match the +embedded digest and the reconstructed script signature chains according to the +selected verification policy. + +Portable verification is available without Win32: + +```powershell +psign-tool portable verify-zip archive.zip +psign-tool portable trust-verify-zip archive.zip --anchor-dir anchors +psign-tool --mode portable verify archive.zip --anchor-dir anchors +``` + +`verify-zip` checks the ZIP digest binding and PowerShell-script PKCS#7 indirect +digest consistency. `trust-verify-zip` also validates the PKCS#7 signer chain +against explicit anchors; portable mode never uses the OS trust store. + +## Security and interoperability notes + +- This custom format is recognized by `psign` and Devolutions tooling, not by the + Windows SIP registry. +- The signature covers ZIP bytes through EOCD with the comment length zeroed, so + changes to ZIP entries, central directory records, or EOCD fields invalidate + the digest. Replacing or appending ZIP comments also invalidates the format + unless the new comment contains a matching `ZipAuthenticode=` payload. +- `psign` validates the EOCD candidate strictly by requiring the EOCD comment to + end exactly at EOF. This avoids treating `PK\x05\x06` bytes inside a comment + as the actual EOCD record. +- ZIP64 archives are supported when they still include the standard EOCD comment + field, as required by the ZIP format. + +## Test fixtures + +The committed fixtures live under `tests\fixtures\zip-authenticode`: + +- `unsigned\sample.zip` +- `signed\sample.signed.zip` +- `zip-authenticode-fixtures.json` + +Regenerate them from a Windows checkout with: + +```powershell +scripts\ci\build-zip-authenticode-fixtures.ps1 -Force +``` + +The script uses the public Devolutions test PFX by default: +`tests\fixtures\devolutions-authenticode\authenticode-test-cert.pfx` with +password `CodeSign123!`. diff --git a/scripts/ci/build-zip-authenticode-fixtures.ps1 b/scripts/ci/build-zip-authenticode-fixtures.ps1 new file mode 100644 index 0000000..e1409c9 --- /dev/null +++ b/scripts/ci/build-zip-authenticode-fixtures.ps1 @@ -0,0 +1,151 @@ +# Build small unsigned and custom ZIP Authenticode signed fixtures. +param( + [string]$WorkspaceRoot = "", + [string]$OutputDir = "", + [string]$PsignToolPath = "", + [string]$PfxPath = "", + [string]$PfxPassword = "CodeSign123!", + [switch]$Force +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +if (-not $WorkspaceRoot) { + $WorkspaceRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +} +$WorkspaceRoot = (Resolve-Path -LiteralPath $WorkspaceRoot).Path + +if (-not $OutputDir) { $OutputDir = Join-Path $WorkspaceRoot "tests\fixtures\zip-authenticode" } +elseif (-not [System.IO.Path]::IsPathRooted($OutputDir)) { $OutputDir = Join-Path $WorkspaceRoot $OutputDir } + +if (-not $PfxPath) { + $PfxPath = Join-Path $WorkspaceRoot "tests\fixtures\devolutions-authenticode\authenticode-test-cert.pfx" +} +elseif (-not [System.IO.Path]::IsPathRooted($PfxPath)) { + $PfxPath = Join-Path $WorkspaceRoot $PfxPath +} +if (-not (Test-Path -LiteralPath $PfxPath)) { throw "PFX not found: $PfxPath" } + +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem + +function Convert-ToManifestPath { + param([Parameter(Mandatory)][string]$Path) + $full = (Resolve-Path -LiteralPath $Path).Path + return [System.IO.Path]::GetRelativePath($WorkspaceRoot, $full) +} + +function Write-ZipEntry { + param( + [Parameter(Mandatory)]$Archive, + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string]$Text + ) + $entry = $Archive.CreateEntry($Name, [System.IO.Compression.CompressionLevel]::NoCompression) + $entry.LastWriteTime = [System.DateTimeOffset]::new(2024, 1, 1, 0, 0, 0, [System.TimeSpan]::Zero) + $stream = $entry.Open() + try { + $writer = [System.IO.StreamWriter]::new($stream, [System.Text.UTF8Encoding]::new($false)) + try { $writer.Write($Text) } + finally { $writer.Dispose() } + } + finally { + $stream.Dispose() + } +} + +function New-UnsignedZipFixture { + param([Parameter(Mandatory)][string]$Path) + if (Test-Path -LiteralPath $Path) { Remove-Item -LiteralPath $Path -Force } + $fs = [System.IO.File]::Open($Path, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::ReadWrite) + try { + $zip = [System.IO.Compression.ZipArchive]::new($fs, [System.IO.Compression.ZipArchiveMode]::Create, $false) + try { + Write-ZipEntry -Archive $zip -Name "README.txt" -Text "psign ZIP Authenticode fixture`n" + Write-ZipEntry -Archive $zip -Name "payload/config.json" -Text "{`"name`":`"zip-authenticode-fixture`",`"version`":1}`n" + } + finally { + $zip.Dispose() + } + } + finally { + $fs.Dispose() + } +} + +function Invoke-PsignZipSign { + param([Parameter(Mandatory)][string]$Path) + if ($PsignToolPath) { + $output = & $PsignToolPath sign --pfx $PfxPath --password $PfxPassword --digest sha256 $Path 2>&1 + } + else { + Push-Location $WorkspaceRoot + try { + $output = & cargo run --quiet --bin psign-tool -- sign --pfx $PfxPath --password $PfxPassword --digest sha256 $Path 2>&1 + } + finally { + Pop-Location + } + } + if ($LASTEXITCODE -ne 0) { + throw "psign ZIP signing failed for $Path`n$($output -join "`n")" + } +} + +function Add-ManifestEntry { + param( + [Parameter(Mandatory)]$List, + [Parameter(Mandatory)][string]$Id, + [Parameter(Mandatory)][string]$State, + [Parameter(Mandatory)][string]$Path, + [string]$SourcePath = "", + [string]$Tool = "" + ) + $item = Get-Item -LiteralPath $Path + $entry = [ordered]@{ + id = $Id + family = "zip" + state = $State + path = Convert-ToManifestPath -Path $item.FullName + size_bytes = $item.Length + sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $item.FullName).Hash.ToLowerInvariant() + } + if ($SourcePath) { $entry.source_path = Convert-ToManifestPath -Path $SourcePath } + if ($Tool) { $entry.tool = $Tool } + $List.Add($entry) +} + +if ((Test-Path -LiteralPath $OutputDir) -and -not $Force) { + throw "Output directory already exists: $OutputDir. Pass -Force to replace it." +} +if (Test-Path -LiteralPath $OutputDir) { + Remove-Item -LiteralPath $OutputDir -Recurse -Force +} + +$unsignedDir = Join-Path $OutputDir "unsigned" +$signedDir = Join-Path $OutputDir "signed" +New-Item -ItemType Directory -Force -Path $unsignedDir, $signedDir | Out-Null + +$unsignedZip = Join-Path $unsignedDir "sample.zip" +$signedZip = Join-Path $signedDir "sample.signed.zip" + +New-UnsignedZipFixture -Path $unsignedZip +Copy-Item -LiteralPath $unsignedZip -Destination $signedZip -Force +Invoke-PsignZipSign -Path $signedZip + +$entries = [System.Collections.Generic.List[object]]::new() +Add-ManifestEntry -List $entries -Id "zip-authenticode-unsigned" -State "unsigned" -Path $unsignedZip +Add-ManifestEntry -List $entries -Id "zip-authenticode-signed" -State "signed" -Path $signedZip -SourcePath $unsignedZip -Tool "psign ZIP Authenticode" + +$manifest = [ordered]@{ + generated_by = "scripts/ci/build-zip-authenticode-fixtures.ps1" + pfx = Convert-ToManifestPath -Path $PfxPath + pfx_thumbprint = ([System.Security.Cryptography.X509Certificates.X509Certificate2]::new($PfxPath, $PfxPassword)).Thumbprint + entries = $entries +} +$manifestPath = Join-Path $OutputDir "zip-authenticode-fixtures.json" +$manifestJson = ($manifest | ConvertTo-Json -Depth 10) -replace "`r`n", "`n" +[System.IO.File]::WriteAllText($manifestPath, $manifestJson + "`n", [System.Text.UTF8Encoding]::new($false)) + +Write-Host "ZIP Authenticode fixtures: $OutputDir" diff --git a/src/cert_store.rs b/src/cert_store.rs index 21e3d4f..2c0bd9f 100644 --- a/src/cert_store.rs +++ b/src/cert_store.rs @@ -548,7 +548,7 @@ fn write_private_key_file(path: &Path, bytes: &[u8]) -> Result<()> { } } -fn load_pfx_cert_and_key(bytes: &[u8], password: &str) -> Result<(Vec, String)> { +pub(crate) fn load_pfx_cert_and_key(bytes: &[u8], password: &str) -> Result<(Vec, String)> { let crypto_context = Pkcs12CryptoContext::new_with_password(password)?; let parsing_params = Pkcs12ParsingParams::default(); let pfx = Pfx::from_der(bytes, &crypto_context, &parsing_params)?; diff --git a/src/lib.rs b/src/lib.rs index dfe202a..0f2fc02 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -141,6 +141,7 @@ fn portable_command_for_path(path: &std::path::Path) -> anyhow::Result<&'static "msi" | "msp" => Ok("verify-msi"), "wim" | "esd" => Ok("verify-esd"), "msix" | "appx" | "msixbundle" | "appxbundle" => Ok("verify-msix"), + "zip" => Ok("verify-zip"), "cat" => Ok("verify-catalog"), "ps1" | "psd1" | "psm1" | "ps1xml" | "psc1" | "cdxml" | "mof" | "js" | "vbs" | "wsf" => { Ok("verify-script") @@ -224,6 +225,7 @@ fn execute_portable_verify(args: &crate::cli::VerifyArgs) -> anyhow::Result "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}" diff --git a/src/win/mod.rs b/src/win/mod.rs index 5a0a843..8740bab 100644 --- a/src/win/mod.rs +++ b/src/win/mod.rs @@ -24,3 +24,4 @@ pub mod verify_catalog_resolve; pub mod verify_chain; pub mod verify_detached; pub mod verify_format; +pub mod zip_authenticode; diff --git a/src/win/sign.rs b/src/win/sign.rs index 5e56c0f..77fb070 100644 --- a/src/win/sign.rs +++ b/src/win/sign.rs @@ -379,6 +379,11 @@ fn try_sign_one( if args.skip_signed && file_has_embedded_authenticode(target) { return Ok(format!("Skipped (already signed): {}\n", target.display())); } + if crate::win::zip_authenticode::is_zip_path(target) { + return crate::win::zip_authenticode::sign_zip_with(args, target, |script| { + sign_one_target(args, global, script) + }); + } let block = sign_one_target(args, global, target)?; post_sign_rust_sip(backend, target, global)?; Ok(block) @@ -435,37 +440,51 @@ pub fn sign_file(args: &SignArgs, global: &GlobalOpts) -> Result let backend = rust_sip_backend(args); if matches!(backend, Some(RustSipBackend::Pe)) { for p in &targets { - ensure_rust_sip_pe_allowed_for_format(p)?; + if !crate::win::zip_authenticode::is_zip_path(p) { + ensure_rust_sip_pe_allowed_for_format(p)?; + } } } if matches!(backend, Some(RustSipBackend::Script)) { for p in &targets { - ensure_rust_sip_script_allowed_for_format(p)?; + if !crate::win::zip_authenticode::is_zip_path(p) { + ensure_rust_sip_script_allowed_for_format(p)?; + } } } if matches!(backend, Some(RustSipBackend::Msi)) { for p in &targets { - ensure_rust_sip_msi_allowed_for_format(p)?; + if !crate::win::zip_authenticode::is_zip_path(p) { + ensure_rust_sip_msi_allowed_for_format(p)?; + } } } if matches!(backend, Some(RustSipBackend::Esd)) { for p in &targets { - ensure_rust_sip_esd_allowed_for_format(p)?; + if !crate::win::zip_authenticode::is_zip_path(p) { + ensure_rust_sip_esd_allowed_for_format(p)?; + } } } if matches!(backend, Some(RustSipBackend::Msix)) { for p in &targets { - ensure_rust_sip_msix_allowed_for_format(p)?; + if !crate::win::zip_authenticode::is_zip_path(p) { + ensure_rust_sip_msix_allowed_for_format(p)?; + } } } if matches!(backend, Some(RustSipBackend::Cab)) { for p in &targets { - ensure_rust_sip_cab_allowed_for_format(p)?; + if !crate::win::zip_authenticode::is_zip_path(p) { + ensure_rust_sip_cab_allowed_for_format(p)?; + } } } if matches!(backend, Some(RustSipBackend::Catalog)) { for p in &targets { - ensure_rust_sip_catalog_allowed_for_format(p)?; + if !crate::win::zip_authenticode::is_zip_path(p) { + ensure_rust_sip_catalog_allowed_for_format(p)?; + } } } diff --git a/src/win/verify.rs b/src/win/verify.rs index 57f7b0a..d62f6bc 100644 --- a/src/win/verify.rs +++ b/src/win/verify.rs @@ -394,7 +394,7 @@ fn verify_all_signatures( Ok((out, accum_warnings, first_summary)) } -fn output_with_verify_warnings( +pub(crate) fn output_with_verify_warnings( args: &VerifyArgs, out: String, summary: Option<&VerifyChainSummary>, @@ -449,7 +449,7 @@ fn embedded_verbose_suffix(args: &VerifyArgs, global: &GlobalOpts) -> String { } /// Embedded WinTrust verify for one path; returns stdout block, post-filter warnings, summary, and whether timestamp is absent. -fn run_embedded_for_target( +pub(crate) fn run_embedded_for_target( target: &Path, args: &VerifyArgs, global: &GlobalOpts, @@ -784,6 +784,45 @@ pub fn verify_file(args: &VerifyArgs, global: &GlobalOpts) -> Result bool { + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("zip")) +} + +fn temporary_sig_path(target: &Path) -> PathBuf { + let stem = target + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("zip") + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect::(); + std::env::temp_dir().join(format!( + "psign_zip_authenticode_{}_{}_{}.sig.ps1", + std::process::id(), + TEMP_COUNTER.fetch_add(1, Ordering::Relaxed), + stem + )) +} + +fn validate_zip_sign_args(args: &SignArgs) -> Result<()> { + if args.append_signature { + return Err(anyhow!( + "ZIP Authenticode signing stores one signature in the ZIP comment; --append-signature is not supported" + )); + } + if args.page_hashes || args.no_page_hashes { + return Err(anyhow!( + "ZIP Authenticode signing does not support page-hash flags" + )); + } + if args.add_sealing_signature + || args.intent_to_seal + || args.force_seal_or_resign + || args.sign_no_seal_warn + || args.sign_no_enclave_warn + { + return Err(anyhow!( + "ZIP Authenticode signing does not support sealing or enclave signing flags" + )); + } + Ok(()) +} + +fn zip_digest_algorithm(args: &SignArgs) -> Result { + Ok(match args.digest { + crate::cli::DigestAlgorithm::Sha256 | crate::cli::DigestAlgorithm::CertHash => { + pkcs7::AuthenticodeSigningDigest::Sha256 + } + crate::cli::DigestAlgorithm::Sha384 => pkcs7::AuthenticodeSigningDigest::Sha384, + crate::cli::DigestAlgorithm::Sha512 => pkcs7::AuthenticodeSigningDigest::Sha512, + crate::cli::DigestAlgorithm::Sha1 => { + return Err(anyhow!( + "ZIP Authenticode PFX signing supports SHA-256, SHA-384, or SHA-512 script signatures" + )); + } + }) +} + +fn can_sign_zip_directly_from_pfx(args: &SignArgs) -> bool { + args.pfx.is_some() + && args.timestamp_url.is_none() + && args.legacy_timestamp_url.is_none() + && args.seal_timestamp_url.is_none() + && args.dlib.is_none() + && args.trusted_signing_dlib_root.is_none() + && args.dmdf.is_none() + && args.azure_key_vault_url.is_none() + && args.artifact_signing_metadata.is_none() +} + +fn sign_zip_comment_from_pfx(args: &SignArgs, digest: &str) -> Result> { + if !can_sign_zip_directly_from_pfx(args) { + return Ok(None); + } + let pfx = args + .pfx + .as_ref() + .expect("can_sign_zip_directly_from_pfx checked PFX"); + let pfx_bytes = std::fs::read(pfx).with_context(|| format!("read PFX {}", pfx.display()))?; + let (cert_der, key_pem) = crate::cert_store::load_pfx_cert_and_key( + &pfx_bytes, + args.password.as_deref().unwrap_or_default(), + ) + .with_context(|| format!("parse PFX {}", pfx.display()))?; + let signer_cert = rdp::parse_certificate(&cert_der).context("parse PFX signer certificate")?; + let private_key = + rdp::parse_rsa_private_key(key_pem.as_bytes()).context("parse PFX private key")?; + let mut chain = Vec::with_capacity(args.additional_certs.len()); + for cert in &args.additional_certs { + let bytes = std::fs::read(cert) + .with_context(|| format!("read additional cert {}", cert.display()))?; + chain.push( + rdp::parse_certificate(&bytes) + .with_context(|| format!("parse additional cert {}", cert.display()))?, + ); + } + let pkcs7 = pkcs7::create_script_authenticode_pkcs7_der_rsa( + &zip_authenticode::unsigned_signature_script_bytes(digest), + zip_digest_algorithm(args)?, + signer_cert, + chain, + private_key, + )?; + zip_authenticode::signature_comment_line_from_pkcs7_der(digest, &pkcs7).map(Some) +} + +fn validate_zip_verify_args(args: &VerifyArgs) -> Result<()> { + if args.detached_pkcs7.is_some() + || args.detached_pkcs7_content.is_some() + || args.catalog.is_some() + || args.catalog_search.is_some() + || args.catalog_database_guid.is_some() + { + return Err(anyhow!( + "ZIP Authenticode verification cannot be combined with detached PKCS#7 or catalog verification modes" + )); + } + if args.all_signatures || args.signature_index.is_some() || args.multiple_semantics { + return Err(anyhow!( + "ZIP Authenticode stores a single custom comment signature; signature enumeration flags are not supported" + )); + } + if args.verify_page_hashes || args.verify_sealing_signatures { + return Err(anyhow!( + "ZIP Authenticode verification does not support page-hash or sealing checks" + )); + } + if args.rust_sip_pe_digest_check + || args.rust_sip_msi_digest_check + || args.rust_sip_esd_digest_check + || args.rust_sip_msix_digest_check + || args.rust_sip_cab_digest_check + || args.rust_sip_catalog_digest_check + || args.rust_sip_all_digest_checks + { + return Err(anyhow!( + "ZIP Authenticode verification uses the custom ZIP digest plus a reconstructed PowerShell signature; non-script Rust SIP checks do not apply" + )); + } + Ok(()) +} + +pub(crate) fn sign_zip_with(args: &SignArgs, target: &Path, sign_script: F) -> Result +where + F: FnOnce(&Path) -> Result, +{ + validate_zip_sign_args(args)?; + let original = std::fs::read(target).with_context(|| format!("read {}", target.display()))?; + let digest = zip_authenticode::zip_authenticode_digest_string(&original)?; + let tmp = temporary_sig_path(target); + let result = (|| { + std::fs::write( + &tmp, + zip_authenticode::unsigned_signature_script_bytes(&digest), + ) + .with_context(|| format!("write temporary ZIP signature script {}", tmp.display()))?; + let (comment_line, report) = if let Some(comment_line) = + sign_zip_comment_from_pfx(args, &digest)? + { + let report = format!( + "Successfully signed\nmode=zip-authenticode-pfx\nfile={}\ndigest={}\n", + target.display(), + args.digest.as_signtool_name() + ); + (comment_line, report) + } else { + let report = sign_script(&tmp)?; + let signed_script = std::fs::read(&tmp) + .with_context(|| format!("read signed ZIP signature script {}", tmp.display()))?; + ( + zip_authenticode::signature_comment_line_from_script(&signed_script)?, + report.replace(&tmp.display().to_string(), &target.display().to_string()), + ) + }; + let signed_zip = zip_authenticode::embed_signature_comment_line(&original, &comment_line)?; + std::fs::write(target, signed_zip) + .with_context(|| format!("write signed ZIP {}", target.display()))?; + Ok(report + &format!("zip_authenticode_digest={digest}\n")) + })(); + let _ = std::fs::remove_file(&tmp); + result +} + +pub(crate) fn verify_zip( + target: &Path, + args: &VerifyArgs, + global: &GlobalOpts, +) -> Result { + validate_zip_verify_args(args)?; + let zip = std::fs::read(target).with_context(|| format!("read {}", target.display()))?; + let sig = zip_authenticode::verify_zip_digest_binding(&zip)?; + let script = zip_authenticode::signature_script_from_parts(&sig.digest, &sig.pkcs7_base64); + let tmp = temporary_sig_path(target); + let result = (|| { + std::fs::write(&tmp, script).with_context(|| { + format!("write temporary ZIP verification script {}", tmp.display()) + })?; + let (out, post_warnings, summary, ts_none) = run_embedded_for_target(&tmp, args, global)?; + let mut zip_out = out.replace(&tmp.display().to_string(), &target.display().to_string()); + if global.verbose { + zip_out.push_str("ZIP Authenticode: custom EOCD comment signature\n"); + zip_out.push_str(&format!("ZIP digest: {}\n", sig.digest)); + } + Ok(output_with_verify_warnings( + args, + zip_out, + summary.as_ref(), + &post_warnings, + ts_none, + )) + })(); + let _ = std::fs::remove_file(&tmp); + result +} diff --git a/tests/cli_pe_digest.rs b/tests/cli_pe_digest.rs index 81ee59d..53c2d44 100644 --- a/tests/cli_pe_digest.rs +++ b/tests/cli_pe_digest.rs @@ -59,9 +59,11 @@ fn help_lists_core_subcommands() { "verify-pe", "trust-verify-pe", "trust-verify-cab", + "trust-verify-zip", "trust-verify-catalog", "trust-verify-detached", "verify-cab", + "verify-zip", "extract-cab-pkcs7", "cab-signer-rs256-prehash", "verify-msi", @@ -202,6 +204,13 @@ fn package_fixture(rel: &str) -> PathBuf { .join(rel.replace('/', &separator)) } +fn zip_fixture(rel: &str) -> PathBuf { + let separator = std::path::MAIN_SEPARATOR.to_string(); + repo_root() + .join("tests/fixtures/zip-authenticode") + .join(rel.replace('/', &separator)) +} + fn hex_lower(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } @@ -1888,6 +1897,41 @@ fn portable_verify_negative_trust_cab_no_anchors_cli() { .stderr(predicate::str::contains("no trust anchors")); } +#[test] +fn portable_verify_zip_fixture_cli() { + let signed = zip_fixture("signed/sample.signed.zip"); + + let mut verify = portable_cmd(); + verify.arg("verify-zip").arg(&signed); + verify.assert().success(); + + let mut trust = portable_cmd(); + trust + .arg("trust-verify-zip") + .arg(&signed) + .arg("--anchor-dir") + .arg(anchor_dir(&repo_root())); + trust + .assert() + .success() + .stdout(predicate::str::contains("trust-verify-zip: ok")); +} + +#[test] +fn portable_verify_zip_fixture_detects_tamper_cli() { + let dir = tempfile::tempdir().unwrap(); + let tampered = dir.path().join("tampered.zip"); + let mut bytes = std::fs::read(zip_fixture("signed/sample.signed.zip")).unwrap(); + bytes[30] ^= 0x01; + std::fs::write(&tampered, bytes).unwrap(); + + let mut cmd = portable_cmd(); + cmd.arg("verify-zip").arg(&tampered); + cmd.assert() + .failure() + .stderr(predicate::str::contains("ZIP Authenticode digest mismatch")); +} + #[test] fn inspect_pkcs7_parity_embedded_pe_reports_agree_tiny32() { let pe_bytes = std::fs::read(tiny32_fixture()).expect("read tiny32"); diff --git a/tests/corpus_sign_verify.rs b/tests/corpus_sign_verify.rs index 98aeca0..55dd7fe 100644 --- a/tests/corpus_sign_verify.rs +++ b/tests/corpus_sign_verify.rs @@ -216,6 +216,56 @@ fn unsigned_corpus_freshly_signed_with_psign_verifies_with_psign() { } } +#[test] +fn committed_zip_authenticode_fixture_verifies_with_psign() { + let repo = repo_root(); + verify_embedded_with_psign( + &repo.join("tests\\fixtures\\zip-authenticode\\signed\\sample.signed.zip"), + "zip-authenticode-signed", + ); +} + +#[test] +fn unsigned_zip_freshly_signed_with_psign_verifies_and_detects_tamper() { + let repo = repo_root(); + let temp = TempDir::new("psign-zip-authenticode"); + let dest = temp.path().join("sample.signed.zip"); + std::fs::copy( + repo.join("tests\\fixtures\\zip-authenticode\\unsigned\\sample.zip"), + &dest, + ) + .expect("copy unsigned ZIP fixture"); + + let sign = psign() + .args(["sign", "--pfx"]) + .arg(test_pfx_path(&repo)) + .args(["--password", TEST_PFX_PASSWORD, "--digest", "sha256"]) + .arg(&dest) + .output() + .expect("run psign ZIP sign"); + assert_success(sign, "psign sign ZIP"); + verify_embedded_with_psign(&dest, "fresh ZIP"); + + let mut bytes = std::fs::read(&dest).expect("read signed ZIP"); + bytes[30] ^= 0x01; + std::fs::write(&dest, bytes).expect("write tampered ZIP"); + let verify = psign() + .args(["verify", "--policy", "pa", "--allow-test-root"]) + .arg(&dest) + .output() + .expect("run psign verify tampered ZIP"); + assert!( + !verify.status.success(), + "tampered ZIP unexpectedly verified\n{}", + output_text(&verify) + ); + assert!( + output_text(&verify).contains("ZIP Authenticode digest mismatch"), + "tampered ZIP should fail on digest mismatch\n{}", + output_text(&verify) + ); +} + fn verify_embedded_with_psign(path: &Path, label: &str) { let verify = psign() .args(["verify", "--policy", "pa", "--allow-test-root"]) diff --git a/tests/fixture_vector_manifest.rs b/tests/fixture_vector_manifest.rs index b64503a..bc2413a 100644 --- a/tests/fixture_vector_manifest.rs +++ b/tests/fixture_vector_manifest.rs @@ -312,6 +312,44 @@ fn package_signing_fixture_manifest_matches_files() { ); } +#[test] +fn zip_authenticode_fixture_manifest_matches_files() { + let manifest: serde_json::Value = serde_json::from_str(include_str!( + "fixtures/zip-authenticode/zip-authenticode-fixtures.json" + )) + .expect("ZIP Authenticode fixture manifest JSON"); + + assert_eq!( + manifest["generated_by"], + "scripts/ci/build-zip-authenticode-fixtures.ps1" + ); + assert_eq!( + manifest["pfx_thumbprint"], + "A9FDF3593E91689CC93B1CEBED5E8FFC1F6FEE38" + ); + + let entries = manifest["entries"] + .as_array() + .expect("ZIP Authenticode entries must be an array"); + assert_eq!(entries.len(), 2, "ZIP Authenticode fixture count"); + assert_hash_entries(entries); + + let states: HashSet<_> = entries + .iter() + .map(|entry| entry["state"].as_str().expect("state").to_owned()) + .collect(); + assert_eq!( + states, + HashSet::from(["unsigned".to_owned(), "signed".to_owned()]) + ); + assert!( + entries + .iter() + .all(|entry| entry["family"].as_str().expect("family") == "zip"), + "ZIP fixture manifest should contain only zip family entries" + ); +} + fn manifest() -> serde_json::Value { serde_json::from_str(include_str!("fixtures/code-signing-vectors.json")) .expect("code-signing vector manifest JSON") diff --git a/tests/fixtures/zip-authenticode/signed/sample.signed.zip b/tests/fixtures/zip-authenticode/signed/sample.signed.zip new file mode 100644 index 0000000000000000000000000000000000000000..0a077f26b40ef0e1b3c3a475d90b610ceaf91fa0 GIT binary patch literal 2331 zcmbtUNzUuW5q6vj@HXDZc{@Y4#Yv>}057`PoJEQhNpaY5o+Qp9MFv^r6uCielndk_ z;r|lg4T)E2qQI)^uBxxQ3ZJh0`ZwQx_3!m3_iyMQ${#o5O}^RW$^oQuKaH=k9g3{} z&X@G>z;n!#dMu(QPQFWv*ZAy{Z}iX7bn#!m$F}e{`NJk{u-uy<-bYQH7THhb(A0l^ zR|k*e-TQZI(f$~GlJdv@qpw<@2kkGQ{#`x4s6 zAYoJg2T)!4?eE?WeZ76l_W%1P|NOmQw4c7i&qE#%2l_pk#w2!#k2rQBLndJ;hCT*S zcsd?Wn?Xb|gJa<-J`iCT#G4hxDScp~V@iidB8~7LR7v7n;K@qkOZgOOz=+sp^W80$ zg+9jugL@$hoMCp)@+yPAp>iPO=6LoLW%eKh0;|mugbHj`A(}PtxnC^717s=$0B{`K zs}}EVCuq2ZAyIaVQw6mu` zzdT#g<-BFP12PfI0?e4ROd1-XZZ#y9W5cM8>P2IPtCf3jP5`2dc}B*kZ@`_Q0cK_8 zT9u6ifZH6H=j&>Uv2dTlTFngih6Tdyx!Gk%T<;Qpy+@>x85lQ}=O=X2>WKrUHE! zfZts-+ldBl%J`I&Ft~RbkE>sl)?Z%mI3dJyd1$&$;M6;dE>M>y)kNqd9*z(@Xufsh z2a2!VNvHdcGR>90UgPkAuDU($Pwil{VCDb_v|1JH8D(4lu9yxCaSwrkK$ZX%s&BIs z%Z8zwnz0SWzyomE=70m2#XJ**Iqvyl z!%&3%c}9E2qf~ot!^`amL#>h-U0bN?7_ZtZHInRLr}VSBN{n;3Iy-PXS5l%I0mT%F zhnPKFdl^~O+7JOVJ%`deH$Bp{t=Ur66iVtaaVA~BgGJO5QC;q>jIE1!Sgz`WS#g?W zc+7jXLAtvQeR)CUaIQFwt9NH6_b1!H>Rqr8WO;Wp;9ysZ^&;^4LYSDsw3x(NOtfiE z?}i4!>qg%^gdF1al95B~H92%~>3hN7#Y$NApE5+CB@LPaXT1lH^XQwm9BaFCsC03`_m zZd!BB#EeAA=_|1mMLq?^a?Iz4;>7y+>X>Cwua^_qVeTz_9?bLgz{zx2a{3rhUZeDn z11zMy7$-CvSyb=}TlLnW!oiOH;Gp`X79=k8vtemaA-H8spEoNR4dYw*`yyM zK9Wr4s22TGlnc0Zpr85#W&{(k_$W-%HF8|5cCBZH!R&NN^=!mbuso@*87g|kP|!sC z)@iUsolTPoVWO5XR$^C+R(pE|vbi(mF3zi+7~`;}!l_Ej H?B{<0bx_*~ literal 0 HcmV?d00001 diff --git a/tests/fixtures/zip-authenticode/unsigned/sample.zip b/tests/fixtures/zip-authenticode/unsigned/sample.zip new file mode 100644 index 0000000000000000000000000000000000000000..f23f89647172aa5231375cde510d37610df6bef0 GIT binary patch literal 311 zcmWIWW@Zs#fB?manJ(A8<$)X!<^tj%S4S6LSG|&ol7iyQ^gM+q&j1C-(vpnSypqi1 z{FGFMw9JZ<(xOzZ02E^iQ<^mmfZ9P=7>ElJD|7M_Q}mPb^U^ZY^|Ffd^Qx8d5_40P ztdy!U3v?5ajMRl1sidP+mReMtnV+X*WmpTefRRar0k=zmwjcq7yV3QbI}M@99mvGu U&;V~%HjpAFAglz^D?l6u0HCiyBme*a literal 0 HcmV?d00001 diff --git a/tests/fixtures/zip-authenticode/zip-authenticode-fixtures.json b/tests/fixtures/zip-authenticode/zip-authenticode-fixtures.json new file mode 100644 index 0000000..fa8d8d3 --- /dev/null +++ b/tests/fixtures/zip-authenticode/zip-authenticode-fixtures.json @@ -0,0 +1,25 @@ +{ + "generated_by": "scripts/ci/build-zip-authenticode-fixtures.ps1", + "pfx": "tests\\fixtures\\devolutions-authenticode\\authenticode-test-cert.pfx", + "pfx_thumbprint": "A9FDF3593E91689CC93B1CEBED5E8FFC1F6FEE38", + "entries": [ + { + "id": "zip-authenticode-unsigned", + "family": "zip", + "state": "unsigned", + "path": "tests\\fixtures\\zip-authenticode\\unsigned\\sample.zip", + "size_bytes": 311, + "sha256": "3fd305d81093b7e74768acb9889e742cd710b9d52bbadcd7cdf657c8f6b82fc1" + }, + { + "id": "zip-authenticode-signed", + "family": "zip", + "state": "signed", + "path": "tests\\fixtures\\zip-authenticode\\signed\\sample.signed.zip", + "size_bytes": 2331, + "sha256": "8ea210d406f6b241b5c7b934e6a24543a2234339d0aa10224dec001015f45fdb", + "source_path": "tests\\fixtures\\zip-authenticode\\unsigned\\sample.zip", + "tool": "psign ZIP Authenticode" + } + ] +}