diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 7aa9e0a..e0cd9c4 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -28,6 +28,15 @@ jobs: - name: Unit tests (workspace libs + bins) run: cargo test --workspace --lib --bins + - name: Portable MSI signing accepted by Windows SIP + shell: pwsh + run: | + ./scripts/ci/bootstrap-devolutions-authenticode.ps1 + $msi = Join-Path $env:RUNNER_TEMP "psign_parity_minimal.msi" + ./scripts/ci/create-minimal-msi.ps1 -OutputMsi $msi + $env:PSIGN_MSI_UNSIGNED_FIXTURE = $msi + cargo test -p psign --test parity_signtool msi_sign_aligns_with_native_sip_stack -- --ignored --nocapture + - name: Portable digest CLI integration test run: cargo test -p psign --test cli_pe_digest --locked diff --git a/crates/psign-digest-cli/src/main.rs b/crates/psign-digest-cli/src/main.rs index cd7aaae..076f394 100644 --- a/crates/psign-digest-cli/src/main.rs +++ b/crates/psign-digest-cli/src/main.rs @@ -2123,7 +2123,7 @@ enum Command { #[arg(long, value_name = "PATH")] output: PathBuf, }, - /// Sign an MSI/MSP OLE package with portable Authenticode CMS and a DigitalSignature stream. + /// Sign an MSI/MSP OLE package with portable Authenticode CMS and the required MSI signature streams. SignMsi { /// Input MSI/MSP path. #[arg(value_name = "PATH")] @@ -3790,15 +3790,13 @@ fn create_cab_authenticode_pkcs7_der_artifact_signing( #[cfg(feature = "artifact-signing-rest")] fn create_msi_authenticode_pkcs7_der_artifact_signing( - msi: &[u8], + msi_digest: &[u8], digest: PortableSignDigest, chain_certs: Vec, args: &ArtifactSigningPortableOptions, ) -> Result> { let digest_algorithm: pkcs7::AuthenticodeSigningDigest = digest.into(); - let msi_digest = - msi_digest::compute_msi_authenticode_digest(msi, digest_algorithm.pe_hash_kind())?; - let indirect = pkcs7::msi_spc_indirect_data(digest_algorithm, &msi_digest)?; + let indirect = pkcs7::msi_spc_indirect_data(digest_algorithm, msi_digest)?; create_authenticode_pkcs7_der_artifact_signing_from_indirect( indirect, digest, @@ -4659,6 +4657,11 @@ where output, } => { let msi = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let digest_algorithm: pkcs7::AuthenticodeSigningDigest = digest.into(); + let prepared = msi_digest::prepare_msi_for_authenticode_signing( + &msi, + digest_algorithm.pe_hash_kind(), + )?; let has_artifact = artifact_signing_requested(&artifact_signing); if has_artifact && (cert.is_some() || key.is_some()) { return Err(anyhow!( @@ -4669,7 +4672,7 @@ where #[cfg(feature = "artifact-signing-rest")] { create_msi_authenticode_pkcs7_der_artifact_signing( - &msi, + prepared.digest(), digest, chain_certs, &artifact_signing, @@ -4701,8 +4704,8 @@ where let private_key = rdp::parse_rsa_private_key(&key_bytes) .with_context(|| format!("parse RSA private key {}", key.display()))?; pkcs7::create_msi_authenticode_pkcs7_der_rsa( - &msi, - digest.into(), + prepared.image(), + digest_algorithm, signer_cert, load_chain_certs(chain_certs)?, private_key, @@ -4720,7 +4723,9 @@ where timestamp_digest, "portable sign-msi", )?; - msi_digest::msi_embed_authenticode_pkcs7_signature(&path, &output, &pkcs7) + msi_digest::msi_embed_prepared_authenticode_pkcs7_signature( + &prepared, &output, &pkcs7, + ) .with_context(|| format!("embed Authenticode signature in {}", path.display()))?; println!( "sign-msi: ok output={} digest={:?} pkcs7_len={}", diff --git a/crates/psign-portable-core/src/lib.rs b/crates/psign-portable-core/src/lib.rs index e9f15fd..f04b685 100644 --- a/crates/psign-portable-core/src/lib.rs +++ b/crates/psign-portable-core/src/lib.rs @@ -1795,9 +1795,9 @@ fn sign_msi(request: &PortableSignRequest, output_path: &Path) -> Result<()> { std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; let provider = load_signing_provider(request)?; let digest_algorithm: AuthenticodeSigningDigest = request.hash_algorithm.into(); - let msi_digest = - msi_digest::compute_msi_authenticode_digest(&msi, digest_algorithm.pe_hash_kind())?; - let indirect = pkcs7::msi_spc_indirect_data(digest_algorithm, &msi_digest)?; + let prepared = + msi_digest::prepare_msi_for_authenticode_signing(&msi, digest_algorithm.pe_hash_kind())?; + let indirect = pkcs7::msi_spc_indirect_data(digest_algorithm, prepared.digest())?; let pkcs7 = provider .create_authenticode_pkcs7(indirect, digest_algorithm) .with_context(|| { @@ -1808,7 +1808,7 @@ fn sign_msi(request: &PortableSignRequest, output_path: &Path) -> Result<()> { })?; let pkcs7 = maybe_timestamp_pkcs7(request, pkcs7) .with_context(|| format!("timestamp {}", request.path.display()))?; - msi_digest::msi_embed_authenticode_pkcs7_signature(&request.path, output_path, &pkcs7) + msi_digest::msi_embed_prepared_authenticode_pkcs7_signature(&prepared, output_path, &pkcs7) .with_context(|| format!("embed Authenticode signature in {}", request.path.display())) } diff --git a/crates/psign-sip-digest/src/msi_digest.rs b/crates/psign-sip-digest/src/msi_digest.rs index 3133c48..b9476aa 100644 --- a/crates/psign-sip-digest/src/msi_digest.rs +++ b/crates/psign-sip-digest/src/msi_digest.rs @@ -1,8 +1,9 @@ //! Windows Installer Authenticode digest (`MSISIP.DLL`) vs PKCS#7 `SpcIndirectData`. //! //! The traversal matches **Signify** `SignedMsiFile` (`signify/authenticode/signed_file/msi.py`, -//! Apache-2.0): sorted UTF-16 code-unit order on sibling names, skip `\u{5}DigitalSignature` and -//! `\u{5}MsiDigitalSignatureEx`, optional metadata **pre-hash** when `MsiDigitalSignatureEx` exists, +//! Apache-2.0): sorted UTF-16 byte order on sibling names (longer prefix first), skip root +//! `\u{5}DigitalSignature` and `\u{5}MsiDigitalSignatureEx`, optional metadata **pre-hash** when +//! `MsiDigitalSignatureEx` exists, //! then recursive stream hashing plus per-storage CLSID little-endian bytes at each storage close. //! **MSISIP.DLL** uses **`DigestStorageMetadataHelper`** / **`DigestStorageContentHelper`** for storage traversal; //! see **`docs/windows-signing-components.md`**. @@ -16,7 +17,7 @@ use sha1::Sha1; use sha2::{Sha256, Sha384, Sha512}; use std::cmp::Ordering; use std::fs::File; -use std::io::{Read, Seek, Write}; +use std::io::{Cursor, Read, Seek, Write}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -34,7 +35,10 @@ fn root_stream(name: &str) -> PathBuf { fn cmp_utf16_name(a: &str, b: &str) -> Ordering { let ae: Vec = a.encode_utf16().flat_map(u16::to_le_bytes).collect(); let be: Vec = b.encode_utf16().flat_map(u16::to_le_bytes).collect(); - ae.cmp(&be) + let common = ae.len().min(be.len()); + ae[..common] + .cmp(&be[..common]) + .then_with(|| be.len().cmp(&ae.len())) } fn hash_utf16_name(name: &str, hasher: &mut H) { @@ -45,15 +49,16 @@ fn hash_utf16_name(name: &str, hasher: &mut H) { fn system_time_to_filetime_le(st: SystemTime) -> [u8; 8] { let ticks = match st.duration_since(UNIX_EPOCH) { - Ok(d) => { - let t = d.as_secs().saturating_mul(10_000_000) + u64::from(d.subsec_nanos()) / 100; - t.saturating_add(FILETIME_UNIX_EPOCH) - } - Err(_) => 0, + Ok(d) => FILETIME_UNIX_EPOCH.saturating_add(duration_to_filetime_ticks(d)), + Err(e) => FILETIME_UNIX_EPOCH.saturating_sub(duration_to_filetime_ticks(e.duration())), }; ticks.to_le_bytes() } +fn duration_to_filetime_ticks(duration: std::time::Duration) -> u64 { + duration.as_secs().saturating_mul(10_000_000) + u64::from(duration.subsec_nanos()) / 100 +} + fn prehash_entry(entry: &Entry, hasher: &mut H) { if !entry.is_root() { hash_utf16_name(entry.name(), hasher); @@ -62,7 +67,8 @@ fn prehash_entry(entry: &Entry, hasher: &mut H) { hasher.update(entry.clsid().to_bytes_le()); } if entry.is_stream() { - let sz = u32::try_from(entry.len()).unwrap_or(0xffff_ffff); + // MSISIP hashes the low DWORD of the stream size. + let sz = entry.len() as u32; hasher.update(sz.to_le_bytes()); } hasher.update(entry.state_bits().to_le_bytes()); @@ -84,7 +90,9 @@ fn prehash_storage_recursive( entries.sort_by(|a, b| cmp_utf16_name(a.name(), b.name())); for e in entries { - if e.name() == DIGITAL_SIGNATURE_ENTRY || e.name() == EXTENDED_SIGNATURE_ENTRY { + if storage_path == Path::new("/") + && (e.name() == DIGITAL_SIGNATURE_ENTRY || e.name() == EXTENDED_SIGNATURE_ENTRY) + { continue; } if e.is_storage() { @@ -105,7 +113,9 @@ fn hash_storage_content_recursive( entries.sort_by(|a, b| cmp_utf16_name(a.name(), b.name())); for e in entries { - if e.name() == DIGITAL_SIGNATURE_ENTRY || e.name() == EXTENDED_SIGNATURE_ENTRY { + if storage_path == Path::new("/") + && (e.name() == DIGITAL_SIGNATURE_ENTRY || e.name() == EXTENDED_SIGNATURE_ENTRY) + { continue; } if e.is_storage() { @@ -209,6 +219,71 @@ pub fn compute_msi_authenticode_digest( compute_msi_fingerprint(&mut cfb, kind) } +/// Compute the signing digest for an MSI/MSP that already carries a valid metadata digest stream. +pub fn compute_prepared_msi_authenticode_digest( + data: &[u8], + kind: PeAuthenticodeHashKind, +) -> Result> { + let mut cfb = CompoundFile::open(Cursor::new(data)) + .map_err(|e| anyhow!("open as OLE compound file: {e}"))?; + let extended_path = root_stream(EXTENDED_SIGNATURE_ENTRY); + if !cfb.exists(&extended_path) { + return Err(anyhow!( + "MSI signing requires the root {} stream; stage the image with prepare_msi_for_authenticode_signing", + EXTENDED_SIGNATURE_ENTRY.escape_debug() + )); + } + let extended = read_stream_all(&mut cfb, &extended_path)?; + let expected = compute_prehash(&cfb, kind)?; + if extended != expected { + return Err(anyhow!( + "MSI {} stream does not match its metadata digest", + EXTENDED_SIGNATURE_ENTRY.escape_debug() + )); + } + compute_msi_fingerprint(&mut cfb, kind) +} + +/// MSI image staged for Authenticode signing, including the metadata digest stream required by +/// the Windows Installer SIP. +pub struct PreparedMsiAuthenticode { + image: Vec, + digest: Vec, +} + +impl PreparedMsiAuthenticode { + /// Compound-file bytes containing the root `MsiDigitalSignatureEx` stream. + pub fn image(&self) -> &[u8] { + &self.image + } + + /// Authenticode digest of [`Self::image`]. + pub fn digest(&self) -> &[u8] { + &self.digest + } +} + +/// Stage an MSI/MSP for signing and compute the Windows Installer SIP digest. +/// +/// Windows requires the root `MsiDigitalSignatureEx` stream to contain the package metadata +/// digest. That stream must exist before the final Authenticode digest is computed. +pub fn prepare_msi_for_authenticode_signing( + data: &[u8], + kind: PeAuthenticodeHashKind, +) -> Result { + let cursor = Cursor::new(data.to_vec()); + let mut cfb = + CompoundFile::open(cursor).map_err(|e| anyhow!("open as OLE compound file: {e}"))?; + let metadata_digest = compute_prehash(&cfb, kind)?; + { + let mut stream = cfb.create_stream(root_stream(EXTENDED_SIGNATURE_ENTRY))?; + stream.write_all(&metadata_digest)?; + } + let digest = compute_msi_fingerprint(&mut cfb, kind)?; + let image = cfb.into_inner().into_inner(); + Ok(PreparedMsiAuthenticode { image, digest }) +} + fn read_stream_all(cfb: &mut CompoundFile, path: &Path) -> Result> { let mut s = cfb.open_stream(path)?; let mut v = Vec::new(); @@ -264,6 +339,16 @@ pub fn msi_embed_authenticode_pkcs7_signature( write_msi_digital_signature_pkcs7(output, pkcs7) } +/// Write a staged MSI/MSP image and its root **`\u{5}DigitalSignature`** PKCS#7 stream. +pub fn msi_embed_prepared_authenticode_pkcs7_signature( + prepared: &PreparedMsiAuthenticode, + output: &Path, + pkcs7: &[u8], +) -> Result<()> { + std::fs::write(output, prepared.image())?; + write_msi_digital_signature_pkcs7(output, pkcs7) +} + /// **RS256** prehash over **`SignerInfo`** authenticated attributes for MSI-embedded PKCS#7 (same as **`pkcs7-signer-rs256-prehash`** on [`msi_digital_signature_pkcs7_der`] output). pub fn msi_rsa_sha256_signer_prehash_digest(data: &[u8], signer_index: usize) -> Result> { let pkcs7 = msi_digital_signature_pkcs7_der(data)?; @@ -305,6 +390,28 @@ pub fn verify_msi_digest_consistency(path: &Path) -> Result<()> { mod msi_pkcs7_tests { use super::*; + fn compound_with_signature_named_streams(root_byte: u8, nested_byte: u8) -> Vec { + let mut cfb = CompoundFile::create(Cursor::new(Vec::new())).expect("create compound file"); + { + let mut root_signature = cfb + .create_stream(root_stream(DIGITAL_SIGNATURE_ENTRY)) + .expect("create root signature stream"); + root_signature + .write_all(&[root_byte]) + .expect("write root signature stream"); + } + cfb.create_storage("/Nested").expect("create storage"); + { + let mut nested_signature = cfb + .create_stream(Path::new("/Nested").join(DIGITAL_SIGNATURE_ENTRY)) + .expect("create nested signature-named stream"); + nested_signature + .write_all(&[nested_byte]) + .expect("write nested signature-named stream"); + } + cfb.into_inner().into_inner() + } + #[test] fn msi_digital_signature_pkcs7_der_matches_pe_fixture_on_stub() { let msi = @@ -346,4 +453,89 @@ mod msi_pkcs7_tests { .unwrap_or_else(|e| panic!("verify {rel} MSI SIP digest: {e:#}")); } } + + #[test] + fn preparing_msi_writes_metadata_digest_before_computing_signing_digest() { + let msi = + include_bytes!("../../../tests/fixtures/msi-authenticode-upstream/tiny-pkcs7-stub.msi"); + for kind in [ + PeAuthenticodeHashKind::Sha1, + PeAuthenticodeHashKind::Sha256, + PeAuthenticodeHashKind::Sha384, + PeAuthenticodeHashKind::Sha512, + ] { + let prepared = prepare_msi_for_authenticode_signing(msi, kind).expect("prepare MSI"); + let mut cfb = + CompoundFile::open(Cursor::new(prepared.image())).expect("open prepared MSI"); + let extended = read_stream_all(&mut cfb, &root_stream(EXTENDED_SIGNATURE_ENTRY)) + .expect("read MsiDigitalSignatureEx"); + assert_eq!( + extended, + compute_prehash(&cfb, kind).expect("metadata digest") + ); + assert_eq!( + prepared.digest(), + compute_msi_fingerprint(&mut cfb, kind).expect("MSI signing digest") + ); + + let prepared_again = prepare_msi_for_authenticode_signing(prepared.image(), kind) + .expect("prepare MSI again"); + assert_eq!(prepared_again.digest(), prepared.digest()); + } + } + + #[test] + fn sip_name_order_puts_longer_prefix_first() { + assert_eq!(cmp_utf16_name("NameExtra", "Name"), Ordering::Less); + assert_eq!(cmp_utf16_name("Name", "NameExtra"), Ordering::Greater); + } + + #[test] + fn filetime_conversion_preserves_pre_unix_values() { + assert_eq!( + system_time_to_filetime_le(UNIX_EPOCH - std::time::Duration::from_secs(1)), + (FILETIME_UNIX_EPOCH - 10_000_000).to_le_bytes() + ); + } + + #[test] + fn only_root_signature_streams_are_excluded_from_content_digest() { + let kind = PeAuthenticodeHashKind::Sha256; + let baseline = compound_with_signature_named_streams(1, 1); + let changed_root = compound_with_signature_named_streams(2, 1); + let changed_nested = compound_with_signature_named_streams(1, 2); + + assert_eq!( + compute_msi_authenticode_digest(&baseline, kind).expect("baseline digest"), + compute_msi_authenticode_digest(&changed_root, kind).expect("changed root digest") + ); + assert_ne!( + compute_msi_authenticode_digest(&baseline, kind).expect("baseline digest"), + compute_msi_authenticode_digest(&changed_nested, kind).expect("changed nested digest") + ); + } + + #[test] + fn prepared_signing_digest_requires_matching_extended_stream() { + let msi = + include_bytes!("../../../tests/fixtures/msi-authenticode-upstream/tiny-pkcs7-stub.msi"); + let kind = PeAuthenticodeHashKind::Sha256; + let missing = compute_prepared_msi_authenticode_digest(msi, kind) + .expect_err("unstaged MSI must be rejected"); + assert!(missing.to_string().contains("MsiDigitalSignatureEx")); + + let prepared = prepare_msi_for_authenticode_signing(msi, kind).expect("prepare MSI"); + let mut cfb = + CompoundFile::open(Cursor::new(prepared.image().to_vec())).expect("open prepared MSI"); + { + let mut extended = cfb + .create_stream(root_stream(EXTENDED_SIGNATURE_ENTRY)) + .expect("replace MsiDigitalSignatureEx"); + extended + .write_all(&[0; 32]) + .expect("write bad metadata digest"); + } + let tampered = cfb.into_inner().into_inner(); + assert!(compute_prepared_msi_authenticode_digest(&tampered, kind).is_err()); + } } diff --git a/crates/psign-sip-digest/src/pkcs7.rs b/crates/psign-sip-digest/src/pkcs7.rs index e672715..b50dcaa 100644 --- a/crates/psign-sip-digest/src/pkcs7.rs +++ b/crates/psign-sip-digest/src/pkcs7.rs @@ -86,6 +86,15 @@ pub const PKCS9_MESSAGE_DIGEST_OID: ObjectIdentifier = /// PKCS#9 **`contentType`** authenticated-attribute type OID. pub const PKCS9_CONTENT_TYPE_OID: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.9.3"); +/// Microsoft Authenticode statement-type authenticated-attribute OID. +pub const SPC_STATEMENT_TYPE_OID: ObjectIdentifier = + ObjectIdentifier::new_unwrap("1.3.6.1.4.1.311.2.1.11"); +/// Microsoft Authenticode program-information authenticated-attribute OID. +pub const SPC_SP_OPUS_INFO_OID: ObjectIdentifier = + ObjectIdentifier::new_unwrap("1.3.6.1.4.1.311.2.1.12"); +/// Microsoft individual-code-signing purpose carried by `SpcStatementType`. +pub const SPC_INDIVIDUAL_CODE_SIGNING_OID: ObjectIdentifier = + ObjectIdentifier::new_unwrap("1.3.6.1.4.1.311.2.1.21"); /// PKCS#9 **`signingTime`** authenticated-attribute type OID. pub const PKCS9_SIGNING_TIME_OID: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.9.5"); @@ -182,7 +191,14 @@ impl AuthenticodeSigningDigest { } } - fn rsa_signature_algorithm(self) -> AlgorithmIdentifierOwned { + fn authenticode_digest_algorithm(self) -> AlgorithmIdentifierOwned { + AlgorithmIdentifierOwned { + parameters: Some(Any::from(AnyRef::NULL)), + ..self.digest_algorithm() + } + } + + fn rsa_digest_signature_algorithm(self) -> AlgorithmIdentifierOwned { AlgorithmIdentifierOwned { oid: match self { Self::Sha256 => ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.11"), @@ -193,6 +209,13 @@ impl AuthenticodeSigningDigest { } } + fn authenticode_rsa_signature_algorithm(self) -> AlgorithmIdentifierOwned { + AlgorithmIdentifierOwned { + oid: ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.1"), + parameters: Some(Any::from(AnyRef::NULL)), + } + } + fn digest_bytes(self, bytes: &[u8]) -> Vec { match self { Self::Sha256 => Sha256::digest(bytes).to_vec(), @@ -281,7 +304,7 @@ fn spc_indirect_data( Ok(SpcIndirectDataContent { data: SpcAttributeTypeAndOptionalValue { value_type, value }, message_digest: DigestInfo { - digest_algorithm: digest_algorithm.digest_algorithm(), + digest_algorithm: digest_algorithm.authenticode_digest_algorithm(), digest, }, }) @@ -309,7 +332,11 @@ pub fn create_cab_authenticode_pkcs7_der_rsa( ) } -/// Create PKCS#7 `ContentInfo(SignedData)` DER for an MSI/MSP Authenticode signature using an RSA private key. +/// Create PKCS#7 `ContentInfo(SignedData)` DER for a prepared MSI/MSP Authenticode signature using +/// an RSA private key. +/// +/// Obtain `msi_image` with [`crate::msi_digest::prepare_msi_for_authenticode_signing`] and embed +/// this signature into that same staged image. pub fn create_msi_authenticode_pkcs7_der_rsa( msi_image: &[u8], digest_algorithm: AuthenticodeSigningDigest, @@ -317,7 +344,7 @@ pub fn create_msi_authenticode_pkcs7_der_rsa( chain_certs: Vec, private_key: RsaPrivateKey, ) -> Result> { - let msi_digest = crate::msi_digest::compute_msi_authenticode_digest( + let msi_digest = crate::msi_digest::compute_prepared_msi_authenticode_digest( msi_image, digest_algorithm.pe_hash_kind(), )?; @@ -377,7 +404,7 @@ pub fn msix_spc_indirect_data( .map_err(|e| anyhow!("SPC_MSIX_SIGINFO Any: {e}"))?, }, message_digest: DigestInfo { - digest_algorithm: digest_algorithm.digest_algorithm(), + digest_algorithm: digest_algorithm.authenticode_digest_algorithm(), digest: OctetString::new(appx_blob) .map_err(|e| anyhow!("APPX SpcIndirectData digest OCTET STRING: {e}"))?, }, @@ -509,9 +536,9 @@ pub fn create_authenticode_pkcs7_der_with_rsa_signature( let signer_info = SignerInfo { version: CmsVersion::V1, sid: signer_id, - digest_alg: digest_algorithm.digest_algorithm(), + digest_alg: digest_algorithm.authenticode_digest_algorithm(), signed_attrs: Some(attrs), - signature_algorithm: digest_algorithm.rsa_signature_algorithm(), + signature_algorithm: digest_algorithm.authenticode_rsa_signature_algorithm(), signature: SignatureValue::new(encrypted_digest.to_vec()) .map_err(|e| anyhow!("SignerInfo.signature OCTET STRING: {e}"))?, unsigned_attrs: None, @@ -522,8 +549,9 @@ pub fn create_authenticode_pkcs7_der_with_rsa_signature( let econtent = Any::decode(&mut rd).map_err(|e| anyhow!("SpcIndirectData as CMS Any: {e}"))?; rd.finish(()) .map_err(|e| anyhow!("trailing octets after SpcIndirectDataContent DER: {e}"))?; - let digest_algorithms = SetOfVec::try_from(vec![digest_algorithm.digest_algorithm()]) - .map_err(|e| anyhow!("DigestAlgorithmIdentifiers SET: {e}"))?; + let digest_algorithms = + SetOfVec::try_from(vec![digest_algorithm.authenticode_digest_algorithm()]) + .map_err(|e| anyhow!("DigestAlgorithmIdentifiers SET: {e}"))?; let mut certs = Vec::with_capacity(chain_certs.len() + 1); certs.push(CertificateChoices::Certificate(signer_cert)); certs.extend(chain_certs.into_iter().map(CertificateChoices::Certificate)); @@ -652,7 +680,7 @@ pub fn create_pkcs7_signed_data_der_with_signed_attrs_and_rsa_signature( sid: signer_id, digest_alg: input.digest_algorithm.digest_algorithm(), signed_attrs: Some(input.signed_attrs), - signature_algorithm: input.digest_algorithm.rsa_signature_algorithm(), + signature_algorithm: input.digest_algorithm.rsa_digest_signature_algorithm(), signature: SignatureValue::new(input.encrypted_digest.to_vec()) .map_err(|e| anyhow!("SignerInfo.signature OCTET STRING: {e}"))?, unsigned_attrs: None, @@ -817,6 +845,7 @@ where authenticode::SPC_INDIRECT_DATA_OBJID, &indirect_der, digest_algorithm, + CmsSigningProfile::Authenticode, signer_cert, chain_certs, private_key, @@ -842,6 +871,7 @@ pub fn create_pkcs7_signed_data_der_rsa( econtent_type, econtent_der, digest_algorithm, + CmsSigningProfile::Generic, signer_cert, chain_certs, private_key, @@ -852,6 +882,7 @@ pub fn create_pkcs7_signed_data_der_rsa( econtent_type, econtent_der, digest_algorithm, + CmsSigningProfile::Generic, signer_cert, chain_certs, private_key, @@ -862,6 +893,7 @@ pub fn create_pkcs7_signed_data_der_rsa( econtent_type, econtent_der, digest_algorithm, + CmsSigningProfile::Generic, signer_cert, chain_certs, private_key, @@ -870,10 +902,17 @@ pub fn create_pkcs7_signed_data_der_rsa( } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CmsSigningProfile { + Generic, + Authenticode, +} + fn create_pkcs7_signed_data_der_rsa_for_digest( econtent_type: ObjectIdentifier, econtent_der: &[u8], digest_algorithm: AuthenticodeSigningDigest, + profile: CmsSigningProfile, signer_cert: Certificate, chain_certs: Vec, private_key: RsaPrivateKey, @@ -886,7 +925,10 @@ where Sig: x509_cert::spki::SignatureBitStringEncoding, { let signer = rsa::pkcs1v15::SigningKey::::new(private_key); - let digest_alg = digest_algorithm.digest_algorithm(); + let digest_alg = match profile { + CmsSigningProfile::Generic => digest_algorithm.digest_algorithm(), + CmsSigningProfile::Authenticode => digest_algorithm.authenticode_digest_algorithm(), + }; let mut rd = SliceReader::new(econtent_der) .map_err(|e| anyhow!("encapsulated content DER reader: {e}"))?; let econtent = @@ -901,9 +943,16 @@ where issuer: signer_cert.tbs_certificate.issuer.clone(), serial_number: signer_cert.tbs_certificate.serial_number.clone(), }); - let signer_info = + let mut signer_info = SignerInfoBuilder::new(&signer, signer_id, digest_alg.clone(), &content, None) .map_err(|e| anyhow!("build CMS SignerInfo: {e}"))?; + if profile == CmsSigningProfile::Authenticode { + signer_info + .add_signed_attribute(spc_sp_opus_info_attribute()?) + .map_err(|e| anyhow!("add Authenticode SpcSpOpusInfo attribute: {e}"))? + .add_signed_attribute(spc_statement_type_attribute()?) + .map_err(|e| anyhow!("add Authenticode SpcStatementType attribute: {e}"))?; + } let mut builder = SignedDataBuilder::new(&content); builder .add_digest_algorithm(digest_alg) @@ -927,6 +976,14 @@ where // RFC 5652 would normally select v3 for non-id-data encapsulated content. The existing // parser and Windows fixtures expect this value; it is outside the signed attribute digest. sd.version = CmsVersion::V1; + if profile == CmsSigningProfile::Authenticode { + let mut signer_info = sd.signer_infos.0.as_slice()[0].clone(); + signer_info.signature_algorithm = digest_algorithm.authenticode_rsa_signature_algorithm(); + sd.signer_infos = SignerInfos( + SetOfVec::try_from(vec![signer_info]) + .map_err(|e| anyhow!("Authenticode SignerInfos SET: {e}"))?, + ); + } encode_pkcs7_content_info_signed_data_der(&sd) } @@ -1585,6 +1642,37 @@ fn pkcs9_content_type_attribute(content_type: ObjectIdentifier) -> Result Result { + let mut values = SetOfVec::new(); + values + .insert( + Any::new(Tag::Sequence, Vec::new()) + .map_err(|e| anyhow!("SpcSpOpusInfo AttributeValue ANY: {e}"))?, + ) + .map_err(|e| anyhow!("SET OF SpcSpOpusInfo AttributeValue insert: {e}"))?; + Ok(Attribute { + oid: SPC_SP_OPUS_INFO_OID, + values, + }) +} + +fn spc_statement_type_attribute() -> Result { + let individual_code_signing = SPC_INDIVIDUAL_CODE_SIGNING_OID + .to_der() + .map_err(|e| anyhow!("individual-code-signing OID DER: {e}"))?; + let mut values = SetOfVec::new(); + values + .insert( + Any::new(Tag::Sequence, individual_code_signing) + .map_err(|e| anyhow!("SpcStatementType AttributeValue ANY: {e}"))?, + ) + .map_err(|e| anyhow!("SET OF SpcStatementType AttributeValue insert: {e}"))?; + Ok(Attribute { + oid: SPC_STATEMENT_TYPE_OID, + values, + }) +} + fn pkcs9_signing_time_attribute() -> Result { let signing_time_der = UtcTime::from_system_time(std::time::SystemTime::now()) .map_err(|e| anyhow!("signingTime UTC time: {e}"))? @@ -1711,7 +1799,9 @@ fn authenticode_signed_attrs( &econtent, )?; SetOfVec::try_from(vec![ + spc_sp_opus_info_attribute()?, pkcs9_content_type_attribute(authenticode::SPC_INDIRECT_DATA_OBJID)?, + spc_statement_type_attribute()?, pkcs9_message_digest_attribute(&econtent_digest)?, ]) .map_err(|e| anyhow!("SignedAttributes SET OF Attribute canonicalization: {e}")) @@ -1858,6 +1948,23 @@ mod tests { assert!(PKCS7_ID_DATA_OID.ends_with(".7.1")); } + #[test] + fn authenticode_signed_attrs_include_windows_code_signing_profile() { + let indirect = msi_spc_indirect_data(AuthenticodeSigningDigest::Sha256, &[0; 32]) + .expect("MSI indirect data"); + let attrs = authenticode_signed_attrs(&indirect, AuthenticodeSigningDigest::Sha256) + .expect("Authenticode signed attributes"); + let oids = attrs + .iter() + .map(|attribute| attribute.oid) + .collect::>(); + + assert!(oids.contains(&PKCS9_CONTENT_TYPE_OID)); + assert!(oids.contains(&PKCS9_MESSAGE_DIGEST_OID)); + assert!(oids.contains(&SPC_SP_OPUS_INFO_OID)); + assert!(oids.contains(&SPC_STATEMENT_TYPE_OID)); + } + fn assert_cms_encap_digest_matches_pkcs9(pe_bytes: &[u8]) { let pkcs7 = crate::verify_pe::pe_nth_pkcs7_signed_data_der(pe_bytes, 0).expect("pkcs7"); let sd = parse_pkcs7_signed_data_der(&pkcs7).expect("SignedData"); diff --git a/docs/ci-parity.md b/docs/ci-parity.md index 0418dd4..4cd7816 100644 --- a/docs/ci-parity.md +++ b/docs/ci-parity.md @@ -12,9 +12,10 @@ Runs on every push, PR, and daily schedule. 2. **`scripts/ci/prepare-parity-fixtures.ps1`** — Native-signs a temp PE (`PSIGN_SIGNED_FIXTURE`) for timestamp scenarios; produces detached PKCS#7 via native `signtool sign /p7 …` (`PSIGN_DETACHED_*`). **`scripts/ci/build-catalog-workflow-fixtures.ps1`** regenerates the committed MakeCat + signtool generic catalog fixtures under [`tests/fixtures/catalog-workflows/`](../tests/fixtures/catalog-workflows/) when catalog membership fixtures need updating. 3. **`scripts/ci/pack-minimal-msix.ps1`** — Packs [`tests/fixtures/msix-minimal/`](../tests/fixtures/msix-minimal/AppxManifest.xml) + `noop.exe` (copy of the built `psign-tool.exe`) into an unsigned `.msix`. -4. **`scripts/ci/pack-minimal-winmd.ps1`** — Copies the same unsigned `psign-tool.exe` to **`PSIGN_WINMD_UNSIGNED_FIXTURE`** (`.winmd` extension) so **`run-parity-diff`** exercises WinMD SIP scenarios; **`PSIGN_WINMD_TIMESTAMP_URL`** mirrors **`PSIGN_TIMESTAMP_URL`** when set after bootstrap. -5. **`scripts/run-parity-diff.ps1 -FailOnSemantic -FailOnSemanticExhaustive`** — Static CLI matrix, remove scenarios, PE + script description parity, timestamp exits, detached verify, catalog path if env set, MSIX semantic blocks when env present, WinMD scenarios when the WinMD fixture env is set, etc. Exhaustive mode asserts that core PE, timestamp, MSIX package, and detached env vars are all set before running. -6. **`scripts/msix-parity-sign.ps1 -FailOnSemantic`** — Focused MSIX sign/verify report (`parity-output/msix-parity-sign-report.json`). +4. **`scripts/ci/create-minimal-msi.ps1`** — Builds a valid no-op MSI with WiX, confirms that Windows Installer can open it as a package, and supplies it to the portable-signing and native parity checks. +5. **`scripts/ci/pack-minimal-winmd.ps1`** — Copies the same unsigned `psign-tool.exe` to **`PSIGN_WINMD_UNSIGNED_FIXTURE`** (`.winmd` extension) so **`run-parity-diff`** exercises WinMD SIP scenarios; **`PSIGN_WINMD_TIMESTAMP_URL`** mirrors **`PSIGN_TIMESTAMP_URL`** when set after bootstrap. +6. **`scripts/run-parity-diff.ps1 -FailOnSemantic -FailOnSemanticExhaustive`** — Static CLI matrix, remove scenarios, PE + script description parity, timestamp exits, detached verify, catalog path if env set, MSI and MSIX semantic blocks when their fixture env vars are present, WinMD scenarios when the WinMD fixture env is set, etc. Exhaustive mode asserts that core PE, timestamp, MSIX package, and detached env vars are all set before running. +7. **`scripts/msix-parity-sign.ps1 -FailOnSemantic`** — Focused MSIX sign/verify report (`parity-output/msix-parity-sign-report.json`). Artifacts: `parity-output/parity-report.json`, `parity-output/msix-parity-sign-report.json` (uploaded as workflow artifacts). The **`parity-output/`** directory is gitignored, so clones do not contain those JSON files until you run parity locally or download CI artifacts. diff --git a/scripts/ci/create-minimal-msi.ps1 b/scripts/ci/create-minimal-msi.ps1 new file mode 100644 index 0000000..8af93d0 --- /dev/null +++ b/scripts/ci/create-minimal-msi.ps1 @@ -0,0 +1,69 @@ +# Create a repository-independent, valid MSI package for Windows SIP signing parity. +param( + [Parameter(Mandatory)][string]$OutputMsi +) + +$ErrorActionPreference = "Stop" + +function Resolve-WixTool([string]$Name) { + $command = Get-Command "$Name.exe" -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + + $candidates = @() + if ($env:WIX) { + $candidates += Join-Path $env:WIX "bin\$Name.exe" + } + $candidates += Join-Path ${env:ProgramFiles(x86)} "WiX Toolset v3.14\bin\$Name.exe" + $candidates += Join-Path ${env:ProgramFiles(x86)} "WiX Toolset v3.11\bin\$Name.exe" + foreach ($candidate in $candidates) { + if ($candidate -and (Test-Path -LiteralPath $candidate)) { + return $candidate + } + } + throw "Could not find WiX v3 $Name.exe" +} + +function Assert-ValidInstallerPackage([string]$Path) { + $installer = New-Object -ComObject WindowsInstaller.Installer + try { + # Option 1 creates a restricted session and does not change machine state. + $session = $installer.OpenPackage($Path, 1) + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($session) + } + finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($installer) + } +} + +$parent = Split-Path -Parent $OutputMsi +if ($parent) { + New-Item -ItemType Directory -Force -Path $parent | Out-Null +} + +$source = Join-Path $PSScriptRoot "..\..\tests\fixtures\msi-parity\minimal.wxs" +$work = Join-Path ([IO.Path]::GetTempPath()) "psign-msi-$([guid]::NewGuid())" +$wixObject = Join-Path $work "minimal.wixobj" +$candle = Resolve-WixTool "candle" +$light = Resolve-WixTool "light" + +New-Item -ItemType Directory -Force -Path $work | Out-Null +try { + & $candle -nologo -out $wixObject $source + if ($LASTEXITCODE -ne 0) { + throw "candle.exe failed with exit code $LASTEXITCODE" + } + + & $light -nologo -out $OutputMsi $wixObject + if ($LASTEXITCODE -ne 0) { + throw "light.exe failed with exit code $LASTEXITCODE" + } + + Assert-ValidInstallerPackage $OutputMsi +} +finally { + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host "Created valid minimal MSI parity fixture: $OutputMsi" diff --git a/scripts/ci/run-exhaustive-parity-ci.ps1 b/scripts/ci/run-exhaustive-parity-ci.ps1 index 30bfb1b..5ae0fdd 100644 --- a/scripts/ci/run-exhaustive-parity-ci.ps1 +++ b/scripts/ci/run-exhaustive-parity-ci.ps1 @@ -36,6 +36,10 @@ $msixOut = Join-Path $rt "psign_parity_minimal.msix" $env:PSIGN_MSIX_UNSIGNED_FIXTURE = $msixOut $env:PSIGN_UNSIGNED_FIXTURE = $unsignedPe +$msiOut = Join-Path $rt "psign_parity_minimal.msi" +& (Join-Path $PSScriptRoot "create-minimal-msi.ps1") -OutputMsi $msiOut +$env:PSIGN_MSI_UNSIGNED_FIXTURE = $msiOut + $winmdOut = Join-Path $rt "psign_parity_minimal.winmd" & (Join-Path $PSScriptRoot "pack-minimal-winmd.ps1") -PeSource $unsignedPe -OutputWinmd $winmdOut $env:PSIGN_WINMD_UNSIGNED_FIXTURE = $winmdOut @@ -45,6 +49,7 @@ if ($env:PSIGN_TIMESTAMP_URL) { if ($env:GITHUB_ENV) { Add-Content -LiteralPath $env:GITHUB_ENV -Value "PSIGN_MSIX_UNSIGNED_FIXTURE=$msixOut" + Add-Content -LiteralPath $env:GITHUB_ENV -Value "PSIGN_MSI_UNSIGNED_FIXTURE=$msiOut" Add-Content -LiteralPath $env:GITHUB_ENV -Value "PSIGN_UNSIGNED_FIXTURE=$unsignedPe" Add-Content -LiteralPath $env:GITHUB_ENV -Value "PSIGN_WINMD_UNSIGNED_FIXTURE=$winmdOut" if ($env:PSIGN_WINMD_TIMESTAMP_URL) { diff --git a/tests/cli_pe_digest.rs b/tests/cli_pe_digest.rs index 8f23280..c7c34c7 100644 --- a/tests/cli_pe_digest.rs +++ b/tests/cli_pe_digest.rs @@ -3229,6 +3229,23 @@ fn sign_msi_creates_portable_authenticode_signature() { let signed = std::fs::read(&out_msi).expect("read signed MSI"); let pkcs7 = msi_digest::msi_digital_signature_pkcs7_der(&signed).expect("extract MSI PKCS#7"); let sd = pkcs7::parse_pkcs7_signed_data_der(&pkcs7).expect("parse SignedData"); + let signer_info = &sd.signer_infos.0.as_slice()[0]; + assert_eq!( + signer_info.signature_algorithm.oid.to_string(), + "1.2.840.113549.1.1.1" + ); + assert!(signer_info.signature_algorithm.parameters.is_some()); + assert!(signer_info.digest_alg.parameters.is_some()); + assert!(sd.digest_algorithms.as_slice()[0].parameters.is_some()); + let signed_attribute_oids = signer_info + .signed_attrs + .as_ref() + .expect("MSI signer has authenticated attributes") + .iter() + .map(|attribute| attribute.oid) + .collect::>(); + assert!(signed_attribute_oids.contains(&pkcs7::SPC_SP_OPUS_INFO_OID)); + assert!(signed_attribute_oids.contains(&pkcs7::SPC_STATEMENT_TYPE_OID)); let indirect = pkcs7::signed_data_spc_indirect_message_digest_octets(&sd).expect("indirect"); let expected = msi_digest::compute_msi_authenticode_digest( &signed, diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 9b04e0b..28e80a1 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -91,8 +91,9 @@ MSIX minimal pack layout (CI-generated unsigned package): - `msix-minimal/AppxManifest.xml` — Identity publisher `CN=Test Code Signing Certificate` (matches Devolutions test signing cert). CI copies `target/debug/psign-tool.exe` as `noop.exe` and adds `Assets/StoreLogo.png` before `MakeAppx pack`. -Optional MSI parity (`scripts/run-parity-diff.ps1`): -- `PSIGN_MSI_UNSIGNED_FIXTURE` -> path to an unsigned `.msi` (not bundled in-repo) +MSI parity (`scripts/run-parity-diff.ps1`): +- `PSIGN_MSI_UNSIGNED_FIXTURE` -> path to an unsigned `.msi`; exhaustive Windows CI generates + a valid no-op package with `scripts/ci/create-minimal-msi.ps1` - Reuses `PSIGN_TEST_PFX` / `_PASSWORD` with PE parity - Optional `PSIGN_MSI_TIMESTAMP_URL` for sign-time RFC3161 (native `/tr` `/td SHA256`) diff --git a/tests/fixtures/msi-parity/minimal.wxs b/tests/fixtures/msi-parity/minimal.wxs new file mode 100644 index 0000000..4d8984e --- /dev/null +++ b/tests/fixtures/msi-parity/minimal.wxs @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/tests/parity_signtool.rs b/tests/parity_signtool.rs index d1cb055..dda62ca 100644 --- a/tests/parity_signtool.rs +++ b/tests/parity_signtool.rs @@ -88,6 +88,21 @@ fn native_signtool_optional_path() -> Option { }) } +fn assert_native_signtool_accepts(signtool: &Path, path: &Path, label: &str) { + let output = Command::new(signtool) + .arg("verify") + .arg("/pa") + .arg(path) + .output() + .unwrap_or_else(|error| panic!("verify {label} with native signtool: {error}")); + assert!( + output.status.success(), + "native signtool rejected {label}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + #[test] #[ignore = "requires PSIGN_UNSIGNED_FIXTURE,PSIGN_TEST_PFX,PSIGN_TEST_PFX_PASSWORD"] fn sign_semantic_parity_creates_verifiable_signature() { @@ -893,14 +908,14 @@ fn msi_sign_aligns_with_native_sip_stack() { .arg("/fd") .arg("SHA256") .arg("/f") - .arg(&pfx) - .arg(&tmp_nat); + .arg(&pfx); if let Some(ref p) = pw { native_cmd.arg("/p").arg(p); } if let Some(ref u) = ts { native_cmd.arg("/tr").arg(u).arg("/td").arg("SHA256"); } + native_cmd.arg(&tmp_nat); let native_out = native_cmd.output().expect("native sign msi"); assert!( native_out.status.success(), @@ -910,12 +925,13 @@ fn msi_sign_aligns_with_native_sip_stack() { let mut rust_cmd = Command::cargo_bin("psign-tool").expect("binary available"); rust_cmd + .arg("--mode") + .arg("portable") .arg("sign") .arg("--pfx") .arg(&pfx) .arg("--digest") - .arg("sha256") - .arg(&tmp_rust); + .arg("sha256"); if let Some(p) = pw { rust_cmd.arg("--password").arg(p); } @@ -926,6 +942,7 @@ fn msi_sign_aligns_with_native_sip_stack() { .arg("--timestamp-digest") .arg("sha256"); } + rust_cmd.arg(&tmp_rust); let rust_out = rust_cmd.output().expect("rust sign msi"); assert!( rust_out.status.success(), @@ -933,34 +950,8 @@ fn msi_sign_aligns_with_native_sip_stack() { String::from_utf8_lossy(&rust_out.stderr) ); - let nat_bytes = std::fs::read(&tmp_nat).expect("read native signed"); - let rust_bytes = std::fs::read(&tmp_rust).expect("read rust signed"); - - let nv_rust = Command::new(&native_exe) - .arg("verify") - .arg("/pa") - .arg(&tmp_rust) - .output() - .expect("native verify rust-signed msi"); - assert!( - nv_rust.status.success(), - "{}", - String::from_utf8_lossy(&nv_rust.stdout) - ); - - if nat_bytes != rust_bytes { - let nv_nat = Command::new(&native_exe) - .arg("verify") - .arg("/pa") - .arg(&tmp_nat) - .output() - .expect("native verify native-signed msi"); - assert!( - nv_nat.status.success(), - "{}", - String::from_utf8_lossy(&nv_nat.stdout) - ); - } + assert_native_signtool_accepts(&native_exe, &tmp_nat, "native-signed MSI baseline"); + assert_native_signtool_accepts(&native_exe, &tmp_rust, "portable-signed MSI"); } /// Windows metadata `.winmd`: PE-based CLI assembly; OS Authenticode SIP (`SignerSignEx3` / `WinVerifyTrust`).