diff --git a/src/code.rs b/src/code.rs index e7214d5..2aa7da7 100644 --- a/src/code.rs +++ b/src/code.rs @@ -15,7 +15,7 @@ use psign_codesigning_rest::{ }; use psign_opc_sign::{nuget, opc, vsix}; use psign_sip_digest::timestamp::{build_timestamp_request_bytes, parse_time_stamp_resp_der}; -use psign_sip_digest::{pe_digest, pe_embed, pkcs7, rdp}; +use psign_sip_digest::{pe_digest, pe_embed, pkcs7, rdp, verify_pe}; use rsa::signature::{SignatureEncoding as _, Signer as _, hazmat::PrehashSigner as _}; use serde::{Deserialize, Serialize}; use sha2::Digest as _; @@ -235,11 +235,16 @@ fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result display_path(&output) )) } else { - let signed = - sign_pe_bytes(&input_bytes, &node.path, &signer, signing_digest, false) - .with_context(|| { - format!("sign Authenticode payload {}", input.display()) - })?; + let signed = sign_pe_bytes( + &input_bytes, + &node.path, + &signer, + signing_digest, + false, + args.timestamp_url.as_deref(), + args.timestamp_digest, + ) + .with_context(|| format!("sign Authenticode payload {}", input.display()))?; std::fs::write(&output, signed).with_context(|| { format!("write signed Authenticode payload {}", output.display()) })?; @@ -503,11 +508,15 @@ fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result ensure_parent_dir(&output)?; let input_bytes = std::fs::read(&input).with_context(|| format!("read {}", input.display()))?; - let signed = - sign_clickonce_deploy_bytes(&input_bytes, &node.path, &signer, signing_digest) - .with_context(|| { - format!("sign ClickOnce deploy payload {}", input.display()) - })?; + let signed = sign_clickonce_deploy_bytes( + &input_bytes, + &node.path, + &signer, + signing_digest, + args.timestamp_url.as_deref(), + args.timestamp_digest, + ) + .with_context(|| format!("sign ClickOnce deploy payload {}", input.display()))?; std::fs::write(&output, signed).with_context(|| { format!("write signed ClickOnce deploy payload {}", output.display()) })?; @@ -966,12 +975,27 @@ fn sign_nested_package_entries( } CodeFormat::Deploy => entry_updates.push(ZipEntryUpdate { name, - bytes: sign_clickonce_deploy_bytes(&bytes, &nested_label, signer, signing_digest)?, + bytes: sign_clickonce_deploy_bytes( + &bytes, + &nested_label, + signer, + signing_digest, + timestamp_url, + timestamp_digest, + )?, compression, }), CodeFormat::Pe | CodeFormat::Winmd => entry_updates.push(ZipEntryUpdate { name, - bytes: sign_pe_bytes(&bytes, &nested_label, signer, signing_digest, skip_signed)?, + bytes: sign_pe_bytes( + &bytes, + &nested_label, + signer, + signing_digest, + skip_signed, + timestamp_url, + timestamp_digest, + )?, compression, }), CodeFormat::Msix @@ -1036,6 +1060,8 @@ fn sign_clickonce_deploy_bytes( label: &str, signer: &CodeSigner, signing_digest: pkcs7::AuthenticodeSigningDigest, + timestamp_url: Option<&str>, + timestamp_digest: Option, ) -> Result> { if signing_digest != pkcs7::AuthenticodeSigningDigest::Sha256 { return Err(anyhow!( @@ -1054,8 +1080,16 @@ fn sign_clickonce_deploy_bytes( "ClickOnce .deploy payload {label} maps to unsupported content name {content_name}" )); } - sign_pe_bytes(input_bytes, label, signer, signing_digest, false) - .with_context(|| format!("sign ClickOnce .deploy PE payload {label}")) + sign_pe_bytes( + input_bytes, + label, + signer, + signing_digest, + false, + timestamp_url, + timestamp_digest, + ) + .with_context(|| format!("sign ClickOnce .deploy PE payload {label}")) } fn clickonce_deploy_content_name(label: &str) -> Option { @@ -1080,6 +1114,8 @@ fn sign_pe_bytes( signer: &CodeSigner, signing_digest: pkcs7::AuthenticodeSigningDigest, skip_signed: bool, + timestamp_url: Option<&str>, + timestamp_digest: Option, ) -> Result> { if skip_signed && pe_has_signature(input_bytes) { return Ok(input_bytes.to_vec()); @@ -1087,9 +1123,67 @@ fn sign_pe_bytes( if signing_digest != pkcs7::AuthenticodeSigningDigest::Sha256 { return Err(anyhow!("PE/WinMD signing currently supports only SHA-256")); } - signer + let signed = signer .sign_pe_bytes(input_bytes, signing_digest) - .with_context(|| format!("sign PE/WinMD payload {label}")) + .with_context(|| format!("sign PE/WinMD payload {label}"))?; + timestamp_pe_if_requested(&signed, timestamp_url, timestamp_digest) + .with_context(|| format!("timestamp PE/WinMD payload {label}")) +} + +fn timestamp_pe_if_requested( + pe_image: &[u8], + timestamp_url: Option<&str>, + timestamp_digest: Option, +) -> Result> { + match (timestamp_url, timestamp_digest) { + (Some(url), Some(digest)) => timestamp_pe_rfc3161(pe_image, url, digest), + (Some(_), None) => Err(anyhow!( + "`psign-tool code` requires --timestamp-digest with --timestamp-url" + )), + (None, Some(_)) => Err(anyhow!( + "`psign-tool code` requires --timestamp-url with --timestamp-digest" + )), + (None, None) => Ok(pe_image.to_vec()), + } +} + +#[cfg(feature = "timestamp-http")] +fn timestamp_pe_rfc3161( + pe_image: &[u8], + timestamp_url: &str, + timestamp_digest: DigestAlgorithm, +) -> Result> { + let pkcs7_count = verify_pe::pe_pkcs7_signed_data_entry_count(pe_image) + .context("inspect PE/WinMD Authenticode PKCS#7 rows")?; + let pkcs7_index = pkcs7_count + .checked_sub(1) + .ok_or_else(|| anyhow!("PE/WinMD has no PKCS#7 Authenticode row to timestamp"))?; + let pkcs7_der = verify_pe::pe_nth_pkcs7_signed_data_der(pe_image, pkcs7_index) + .with_context(|| format!("extract PE/WinMD PKCS#7 row {pkcs7_index}"))?; + let stamped_pkcs7 = timestamp_pkcs7_der_rfc3161( + &pkcs7_der, + timestamp_url, + timestamp_digest, + Rfc3161TimestampAttribute::MicrosoftAuthenticode, + ) + .with_context(|| format!("timestamp PE/WinMD PKCS#7 row {pkcs7_index}"))?; + pe_embed::pe_replace_authenticode_pkcs7_certificate_at( + pe_image.to_vec(), + pkcs7_index, + &stamped_pkcs7, + ) + .with_context(|| format!("replace PE/WinMD PKCS#7 row {pkcs7_index}")) +} + +#[cfg(not(feature = "timestamp-http"))] +fn timestamp_pe_rfc3161( + _pe_image: &[u8], + _timestamp_url: &str, + _timestamp_digest: DigestAlgorithm, +) -> Result> { + Err(anyhow!( + "`psign-tool code` RFC3161 timestamping requires the timestamp-http feature" + )) } #[allow(clippy::too_many_arguments)] diff --git a/tests/code_command.rs b/tests/code_command.rs index e373e1c..41438f1 100644 --- a/tests/code_command.rs +++ b/tests/code_command.rs @@ -1,7 +1,7 @@ use assert_cmd::Command; use predicates::prelude::*; use psign_opc_sign::nuget; -use psign_sip_digest::pkcs7; +use psign_sip_digest::{pkcs7, verify_pe}; use rand::rngs::OsRng; use rsa::RsaPrivateKey; use rsa::pkcs1v15::SigningKey; @@ -387,6 +387,59 @@ fn code_signs_top_level_pe_with_local_cert_key() { .success(); } +#[cfg(all(feature = "timestamp-server", feature = "timestamp-http"))] +#[test] +fn code_signs_top_level_pe_with_rfc3161_timestamp() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("app.exe"); + let output = base.join("app.timestamped.exe"); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + write_test_rsa_cert_key(&cert, &key); + std::fs::copy( + repo_root().join("tests/fixtures/pe-authenticode-upstream/tiny32.efi"), + &input, + ) + .unwrap(); + let (mut guard, timestamp_url) = spawn_timestamp_server(); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--timestamp-url", + ×tamp_url, + "--timestamp-digest", + "sha256", + "--output", + ]) + .arg(&output) + .arg("app.exe"); + cmd.assert().success(); + let status = guard.0.wait().expect("timestamp server exit"); + assert!(status.success(), "timestamp server failed with {status}"); + + let mut verify = psign(); + verify + .args(["portable", "verify-pe"]) + .arg(&output) + .assert() + .success(); + + let pkcs7_der = + verify_pe::pe_nth_pkcs7_signed_data_der(&std::fs::read(&output).unwrap(), 0).unwrap(); + assert_pkcs7_has_unsigned_attr( + &pkcs7_der, + pkcs7::MS_RFC3161_TIMESTAMP_TOKEN_OID, + pkcs7::PKCS9_RFC3161_TIMESTAMP_TOKEN_OID, + ); +} + #[cfg(all(feature = "timestamp-server", feature = "azure-kv-sign"))] #[test] fn code_signs_pe_with_azure_key_vault_identity() { @@ -1780,6 +1833,61 @@ fn code_signs_nupkg_with_rfc3161_timestamp() { .stdout(predicate::str::contains("1.2.840.113549.1.9.16.2.14")); } +#[cfg(all(feature = "timestamp-server", feature = "timestamp-http"))] +#[test] +fn code_signs_nupkg_nested_pe_with_rfc3161_timestamp() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("with-pe.nupkg"); + let output = base.join("with-pe.timestamped.nupkg"); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + let nested_pe = base.join("tiny32.timestamped.dll"); + write_test_rsa_cert_key(&cert, &key); + std::fs::copy( + repo_root().join("tests/fixtures/package-signing/unsigned/with-pe.nupkg"), + &input, + ) + .unwrap(); + let (mut guard, timestamp_url) = spawn_timestamp_server_with_max_requests(2); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--timestamp-url", + ×tamp_url, + "--timestamp-digest", + "sha256", + "--output", + ]) + .arg(&output) + .arg("with-pe.nupkg"); + cmd.assert().success(); + let status = guard.0.wait().expect("timestamp server exit"); + assert!(status.success(), "timestamp server failed with {status}"); + + let signature_der = nuget::extract_signature_path(&output).expect("extract NuGet signature"); + assert_pkcs7_has_unsigned_attr( + &signature_der, + pkcs7::PKCS9_RFC3161_TIMESTAMP_TOKEN_OID, + pkcs7::MS_RFC3161_TIMESTAMP_TOKEN_OID, + ); + + extract_zip_entry(&output, "lib/net8.0/tiny32.dll", &nested_pe); + let nested_pkcs7 = + verify_pe::pe_nth_pkcs7_signed_data_der(&std::fs::read(&nested_pe).unwrap(), 0).unwrap(); + assert_pkcs7_has_unsigned_attr( + &nested_pkcs7, + pkcs7::MS_RFC3161_TIMESTAMP_TOKEN_OID, + pkcs7::PKCS9_RFC3161_TIMESTAMP_TOKEN_OID, + ); +} + #[test] fn code_rejects_zero_max_concurrency() { let mut cmd = psign(); @@ -2378,7 +2486,13 @@ impl Drop for PsignServerGuard { #[cfg(all(feature = "timestamp-server", feature = "timestamp-http"))] fn spawn_timestamp_server() -> (PsignServerGuard, String) { + spawn_timestamp_server_with_max_requests(1) +} + +#[cfg(all(feature = "timestamp-server", feature = "timestamp-http"))] +fn spawn_timestamp_server_with_max_requests(max_requests: u64) -> (PsignServerGuard, String) { let mut server_cmd = std::process::Command::new(assert_cmd::cargo::cargo_bin("psign-server")); + let max_requests = max_requests.to_string(); server_cmd.args([ "timestamp-server", "--listen", @@ -2386,7 +2500,7 @@ fn spawn_timestamp_server() -> (PsignServerGuard, String) { "--gen-time", "20240102030405Z", "--max-requests", - "1", + max_requests.as_str(), ]); server_cmd.stdout(std::process::Stdio::piped()); server_cmd.stderr(std::process::Stdio::piped());