From 0d1d27bbdea3f1a385e2184637a5a9c96f43dfe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 22 May 2026 10:07:20 -0400 Subject: [PATCH 01/14] Add dotnet sign package orchestration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitattributes | 3 + Cargo.lock | 2 + Cargo.toml | 9 +- README.md | 53 + crates/psign-authenticode-trust/src/anchor.rs | 2 +- crates/psign-digest-cli/Cargo.toml | 2 + crates/psign-digest-cli/src/main.rs | 3642 ++++++++++++++--- crates/psign-opc-sign/Cargo.toml | 1 + crates/psign-opc-sign/src/nuget.rs | 433 +- crates/psign-opc-sign/src/vsix.rs | 680 ++- crates/psign-sip-digest/src/pkcs7.rs | 97 + docs/gap-analysis-signing-platforms.md | 19 +- docs/linux-signing-pipelines.md | 59 +- docs/migration-dotnet-sign.md | 100 + docs/psign-cli-matrix.json | 38 + docs/rust-sip-gaps.md | 2 +- scripts/ci/build-package-signing-fixtures.ps1 | 71 +- scripts/linux-portable-validation.sh | 8 +- src/cli.rs | 88 + src/code.rs | 2288 +++++++++++ src/lib.rs | 7 + src/portable_sign.rs | 49 +- src/signing_provider.rs | 474 +++ tests/cli_pe_digest.rs | 1481 ++++++- tests/code_command.rs | 1561 +++++++ tests/fixture_vector_manifest.rs | 59 +- tests/fixtures/README.md | 4 + .../package-signing-fixtures.json | 78 +- .../signed/deep-nested.signed.vsix | Bin 0 -> 8520 bytes .../package-signing/signed/nested.signed.vsix | Bin 0 -> 8828 bytes .../signed/sample.signed.nupkg | Bin 3354 -> 3364 bytes .../signed/sample.signed.snupkg | Bin 3354 -> 3364 bytes .../package-signing/signed/sample.signed.vsix | Bin 7080 -> 7107 bytes .../signed/with-pe.signed.nupkg | Bin 0 -> 3803 bytes .../package-signing/unsigned/deep-nested.vsix | Bin 0 -> 2403 bytes .../package-signing/unsigned/nested.vsix | Bin 0 -> 2487 bytes .../package-signing/unsigned/sample.nupkg | Bin 823 -> 833 bytes .../package-signing/unsigned/sample.snupkg | Bin 823 -> 833 bytes .../package-signing/unsigned/sample.vsix | Bin 1185 -> 1208 bytes .../package-signing/unsigned/with-pe.nupkg | Bin 0 -> 1272 bytes 40 files changed, 10672 insertions(+), 638 deletions(-) create mode 100644 docs/migration-dotnet-sign.md create mode 100644 src/code.rs create mode 100644 src/signing_provider.rs create mode 100644 tests/code_command.rs create mode 100644 tests/fixtures/package-signing/signed/deep-nested.signed.vsix create mode 100644 tests/fixtures/package-signing/signed/nested.signed.vsix create mode 100644 tests/fixtures/package-signing/signed/with-pe.signed.nupkg create mode 100644 tests/fixtures/package-signing/unsigned/deep-nested.vsix create mode 100644 tests/fixtures/package-signing/unsigned/nested.vsix create mode 100644 tests/fixtures/package-signing/unsigned/with-pe.nupkg diff --git a/.gitattributes b/.gitattributes index 303d14c..6b16159 100644 --- a/.gitattributes +++ b/.gitattributes @@ -24,8 +24,11 @@ tests/fixtures/**/*.msixbundle binary tests/fixtures/**/*.msi binary tests/fixtures/**/*.msp binary tests/fixtures/**/*.mst binary +tests/fixtures/**/*.nupkg binary tests/fixtures/**/*.ocx binary tests/fixtures/**/*.p7 binary tests/fixtures/**/*.pfx binary +tests/fixtures/**/*.snupkg binary tests/fixtures/**/*.sys binary +tests/fixtures/**/*.vsix binary tests/fixtures/**/*.winmd binary diff --git a/Cargo.lock b/Cargo.lock index 3739b57..dca7530 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2017,6 +2017,7 @@ dependencies = [ "psign-azure-kv-rest", "psign-codesigning-rest", "psign-digest-cli", + "psign-opc-sign", "psign-sip-digest", "rand 0.8.6", "rayon", @@ -2116,6 +2117,7 @@ name = "psign-opc-sign" version = "0.2.0" dependencies = [ "anyhow", + "base64", "sha2 0.10.9", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index 8af5ca1..23cf3d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,12 +55,13 @@ artifact-signing-rest = ["dep:psign-codesigning-rest", "psign-digest-cli/artifac ## Portable RFC 3161 TSA HTTP POST helper under `psign-tool portable`. timestamp-http = ["psign-digest-cli/timestamp-http"] ## Local RFC 3161 timestamp test server (`psign-server`). -timestamp-server = ["dep:cms", "dep:der", "dep:rand", "dep:rsa", "x509-cert/builder"] +timestamp-server = ["dep:cms", "dep:der", "dep:rand", "x509-cert/builder"] [dependencies] psign-sip-digest = { path = "crates/psign-sip-digest" } psign-authenticode-trust = { path = "crates/psign-authenticode-trust" } psign-digest-cli = { path = "crates/psign-digest-cli" } +psign-opc-sign = { path = "crates/psign-opc-sign" } anyhow = "1" clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } @@ -73,15 +74,16 @@ picky = { version = "7.0.0-rc.23", default-features = false, features = ["pkcs12 cms = { version = "0.2.3", features = ["builder"], optional = true } der = { version = "0.7", features = ["derive"], optional = true } rand = { version = "0.8", optional = true } -rsa = { version = "0.9.10", features = ["sha2"], optional = true } +rsa = { version = "0.9.10", features = ["sha2"] } x509-cert = "0.2.5" +zip = { version = "0.6.6", default-features = false, features = ["deflate"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true } [target.'cfg(windows)'.dependencies] glob = "0.3" rayon = "1.10" psign-azure-kv-rest = { path = "crates/psign-azure-kv-rest", optional = true } psign-codesigning-rest = { path = "crates/psign-codesigning-rest", optional = true } -reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true } uuid = "1" windows = { version = "0.59", features = [ "Win32_Foundation", @@ -103,7 +105,6 @@ rand = "0.8" rsa = { version = "0.9.10", features = ["sha2"] } tempfile = "3" x509-cert = { version = "0.2.5", features = ["builder"] } -zip = { version = "0.6.6", default-features = false, features = ["deflate"] } [build-dependencies] winresource = "0.1.31" diff --git a/README.md b/README.md index d970292..3c26522 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Canonical repository: . - `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). - `cert-store`: Portable file-backed certificate store under `~/.psign/cert-store` by default, with Windows-style store/thumbprint selection. +- `code`: dotnet/sign-style orchestration entry point. It supports `--dry-run` / `--plan-json` planning over inputs, file lists, globs, and nested ZIP/OPC containers, plus guarded local cert/key execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, MSIX/AppX unsigned-package prepare, encrypted MSIX/AppX OS-only diagnostics, PE-like ClickOnce `.deploy` payloads, App Installer publisher updates + companion signatures, `--continue-on-error`, `--skip-signed`, `--overwrite`, and inside-out VSIX/ZIP -> NuGet/VSIX -> PE signing. - `portable ...`: Cross-platform digest, verification, trust, signing, package, RFC3161, and remote-hash helpers that avoid Win32 APIs, including PE/WinMD signing through Azure Artifact Signing REST without Microsoft client DLLs. ## MSIX parity notes @@ -99,7 +100,52 @@ cargo build -p psign --bin psign-tool --locked # Portable package inspection helpers: # psign-tool portable nupkg-signature-info package.nupkg # psign-tool portable nupkg-digest package.nupkg --algorithm sha256 +# psign-tool portable nupkg-signature-content package.nupkg --output signature-content.txt +# psign-tool portable nupkg-signature-pkcs7 package.nupkg --cert signer.der --key signer.pkcs8 --timestamp-url http://tsa --timestamp-digest sha256 --output signature.p7s +# psign-tool portable nupkg-signature-pkcs7-prehash package.nupkg --encoding raw --output prehash.bin +# psign-tool portable nupkg-signature-pkcs7-from-signature package.nupkg --cert signer.der --signature remote.sig --output signature.p7s +# psign-tool portable nupkg-verify-signature-content package.nupkg --content signature-content.txt +# psign-tool portable nupkg-embed-signature package.nupkg --signature signature.p7s --output signed.nupkg +# psign-tool portable nupkg-sign package.nupkg --cert signer.der --key signer.pkcs8 --timestamp-url http://tsa --timestamp-digest sha256 --output signed.nupkg +# psign-tool portable nupkg-verify-signature signed.nupkg --trusted-ca signer.der --allow-loose-signing-cert # psign-tool portable vsix-signature-info extension.vsix +# psign-tool portable vsix-signature-reference-xml extension.vsix --output signature-reference.xml +# psign-tool portable vsix-verify-signature-reference-xml extension.vsix --signature-xml signature-reference.xml +# psign-tool portable vsix-signature-xml extension.vsix --cert signer.der --key signer.pkcs8 --output signature.xml +# psign-tool portable vsix-signature-xml-prehash extension.vsix --encoding raw --output prehash.bin +# psign-tool portable vsix-signature-xml-from-signature extension.vsix --cert signer.der --signature remote.sig --output signature.xml +# psign-tool portable vsix-verify-signature-xml extension.vsix --signature-xml signature.xml --cert signer.der --trusted-ca root.der +# psign-tool portable vsix-embed-signature-xml extension.vsix --signature-xml signature.xml --output signed.vsix +# psign-tool portable vsix-sign extension.vsix --cert signer.der --key signer.pkcs8 --output signed.vsix +# psign-tool portable vsix-verify-signature signed.vsix --trusted-ca root.der +# psign-tool portable appinstaller-info app.appinstaller --signature app.appinstaller.p7 +# psign-tool portable appinstaller-sign-companion app.appinstaller --cert signer.der --key signer.pkcs8 --timestamp-url http://tsa --timestamp-digest sha256 --output app.appinstaller.p7 +# psign-tool portable appinstaller-sign-companion-prehash app.appinstaller --encoding raw --output prehash.bin +# psign-tool portable appinstaller-sign-companion-from-signature app.appinstaller --cert signer.der --signature remote.sig --output app.appinstaller.p7 +# psign-tool portable appinstaller-verify-companion app.appinstaller --signature app.appinstaller.p7 --anchor-dir anchors +# psign-tool portable appinstaller-set-publisher app.appinstaller --publisher "CN=Example" --output updated.appinstaller +# psign-tool portable business-central-app-info package.app +# psign-tool portable msix-manifest-info package.msix +# psign-tool portable msix-set-publisher package.msix --publisher "CN=Example" --output updated.msix +# psign-tool portable clickonce-deploy-info app.exe.deploy +# psign-tool portable clickonce-copy-deploy-payload app.exe.deploy --output app.exe +# psign-tool portable clickonce-update-manifest-hashes app.exe.manifest --base-directory . --output updated.manifest +# psign-tool portable clickonce-manifest-hashes updated.manifest --base-directory . +# psign-tool portable clickonce-sign-manifest updated.manifest --cert signer.der --key signer.pkcs8 --output signed.manifest +# psign-tool portable clickonce-sign-manifest-prehash updated.manifest --encoding raw --output prehash.bin +# psign-tool portable clickonce-sign-manifest-from-signature updated.manifest --cert signer.der --signature remote.sig --output signed.manifest +# psign-tool portable clickonce-verify-manifest-signature signed.manifest --trusted-ca signer.der +# dotnet/sign-style dry-run planning for nested package orchestration: +# psign-tool code --dry-run --plan-json --base-directory . --file-list files.txt +# Initial guarded code execution for PE/NuGet/VSIX/ZIP/MSIX/ClickOnce/App Installer inputs: +# psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.exe app.exe +# psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --timestamp-url http://tsa --timestamp-digest sha256 --output signed.nupkg package.nupkg +# psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.vsix extension.vsix +# psign-tool code --base-directory . --overwrite --cert signer.der --key signer.pkcs8 --output resigned.nupkg signed-package.nupkg +# psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.zip package-bundle.zip +# psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --publisher-name "CN=Publisher" --output prepared.msix app.msix +# psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output app.signed.exe.deploy app.exe.deploy +# psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --publisher-name "CN=Publisher" --output updated.appinstaller.p7 app.appinstaller # Optional portable REST helpers (Linux/macOS): # cargo build -p psign --bin psign-tool --locked --features artifact-signing-rest # cargo build -p psign --bin psign-tool --locked --features azure-kv-sign @@ -167,6 +213,13 @@ psign-tool --mode portable sign /sha1 ABCDEF0123456789ABCDEF0123456789ABCDEF01 / The portable signing path supports local RSA/SHA-2 Authenticode signing for PE/WinMD plus the package/script formats exposed by the portable core. Unsupported native signing options, CSP/KSP selection, auto-selection, and non-exportable local keys return explicit errors in portable mode. +Cloud-backed signing options also accept Azure.Identity-style selectors: +`--azure-key-vault-credential-type` and `--artifact-signing-credential-type` +(`default`, `managed-identity`, `access-token`, `client-secret`, +`workload-identity`). Managed identity maps to the existing managed-identity +flows; workload identity is represented in provider planning but explicit +signing execution is not wired yet. + ## Generate binary manifest and dependency graph ```powershell diff --git a/crates/psign-authenticode-trust/src/anchor.rs b/crates/psign-authenticode-trust/src/anchor.rs index 36ef42d..3660eb9 100644 --- a/crates/psign-authenticode-trust/src/anchor.rs +++ b/crates/psign-authenticode-trust/src/anchor.rs @@ -109,7 +109,7 @@ pub fn cert_sha1_thumbprint(cert: &Cert) -> Result<[u8; 20]> { Ok(out) } -fn parse_cert_bytes(raw: &[u8]) -> Result { +pub fn parse_cert_bytes(raw: &[u8]) -> Result { let trimmed = raw.trim_ascii_start(); if trimmed.starts_with(b"-----BEGIN ") { let s = diff --git a/crates/psign-digest-cli/Cargo.toml b/crates/psign-digest-cli/Cargo.toml index f3fa124..2d42c16 100644 --- a/crates/psign-digest-cli/Cargo.toml +++ b/crates/psign-digest-cli/Cargo.toml @@ -34,7 +34,9 @@ base64 = { version = "0.22", optional = true } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true } sha1 = "0.10" sha2 = "0.10" +rsa = { version = "0.9.10", features = ["sha2"] } x509-cert = "0.2.5" +zip = { version = "0.6.6", default-features = false, features = ["deflate"] } [dev-dependencies] assert_cmd = "2" diff --git a/crates/psign-digest-cli/src/main.rs b/crates/psign-digest-cli/src/main.rs index 6363379..633cc1d 100644 --- a/crates/psign-digest-cli/src/main.rs +++ b/crates/psign-digest-cli/src/main.rs @@ -14,6 +14,7 @@ use psign_authenticode_trust::{ 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, + trust_verify_pe::load_trust_material, }; #[cfg(feature = "azure-kv-sign-portable")] use psign_azure_kv_rest::{ @@ -50,11 +51,19 @@ 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 rsa::pkcs8::DecodePublicKey as _; +use rsa::signature::{SignatureEncoding as _, Signer as _, Verifier as _}; use serde::Deserialize; use sha1::Sha1; use sha2::{Digest as _, Sha256, Sha384, Sha512}; use std::ffi::{OsStr, OsString}; +use std::fs::File; +use std::io::{Read as _, Write as _}; use std::path::{Path, PathBuf}; +use x509_cert::der::{ + Encode as _, + asn1::{ObjectIdentifier, OctetString}, +}; #[derive(Parser)] #[command(name = "psign-tool")] @@ -167,6 +176,84 @@ fn trust_verify_options_from_shared(a: &TrustVerifySharedArgs) -> Result bool { + a.anchor_dir.is_some() + || !a.trusted_ca.is_empty() + || a.authroot_cab.is_some() + || a.expect_authroot_cab_sha256.is_some() + || a.as_of.is_some() + || a.online_aia + || a.online_ocsp + || a.revocation_mode != CliRevocationMode::Off + || a.crl_url_override.is_some() + || a.aia_url_override.is_some() + || a.ocsp_url_override.is_some() +} + +fn verify_xml_signer_certificate_trust(cert_der: &[u8], shared: &TrustVerifySharedArgs) -> Result { + let opts = trust_verify_options_from_shared(shared)?; + let (anchors, anchor_certs) = load_trust_material(&opts)?; + let leaf = psign_authenticode_trust::anchor::parse_cert_bytes(cert_der) + .context("parse XMLDSig signer certificate for trust verification")?; + let mut merged = + psign_authenticode_trust::chain::merge_unique_certs(vec![leaf.clone()], anchor_certs)?; + let chain_owned = psign_authenticode_trust::chain::issuer_chain_excluding_leaf_online( + &leaf, + &mut merged, + &opts.online, + )?; + let root = psign_authenticode_trust::chain::terminal_root_cert_owned(&leaf, &chain_owned); + let root_thumb = psign_authenticode_trust::anchor::cert_sha1_thumbprint(root)?; + if !anchors.contains_thumbprint(&root_thumb) { + return Err(anyhow!( + "XMLDSig terminal root certificate is not in the anchor store (SHA-1 thumbprint {:02x}{:02x}...)", + root_thumb[0], + root_thumb[1] + )); + } + psign_authenticode_trust::online::check_revocation_chain(&leaf, &chain_owned, &opts.online)?; + + let verification_instant = match opts.verification_instant_override.as_ref() { + Some(instant) => instant.clone(), + None if opts.policy.prefer_timestamp_signing_time && opts.policy.require_valid_timestamp => { + return Err(anyhow!( + "VSIX XMLDSig timestamp trust verification is not implemented; use --as-of for deterministic certificate-chain validation" + )); + } + None => psign_authenticode_trust::verification_instant::resolve_verification_utc_date( + b"", + &opts.policy, + )?, + }; + + let chain_refs: Vec<_> = chain_owned.iter().collect(); + let leaf_verifier = leaf.verifier(); + let verifier = leaf_verifier + .chain(chain_refs.iter().copied()) + .exact_date(&verification_instant); + verifier + .verify() + .map_err(|e| anyhow!("XMLDSig certificate chain verification: {e}"))?; + + if opts.verbose_chain { + let thumb_hex: String = root_thumb.iter().map(|b| format!("{b:02x}")).collect(); + eprintln!("xml-trust: leaf subject: {}", leaf.subject_name()); + for (i, cert) in chain_refs.iter().enumerate() { + eprintln!( + "xml-trust: chain[{i}] subject: {} issuer: {}", + cert.subject_name(), + cert.issuer_name() + ); + } + eprintln!( + "xml-trust: root subject: {} (thumb SHA-1 {thumb_hex})", + root.subject_name() + ); + } + + Ok(anchors.thumbprint_count()) +} + fn digest_byte_len_for_hash_alg(alg: HashAlg) -> usize { match alg { HashAlg::Sha1 => 20, @@ -283,512 +370,2003 @@ fn run_rfc3161_timestamp_req( Ok(()) } -fn run_rfc3161_timestamp_resp_inspect( - path: &Path, - expect_digest_hex: Option<&str>, - expect_nonce: Option, -) -> Result<()> { - let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?; - let expected_digest = expect_digest_hex - .map(normalize_even_hex) - .transpose() - .context("parse --expect-digest-hex")?; - let expected_nonce = expect_nonce.map(rfc3161_nonce_hex); - let p = parse_time_stamp_resp_der(&bytes).ok_or_else(|| { - anyhow!("could not parse TimeStampResp DER (definite ASN.1 subset or trailing garbage)") - })?; - let tok_len = p.time_stamp_token.map(|t| t.len()).unwrap_or(0); - println!( - "pki_status={} pki_status_int={} granted={} time_stamp_token_len={}", - pki_status_label(p.pki_status), - p.pki_status.as_raw_integer(), - if p.pki_status.granted() { "yes" } else { "no" }, - tok_len - ); - println!( - "time_stamp_token_prefix_hex={}", - time_stamp_token_prefix_hex(p.time_stamp_token) - ); - println!( - "status_strings_json={}", - serde_json::to_string(&p.status_strings).context("encode PKIStatusInfo.statusString")? - ); - match p.fail_info_tlv { - Some(fi) => println!("fail_info_tlv_hex={}", hex_lower(fi)), - None => println!("fail_info_tlv_hex=-"), - } - let flags_json = match p.fail_info_tlv { - None => serde_json::Value::Array(vec![]), - Some(fi) => match pkifailure_info_flag_labels_from_bit_string_tlv(fi) { - Some(labels) => serde_json::to_value(&labels).context("encode failInfo flags")?, - None => serde_json::Value::Null, - }, - }; - println!("fail_info_flags_json={flags_json}"); - if let Some(tst) = p.time_stamp_token.and_then(parse_time_stamp_token_tst_info) { - println!("tst_info_present=yes"); - println!("tst_info_policy_oid={}", tst.policy_oid); - println!( - "tst_info_message_imprint_digest_alg_oid={}", - tst.message_imprint_digest_alg_oid - ); - println!( - "tst_info_message_imprint_hashed_message_hex={}", - hex_lower(&tst.message_imprint_hashed_message) - ); - println!("tst_info_serial_hex={}", tst.serial_number_hex); - println!("tst_info_gen_time={}", tst.gen_time); - println!( - "tst_info_nonce_hex={}", - tst.nonce_hex.as_deref().unwrap_or("-") - ); - if let Some(expected) = expected_digest.as_deref() { - println!( - "tst_info_message_imprint_match={}", - if hex_lower(&tst.message_imprint_hashed_message) == expected { - "yes" - } else { - "no" - } - ); - } - if let Some(expected) = expected_nonce.as_deref() { - println!( - "tst_info_nonce_match={}", - if tst.nonce_hex.as_deref() == Some(expected) { - "yes" - } else { - "no" - } - ); - } - } else { - println!("tst_info_present=no"); - if expected_digest.is_some() { - println!("tst_info_message_imprint_match=no"); - } - if expected_nonce.is_some() { - println!("tst_info_nonce_match=no"); +#[derive(Debug, Eq, PartialEq)] +struct AppInstallerDescriptorInfo { + root: &'static str, + namespace: Option, + has_main_package: bool, + has_main_bundle: bool, + publisher: Option, +} + +#[derive(Debug, Eq, PartialEq)] +struct BusinessCentralAppInfo { + is_navx: bool, + len: u64, +} + +#[derive(Debug, Eq, PartialEq)] +struct MsixManifestInfo { + package_name: Option, + publisher: Option, + version: Option, + processor_architecture: Option, +} + +#[derive(Debug, Eq, PartialEq)] +struct ClickOnceDeployInfo { + deployed: bool, + content_name: Option, + len: u64, +} + +#[derive(Debug, Eq, PartialEq)] +struct ClickOnceManifestHashEntry { + path: String, + algorithm: HashAlg, + expected_size: Option, + actual_size: u64, + expected_digest_b64: String, + actual_digest_b64: String, +} + +impl ClickOnceManifestHashEntry { + fn status(&self) -> &'static str { + if self.expected_size.is_some_and(|size| size != self.actual_size) { + "mismatch" + } else if self.expected_digest_b64 == self.actual_digest_b64 { + "valid" + } else { + "mismatch" } } - Ok(()) } -#[cfg(feature = "timestamp-http")] -fn run_rfc3161_timestamp_http_post( - url: String, - algorithm: HashAlg, - digest_file: Option, - digest_hex: Option, - nonce: Option, - cert_req: bool, - output: Option, -) -> Result<()> { - use std::io::Write; - let preimage = - load_timestamp_imprint_preimage(digest_hex.as_ref(), digest_file.as_ref(), algorithm)?; - let plan = Rfc3161TimestampRequestPlan { - digest_alg_oid: hash_alg_timestamp_oid(algorithm), - nonce, - cert_req, - }; - let der = build_timestamp_request_bytes(&plan, &preimage).ok_or_else(|| { - anyhow!("unsupported digest OID / preimage length for RFC3161 TimeStampReq") - })?; - let client = reqwest::blocking::Client::builder() - .use_rustls_tls() - .timeout(std::time::Duration::from_secs(120)) - .build() - .context("build HTTP client (timestamp-http feature)")?; - let resp = client - .post(url.trim()) - .header("Content-Type", "application/timestamp-query") - .header( - "Accept", - "application/timestamp-reply, application/timestamp-response", - ) - .body(der) - .send() - .with_context(|| format!("POST TimeStampReq to {}", url.trim()))?; - let status = resp.status(); - let body = resp.bytes().context("read TSA response body")?; - if !status.is_success() { +#[derive(Debug, Eq, PartialEq)] +struct ClickOnceManifestSignatureReport { + digest: PortableSignDigest, + manifest_digest_b64: String, + signature_len: usize, +} + +fn inspect_clickonce_deploy_payload(path: &Path) -> Result { + let metadata = std::fs::metadata(path).with_context(|| format!("stat {}", path.display()))?; + let content_name = clickonce_deploy_content_name(path); + Ok(ClickOnceDeployInfo { + deployed: content_name.is_some(), + content_name, + len: metadata.len(), + }) +} + +fn clickonce_deploy_content_name(path: &Path) -> Option { + let file_name = path.file_name()?.to_string_lossy(); + file_name + .strip_suffix(".deploy") + .filter(|name| !name.is_empty()) + .map(str::to_owned) +} + +fn copy_clickonce_deploy_payload(input: &Path, output: &Path) -> Result { + let Some(_) = clickonce_deploy_content_name(input) else { return Err(anyhow!( - "TSA HTTP {} — first {} body bytes (hex): {}", - status, - body.len().min(256), - hex_lower(&body[..body.len().min(256)]) + "ClickOnce deploy payload name must end with .deploy: {}", + input.display() )); + }; + std::fs::copy(input, output) + .with_context(|| format!("copy {} to {}", input.display(), output.display())) +} + +fn clickonce_manifest_hashes( + manifest_path: &Path, + base_directory: Option<&Path>, +) -> Result> { + let text = std::fs::read_to_string(manifest_path) + .with_context(|| format!("read ClickOnce manifest {}", manifest_path.display()))?; + let base = base_directory + .map(Path::to_path_buf) + .unwrap_or_else(|| manifest_path.parent().unwrap_or(Path::new(".")).to_path_buf()); + clickonce_manifest_hashes_from_text(&text, &base) +} + +fn clickonce_manifest_hashes_from_text( + text: &str, + base_directory: &Path, +) -> Result> { + let entries = clickonce_manifest_reference_spans(text)?; + let mut out = Vec::with_capacity(entries.len()); + for entry in entries { + let file_path = resolve_clickonce_manifest_path(base_directory, &entry.path)?; + let bytes = + std::fs::read(&file_path).with_context(|| format!("read {}", file_path.display()))?; + let digest = digest_bytes_for_hash_alg(entry.algorithm, &bytes); + out.push(ClickOnceManifestHashEntry { + path: entry.path, + algorithm: entry.algorithm, + expected_size: entry.size, + actual_size: bytes.len() as u64, + expected_digest_b64: entry.digest_value, + actual_digest_b64: base64_encode(&digest), + }); } - match output.as_ref() { - Some(p) => std::fs::write(p, &body).with_context(|| format!("write {}", p.display()))?, - None => std::io::stdout() - .write_all(&body) - .context("write TimeStampResp DER to stdout")?, - } - Ok(()) + Ok(out) } -#[cfg(feature = "timestamp-http")] -fn post_rfc3161_timestamp_request( - url: &str, +fn update_clickonce_manifest_hashes( + manifest_path: &Path, + base_directory: Option<&Path>, + output: &Path, algorithm: HashAlg, - message_imprint: &[u8], -) -> Result> { - if message_imprint.len() != digest_byte_len_for_hash_alg(algorithm) { - return Err(anyhow!( - "timestamp message imprint must be exactly {} bytes for {:?}, got {}", - digest_byte_len_for_hash_alg(algorithm), - algorithm, - message_imprint.len() +) -> Result { + let text = std::fs::read_to_string(manifest_path) + .with_context(|| format!("read ClickOnce manifest {}", manifest_path.display()))?; + let base = base_directory + .map(Path::to_path_buf) + .unwrap_or_else(|| manifest_path.parent().unwrap_or(Path::new(".")).to_path_buf()); + let updated = update_clickonce_manifest_hashes_in_text(&text, &base, algorithm)?; + std::fs::write(output, updated.text) + .with_context(|| format!("write ClickOnce manifest {}", output.display()))?; + Ok(updated.updated) +} + +#[derive(Debug)] +struct ClickOnceManifestReference { + tag_start: usize, + tag_end: usize, + path: String, + size: Option, + algorithm: HashAlg, + digest_value: String, + digest_method_tag_start: usize, + digest_method_tag_end: usize, + digest_value_content_start: usize, + digest_value_content_end: usize, +} + +#[derive(Debug)] +struct UpdatedClickOnceManifest { + text: String, + updated: usize, +} + +fn update_clickonce_manifest_hashes_in_text( + text: &str, + base_directory: &Path, + algorithm: HashAlg, +) -> Result { + let entries = clickonce_manifest_reference_spans(text)?; + let mut replacements = Vec::with_capacity(entries.len() * 3); + for entry in &entries { + let file_path = resolve_clickonce_manifest_path(base_directory, &entry.path)?; + let bytes = + std::fs::read(&file_path).with_context(|| format!("read {}", file_path.display()))?; + let digest = digest_bytes_for_hash_alg(algorithm, &bytes); + let size = bytes.len().to_string(); + replacements.push(( + entry.tag_start, + entry.tag_end + 1, + replace_or_insert_xml_attr(&text[entry.tag_start..=entry.tag_end], "size", &size)?, )); - } - let plan = Rfc3161TimestampRequestPlan { - digest_alg_oid: hash_alg_timestamp_oid(algorithm), - nonce: None, - cert_req: true, - }; - let der = build_timestamp_request_bytes(&plan, message_imprint).ok_or_else(|| { - anyhow!("unsupported digest OID / preimage length for RFC3161 TimeStampReq") - })?; - let client = reqwest::blocking::Client::builder() - .use_rustls_tls() - .timeout(std::time::Duration::from_secs(120)) - .build() - .context("build HTTP client (timestamp-http feature)")?; - let resp = client - .post(url.trim()) - .header("Content-Type", "application/timestamp-query") - .header( - "Accept", - "application/timestamp-reply, application/timestamp-response", - ) - .body(der) - .send() - .with_context(|| format!("POST TimeStampReq to {}", url.trim()))?; - let status = resp.status(); - let body = resp.bytes().context("read TSA response body")?; - if !status.is_success() { - return Err(anyhow!( - "TSA HTTP {} — first {} body bytes (hex): {}", - status, - body.len().min(256), - hex_lower(&body[..body.len().min(256)]) + replacements.push(( + entry.digest_method_tag_start, + entry.digest_method_tag_end + 1, + replace_or_insert_xml_attr( + &text[entry.digest_method_tag_start..=entry.digest_method_tag_end], + "Algorithm", + clickonce_digest_algorithm_uri(algorithm), + )?, + )); + replacements.push(( + entry.digest_value_content_start, + entry.digest_value_content_end, + base64_encode(&digest), )); } - Ok(body.to_vec()) + + replacements.sort_by_key(|(start, _, _)| *start); + let mut out = String::with_capacity(text.len()); + let mut cursor = 0usize; + for (start, end, replacement) in replacements { + if start < cursor { + return Err(anyhow!("internal ClickOnce manifest replacement overlap")); + } + out.push_str(&text[cursor..start]); + out.push_str(&replacement); + cursor = end; + } + out.push_str(&text[cursor..]); + Ok(UpdatedClickOnceManifest { + text: out, + updated: entries.len(), + }) } -#[cfg(feature = "timestamp-http")] -fn timestamp_pkcs7_der_rfc3161( - pkcs7_der: &[u8], - timestamp_url: &str, - timestamp_digest: HashAlg, -) -> Result> { - let sd = pkcs7::parse_pkcs7_signed_data_der(pkcs7_der).context("parse PKCS#7 SignedData")?; - let signer = sd - .signer_infos - .0 - .as_slice() - .first() - .ok_or_else(|| anyhow!("PKCS#7 SignedData has no SignerInfo to timestamp"))?; - let imprint = digest_bytes_for_hash_alg(timestamp_digest, signer.signature.as_bytes()); - let response = post_rfc3161_timestamp_request(timestamp_url, timestamp_digest, &imprint)?; - let parsed = parse_time_stamp_resp_der(&response) - .ok_or_else(|| anyhow!("could not parse TimeStampResp DER from TSA response"))?; - if !parsed.pki_status.granted() { +fn clickonce_manifest_reference_spans(text: &str) -> Result> { + let mut refs = Vec::new(); + collect_clickonce_manifest_references(text, "file", "name", &mut refs)?; + collect_clickonce_manifest_references(text, "dependentAssembly", "codebase", &mut refs)?; + refs.sort_by_key(|entry| entry.tag_start); + Ok(refs) +} + +fn collect_clickonce_manifest_references( + text: &str, + tag: &str, + path_attr: &str, + refs: &mut Vec, +) -> Result<()> { + let mut cursor = 0usize; + while let Some(start) = find_xml_start_tag(text, tag, cursor) { + let tag_end = text[start..] + .find('>') + .map(|offset| start + offset) + .ok_or_else(|| anyhow!("ClickOnce manifest <{tag}> tag is not closed"))?; + let start_tag = &text[start..=tag_end]; + cursor = tag_end + 1; + if start_tag.ends_with("/>") { + continue; + } + let Some(path) = xml_attr(start_tag, path_attr) else { + continue; + }; + let close = format!(""); + let Some(close_start) = text[cursor..].find(&close).map(|offset| cursor + offset) else { + continue; + }; + let block_end = close_start + close.len(); + let block = &text[tag_end + 1..close_start]; + let Some(method) = find_xml_start_tag_by_local_name(block, "DigestMethod", 0)? else { + continue; + }; + let Some(value) = find_xml_element_by_local_name(block, "DigestValue", 0)? else { + continue; + }; + let method_tag = &block[method.start..=method.end]; + let algorithm = xml_attr(method_tag, "Algorithm") + .as_deref() + .map(clickonce_hash_alg_from_uri) + .transpose()? + .unwrap_or(HashAlg::Sha256); + refs.push(ClickOnceManifestReference { + tag_start: start, + tag_end, + path, + size: xml_attr(start_tag, "size") + .map(|s| s.parse::()) + .transpose() + .context("parse ClickOnce manifest size attribute")?, + algorithm, + digest_value: block[value.content_start..value.content_end] + .trim() + .to_owned(), + digest_method_tag_start: tag_end + 1 + method.start, + digest_method_tag_end: tag_end + 1 + method.end, + digest_value_content_start: tag_end + 1 + value.content_start, + digest_value_content_end: tag_end + 1 + value.content_end, + }); + cursor = block_end; + } + Ok(()) +} + +#[derive(Debug)] +struct XmlStartTagSpan { + start: usize, + end: usize, + name: String, +} + +#[derive(Debug)] +struct XmlElementSpan { + content_start: usize, + content_end: usize, +} + +fn find_xml_start_tag(text: &str, tag: &str, from: usize) -> Option { + let needle = format!("<{tag}"); + let mut cursor = from; + while let Some(rel) = text[cursor..].find(&needle) { + let start = cursor + rel; + let next = text[start + needle.len()..].chars().next(); + if matches!(next, Some(' ' | '\t' | '\r' | '\n' | '>' | '/')) { + return Some(start); + } + cursor = start + needle.len(); + } + None +} + +fn find_xml_start_tag_by_local_name( + text: &str, + local_name: &str, + from: usize, +) -> Result> { + let mut cursor = from; + while let Some(rel) = text[cursor..].find('<') { + let start = cursor + rel; + let Some(first) = text[start + 1..].chars().next() else { + return Ok(None); + }; + if matches!(first, '/' | '!' | '?') { + cursor = start + 1; + continue; + } + let end = text[start..] + .find('>') + .map(|offset| start + offset) + .ok_or_else(|| anyhow!("ClickOnce XML tag is not closed"))?; + let name_start = start + 1; + let name_end = text[name_start..=end] + .find(|ch: char| ch.is_whitespace() || ch == '>' || ch == '/') + .map(|offset| name_start + offset) + .unwrap_or(end); + let name = &text[name_start..name_end]; + let name_local = name.rsplit_once(':').map(|(_, local)| local).unwrap_or(name); + if name_local == local_name { + return Ok(Some(XmlStartTagSpan { + start, + end, + name: name.to_owned(), + })); + } + cursor = end + 1; + } + Ok(None) +} + +fn find_xml_element_by_local_name( + text: &str, + local_name: &str, + from: usize, +) -> Result> { + let Some(start_tag) = find_xml_start_tag_by_local_name(text, local_name, from)? else { + return Ok(None); + }; + let close = format!("", start_tag.name); + let content_start = start_tag.end + 1; + let content_end = text[content_start..] + .find(&close) + .map(|offset| content_start + offset) + .ok_or_else(|| anyhow!("ClickOnce XML tag is not closed", start_tag.name))?; + Ok(Some(XmlElementSpan { + content_start, + content_end, + })) +} + +fn find_xml_element_span_by_local_name( + text: &str, + local_name: &str, + from: usize, +) -> Result> { + let Some(start_tag) = find_xml_start_tag_by_local_name(text, local_name, from)? else { + return Ok(None); + }; + let close = format!("", start_tag.name); + let content_start = start_tag.end + 1; + let close_start = text[content_start..] + .find(&close) + .map(|offset| content_start + offset) + .ok_or_else(|| anyhow!("ClickOnce XML tag is not closed", start_tag.name))?; + Ok(Some((start_tag.start, close_start + close.len()))) +} + +fn find_xml_root_start_tag(text: &str) -> Result { + let mut cursor = 0usize; + while let Some(rel) = text[cursor..].find('<') { + let start = cursor + rel; + let Some(first) = text[start + 1..].chars().next() else { + break; + }; + if matches!(first, '?' | '!') { + let end = text[start..] + .find('>') + .map(|offset| start + offset) + .ok_or_else(|| anyhow!("ClickOnce XML declaration/comment is not closed"))?; + cursor = end + 1; + continue; + } + if first == '/' { + return Err(anyhow!("ClickOnce XML starts with an unexpected closing tag")); + } + let end = text[start..] + .find('>') + .map(|offset| start + offset) + .ok_or_else(|| anyhow!("ClickOnce XML root tag is not closed"))?; + let name_start = start + 1; + let name_end = text[name_start..=end] + .find(|ch: char| ch.is_whitespace() || ch == '>' || ch == '/') + .map(|offset| name_start + offset) + .unwrap_or(end); + return Ok(XmlStartTagSpan { + start, + end, + name: text[name_start..name_end].to_owned(), + }); + } + Err(anyhow!("ClickOnce manifest does not contain a root XML element")) +} + +fn resolve_clickonce_manifest_path(base_directory: &Path, manifest_path: &str) -> Result { + let relative = Path::new(manifest_path); + let mut safe = PathBuf::new(); + for component in relative.components() { + match component { + std::path::Component::Normal(part) => safe.push(part), + std::path::Component::CurDir => {} + _ => { + return Err(anyhow!( + "ClickOnce manifest path must be relative and stay under the base directory: {manifest_path}" + )); + } + } + } + if safe.as_os_str().is_empty() { + return Err(anyhow!("ClickOnce manifest path is empty")); + } + Ok(base_directory.join(safe)) +} + +fn clickonce_hash_alg_from_uri(uri: &str) -> Result { + match uri { + "http://www.w3.org/2000/09/xmldsig#sha1" => Ok(HashAlg::Sha1), + "http://www.w3.org/2001/04/xmlenc#sha256" => Ok(HashAlg::Sha256), + "http://www.w3.org/2001/04/xmldsig-more#sha384" => Ok(HashAlg::Sha384), + "http://www.w3.org/2001/04/xmlenc#sha512" => Ok(HashAlg::Sha512), + other => Err(anyhow!( + "unsupported ClickOnce digest method Algorithm: {other}" + )), + } +} + +fn clickonce_digest_algorithm_uri(algorithm: HashAlg) -> &'static str { + match algorithm { + HashAlg::Sha1 => "http://www.w3.org/2000/09/xmldsig#sha1", + HashAlg::Sha256 => "http://www.w3.org/2001/04/xmlenc#sha256", + HashAlg::Sha384 => "http://www.w3.org/2001/04/xmldsig-more#sha384", + HashAlg::Sha512 => "http://www.w3.org/2001/04/xmlenc#sha512", + } +} + +fn clickonce_signature_algorithm_uri(digest: PortableSignDigest) -> &'static str { + match digest { + PortableSignDigest::Sha256 => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + PortableSignDigest::Sha384 => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384", + PortableSignDigest::Sha512 => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512", + } +} + +fn clickonce_signature_digest_uri(digest: PortableSignDigest) -> &'static str { + match digest { + PortableSignDigest::Sha256 => clickonce_digest_algorithm_uri(HashAlg::Sha256), + PortableSignDigest::Sha384 => clickonce_digest_algorithm_uri(HashAlg::Sha384), + PortableSignDigest::Sha512 => clickonce_digest_algorithm_uri(HashAlg::Sha512), + } +} + +fn clickonce_signature_digest_bytes(digest: PortableSignDigest, bytes: &[u8]) -> Vec { + match digest { + PortableSignDigest::Sha256 => Sha256::digest(bytes).to_vec(), + PortableSignDigest::Sha384 => Sha384::digest(bytes).to_vec(), + PortableSignDigest::Sha512 => Sha512::digest(bytes).to_vec(), + } +} + +fn unsigned_clickonce_manifest_text(text: &str) -> Result { + let Some((start, end)) = find_xml_element_span_by_local_name(text, "Signature", 0)? else { + return Ok(text.to_owned()); + }; + let mut out = String::with_capacity(text.len() - (end - start)); + out.push_str(&text[..start]); + out.push_str(&text[end..]); + Ok(out) +} + +fn clickonce_manifest_signed_info_xml( + unsigned_manifest_text: &str, + digest: PortableSignDigest, +) -> Vec { + let manifest_digest = clickonce_signature_digest_bytes(digest, unsigned_manifest_text.as_bytes()); + let digest_b64 = base64_encode(&manifest_digest); + format!( + r#"{digest_b64}"#, + clickonce_signature_algorithm_uri(digest), + clickonce_signature_digest_uri(digest), + ) + .into_bytes() +} + +fn clickonce_manifest_signature_xml( + signed_info: &[u8], + signature: &[u8], + cert_der: &[u8], +) -> String { + let signed_info = String::from_utf8_lossy(signed_info); + format!( + r#"{signed_info}{}{}"#, + base64_encode(signature), + base64_encode(cert_der) + ) +} + +fn insert_clickonce_signature_xml(unsigned_manifest_text: &str, signature_xml: &str) -> Result { + let root = find_xml_root_start_tag(unsigned_manifest_text)?; + let close = format!("", root.name); + let close_start = unsigned_manifest_text + .rfind(&close) + .ok_or_else(|| anyhow!("ClickOnce manifest root tag is not closed", root.name))?; + let mut out = String::with_capacity(unsigned_manifest_text.len() + signature_xml.len()); + out.push_str(&unsigned_manifest_text[..close_start]); + out.push_str(signature_xml); + out.push_str(&unsigned_manifest_text[close_start..]); + Ok(out) +} + +fn clickonce_signed_info_from_signature_xml(signature_xml: &str) -> Result> { + let Some((start, end)) = find_xml_element_span_by_local_name(signature_xml, "SignedInfo", 0)? else { + return Err(anyhow!("ClickOnce manifest signature is missing SignedInfo")); + }; + Ok(signature_xml.as_bytes()[start..end].to_vec()) +} + +fn clickonce_signature_value_from_signature_xml(signature_xml: &str) -> Result> { + let value = find_xml_element_by_local_name(signature_xml, "SignatureValue", 0)? + .ok_or_else(|| anyhow!("ClickOnce manifest signature is missing SignatureValue"))?; + let text = &signature_xml[value.content_start..value.content_end]; + let signature = base64_decode(text.trim()).context("decode ClickOnce SignatureValue")?; + if signature.is_empty() { + return Err(anyhow!("ClickOnce manifest SignatureValue is empty")); + } + Ok(signature) +} + +fn clickonce_signer_certificate_from_signature_xml(signature_xml: &str) -> Result> { + let value = find_xml_element_by_local_name(signature_xml, "X509Certificate", 0)? + .ok_or_else(|| anyhow!("ClickOnce manifest signature is missing X509Certificate"))?; + let text = &signature_xml[value.content_start..value.content_end]; + let cert = base64_decode(text.trim()).context("decode ClickOnce X509Certificate")?; + if cert.is_empty() { + return Err(anyhow!("ClickOnce manifest X509Certificate is empty")); + } + Ok(cert) +} + +fn sign_clickonce_manifest_path( + path: &Path, + cert: &Path, + key: &Path, + digest: PortableSignDigest, + output: &Path, +) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("read ClickOnce manifest {}", path.display()))?; + let unsigned = unsigned_clickonce_manifest_text(&text)?; + let cert_bytes = std::fs::read(cert).with_context(|| format!("read {}", cert.display()))?; + rdp::parse_certificate(&cert_bytes) + .with_context(|| format!("parse signer certificate {}", cert.display()))?; + let key_bytes = std::fs::read(key).with_context(|| format!("read {}", key.display()))?; + let private_key = rdp::parse_rsa_private_key(&key_bytes) + .with_context(|| format!("parse RSA private key {}", key.display()))?; + let signed_info = clickonce_manifest_signed_info_xml(&unsigned, digest); + let signature = sign_clickonce_signed_info(digest, private_key, &signed_info)?; + let signature_xml = clickonce_manifest_signature_xml(&signed_info, &signature, &cert_bytes); + let signed_manifest = insert_clickonce_signature_xml(&unsigned, &signature_xml)?; + std::fs::write(output, signed_manifest) + .with_context(|| format!("write ClickOnce manifest {}", output.display()))?; + Ok(ClickOnceManifestSignatureReport { + digest, + manifest_digest_b64: base64_encode(&clickonce_signature_digest_bytes( + digest, + unsigned.as_bytes(), + )), + signature_len: signature.len(), + }) +} + +fn clickonce_signed_info_remote_prehash( + digest: PortableSignDigest, + signed_info: &[u8], +) -> Vec { + clickonce_signature_digest_bytes(digest, signed_info) +} + +fn sign_clickonce_manifest_from_external_signature_path( + path: &Path, + cert: &Path, + signature: &Path, + digest: PortableSignDigest, + output: &Path, +) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("read ClickOnce manifest {}", path.display()))?; + let unsigned = unsigned_clickonce_manifest_text(&text)?; + let cert_bytes = std::fs::read(cert).with_context(|| format!("read {}", cert.display()))?; + rdp::parse_certificate(&cert_bytes) + .with_context(|| format!("parse signer certificate {}", cert.display()))?; + let signature = std::fs::read(signature).with_context(|| format!("read {}", signature.display()))?; + let signed_info = clickonce_manifest_signed_info_xml(&unsigned, digest); + let signature_xml = clickonce_manifest_signature_xml(&signed_info, &signature, &cert_bytes); + let signed_manifest = insert_clickonce_signature_xml(&unsigned, &signature_xml)?; + std::fs::write(output, signed_manifest) + .with_context(|| format!("write ClickOnce manifest {}", output.display()))?; + Ok(ClickOnceManifestSignatureReport { + digest, + manifest_digest_b64: base64_encode(&clickonce_signature_digest_bytes( + digest, + unsigned.as_bytes(), + )), + signature_len: signature.len(), + }) +} + +fn verify_clickonce_manifest_signature_path( + path: &Path, + cert: Option<&Path>, + digest: PortableSignDigest, + shared: &TrustVerifySharedArgs, +) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("read ClickOnce manifest {}", path.display()))?; + let unsigned = unsigned_clickonce_manifest_text(&text)?; + let (signature_start, signature_end) = find_xml_element_span_by_local_name(&text, "Signature", 0)? + .ok_or_else(|| anyhow!("ClickOnce manifest is missing XMLDSig Signature"))?; + let signature_xml = &text[signature_start..signature_end]; + let signed_info = clickonce_signed_info_from_signature_xml(signature_xml)?; + let signature = clickonce_signature_value_from_signature_xml(signature_xml)?; + let embedded_cert = clickonce_signer_certificate_from_signature_xml(signature_xml)?; + let cert_bytes = match cert { + Some(path) => std::fs::read(path).with_context(|| format!("read {}", path.display()))?, + None => embedded_cert, + }; + let signer_cert = rdp::parse_certificate(&cert_bytes).context("parse ClickOnce signer certificate")?; + let expected_signed_info = clickonce_manifest_signed_info_xml(&unsigned, digest); + if signed_info != expected_signed_info { + return Err(anyhow!("ClickOnce manifest SignedInfo does not match manifest digest")); + } + verify_clickonce_signed_info(digest, &signer_cert, &signed_info, &signature)?; + if trust_verify_args_present(shared) { + verify_xml_signer_certificate_trust(&cert_bytes, shared)?; + } + Ok(ClickOnceManifestSignatureReport { + digest, + manifest_digest_b64: base64_encode(&clickonce_signature_digest_bytes( + digest, + unsigned.as_bytes(), + )), + signature_len: signature.len(), + }) +} + +fn hash_alg_label(algorithm: HashAlg) -> &'static str { + match algorithm { + HashAlg::Sha1 => "sha1", + HashAlg::Sha256 => "sha256", + HashAlg::Sha384 => "sha384", + HashAlg::Sha512 => "sha512", + } +} + +fn base64_encode(bytes: &[u8]) -> String { + const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b0 = chunk[0]; + let b1 = *chunk.get(1).unwrap_or(&0); + let b2 = *chunk.get(2).unwrap_or(&0); + out.push(TABLE[(b0 >> 2) as usize] as char); + out.push(TABLE[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); + if chunk.len() > 1 { + out.push(TABLE[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char); + } else { + out.push('='); + } + if chunk.len() > 2 { + out.push(TABLE[(b2 & 0x3f) as usize] as char); + } else { + out.push('='); + } + } + out +} + +fn base64_decode(text: &str) -> Result> { + let mut out = Vec::with_capacity(text.len() * 3 / 4); + let mut chunk = [0u8; 4]; + let mut chunk_len = 0usize; + let mut padding = 0usize; + for ch in text.bytes().filter(|b| !b.is_ascii_whitespace()) { + let value = match ch { + b'A'..=b'Z' => ch - b'A', + b'a'..=b'z' => ch - b'a' + 26, + b'0'..=b'9' => ch - b'0' + 52, + b'+' => 62, + b'/' => 63, + b'=' => { + padding += 1; + 0 + } + _ => return Err(anyhow!("invalid base64 character in XMLDSig value")), + }; + chunk[chunk_len] = value; + chunk_len += 1; + if chunk_len == 4 { + out.push((chunk[0] << 2) | (chunk[1] >> 4)); + if padding < 2 { + out.push((chunk[1] << 4) | (chunk[2] >> 2)); + } + if padding == 0 { + out.push((chunk[2] << 6) | chunk[3]); + } + chunk_len = 0; + padding = 0; + } + } + if chunk_len != 0 { + return Err(anyhow!("truncated base64 XMLDSig value")); + } + Ok(out) +} + +fn inspect_business_central_app(path: &Path) -> Result { + let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?; + Ok(BusinessCentralAppInfo { + is_navx: bytes.starts_with(b"NAVX"), + len: bytes.len() as u64, + }) +} + +fn inspect_msix_manifest_path(path: &Path) -> Result { + reject_encrypted_msix_path(path)?; + let manifest = read_msix_manifest(path)?; + let identity = first_tag(&manifest, "Identity") + .ok_or_else(|| anyhow!("MSIX/AppX AppxManifest.xml is missing Identity"))?; + Ok(MsixManifestInfo { + package_name: xml_attr(identity, "Name"), + publisher: xml_attr(identity, "Publisher"), + version: xml_attr(identity, "Version"), + processor_architecture: xml_attr(identity, "ProcessorArchitecture"), + }) +} + +fn set_msix_manifest_publisher_path(input: &Path, output: &Path, publisher: &str) -> Result<()> { + reject_encrypted_msix_path(input)?; + if publisher.is_empty() { + return Err(anyhow!("MSIX/AppX publisher cannot be empty")); + } + let reader = File::open(input).with_context(|| format!("open {}", input.display()))?; + let writer = File::create(output).with_context(|| format!("create {}", output.display()))?; + set_msix_manifest_publisher(reader, writer, publisher) + .with_context(|| format!("set MSIX/AppX manifest publisher in {}", input.display())) +} + +fn reject_encrypted_msix_path(path: &Path) -> Result<()> { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + if psign_sip_digest::msix_digest::is_encrypted_msix_extension(&ext) { return Err(anyhow!( - "TimeStampResp status is not granted (status={})", - parsed.pki_status.as_raw_integer() + "encrypted MSIX/AppX packages (.eappx/.emsix) require Windows AppxSip OS delegation; portable cleartext package helpers cannot inspect or update {}", + path.display() )); } - let token = parsed - .time_stamp_token - .ok_or_else(|| anyhow!("TimeStampResp has no timeStampToken"))?; - let stamped = pkcs7::signed_data_add_rfc3161_timestamp_token(&sd, 0, token) - .context("attach RFC3161 timestamp token")?; - pkcs7::encode_pkcs7_content_info_signed_data_der(&stamped) + Ok(()) } -fn parse_sha256_hex(s: &str) -> Result<[u8; 32]> { - let hex = s.trim().strip_prefix("0x").unwrap_or(s.trim()); - let hex = hex.strip_prefix("0X").unwrap_or(hex); - if hex.len() != 64 { +fn read_msix_manifest(path: &Path) -> Result { + let file = File::open(path).with_context(|| format!("open {}", path.display()))?; + let mut archive = zip::ZipArchive::new(file).context("open MSIX/AppX ZIP")?; + let mut manifest = archive + .by_name("AppxManifest.xml") + .context("read AppxManifest.xml")?; + let mut text = String::new(); + manifest + .read_to_string(&mut text) + .context("read AppxManifest.xml as UTF-8")?; + Ok(text) +} + +fn set_msix_manifest_publisher(reader: R, writer: W, publisher: &str) -> Result<()> +where + R: std::io::Read + std::io::Seek, + W: std::io::Write + std::io::Seek, +{ + let escaped = xml_escape_attr(publisher); + let mut input = zip::ZipArchive::new(reader).context("open MSIX/AppX ZIP")?; + if input.by_name("AppxSignature.p7x").is_ok() { return Err(anyhow!( - "expect 64 hex chars for SHA-256, got {}", - hex.len() + "MSIX/AppX package already contains AppxSignature.p7x; update the unsigned package before final signing" )); } - let mut out = [0u8; 32]; - for i in 0..32 { - let byte = - u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).map_err(|_| anyhow!("invalid hex"))?; - out[i] = byte; + let mut output = zip::ZipWriter::new(writer); + let mut updated_manifest = false; + + for i in 0..input.len() { + let mut file = input.by_index(i).context("read MSIX/AppX ZIP entry")?; + let name = psign_opc_sign::opc::normalize_zip_part_name(file.name())?; + let options = zip::write::FileOptions::default().compression_method(file.compression()); + if file.is_dir() { + output.add_directory(name, options)?; + continue; + } + output.start_file(&name, options)?; + if name == "AppxManifest.xml" { + let mut text = String::new(); + file.read_to_string(&mut text) + .context("read AppxManifest.xml as UTF-8")?; + let updated = update_attr_for_tags(&text, "Identity", "Publisher", &escaped)?; + output.write_all(updated.as_bytes())?; + updated_manifest = true; + } else { + std::io::copy(&mut file, &mut output)?; + } + } + + if !updated_manifest { + return Err(anyhow!("MSIX/AppX package is missing AppxManifest.xml")); + } + output.finish()?; + Ok(()) +} + +fn parse_appinstaller_descriptor(text: &str) -> Result { + let root_start = text + .find(" not found"))?; + let root_end = text[root_start..] + .find('>') + .map(|offset| root_start + offset) + .ok_or_else(|| anyhow!("App Installer root tag is not closed"))?; + let root_tag = &text[root_start..=root_end]; + let namespace = xml_attr(root_tag, "xmlns"); + let has_main_package = text.contains(" Result { + if publisher.is_empty() { + return Err(anyhow!("App Installer publisher cannot be empty")); + } + let info = parse_appinstaller_descriptor(text)?; + if !info.has_main_package && !info.has_main_bundle { + return Err(anyhow!( + "App Installer descriptor does not contain MainPackage or MainBundle" + )); + } + + let escaped = xml_escape_attr(publisher); + let mut updated = text.to_owned(); + for tag in ["MainPackage", "MainBundle"] { + updated = update_attr_for_tags(&updated, tag, "Publisher", &escaped)?; + } + Ok(updated) +} + +fn sign_pkcs7_id_data( + content: &[u8], + cert: &Path, + key: &Path, + chain_certs: Vec, + digest: PortableSignDigest, +) -> Result> { + let (signer_cert, chain) = load_cms_signer_material(cert, chain_certs)?; + let key_bytes = std::fs::read(key).with_context(|| format!("read {}", key.display()))?; + let private_key = rdp::parse_rsa_private_key(&key_bytes) + .with_context(|| format!("parse RSA private key {}", key.display()))?; + let econtent_der = id_data_econtent_der(content)?; + let pkcs7 = pkcs7::create_pkcs7_signed_data_der_rsa( + pkcs7_id_data_oid()?, + &econtent_der, + digest.into(), + signer_cert, + chain, + private_key, + )?; + detach_pkcs7_econtent(&pkcs7) +} + +fn load_cms_signer_material( + cert: &Path, + chain_certs: Vec, +) -> Result<(x509_cert::Certificate, Vec)> { + let cert_bytes = std::fs::read(cert).with_context(|| format!("read {}", cert.display()))?; + let signer_cert = rdp::parse_certificate(&cert_bytes) + .with_context(|| format!("parse signer certificate {}", cert.display()))?; + let mut chain = Vec::with_capacity(chain_certs.len()); + for chain_cert in chain_certs { + let bytes = + std::fs::read(&chain_cert).with_context(|| format!("read {}", chain_cert.display()))?; + chain.push( + rdp::parse_certificate(&bytes) + .with_context(|| format!("parse chain certificate {}", chain_cert.display()))?, + ); + } + Ok((signer_cert, chain)) +} + +fn id_data_econtent_der(content: &[u8]) -> Result> { + OctetString::new(content.to_vec()) + .map_err(|e| anyhow!("encode CMS id-data OCTET STRING: {e}"))? + .to_der() + .map_err(|e| anyhow!("encode CMS id-data DER: {e}")) +} + +fn pkcs7_id_data_oid() -> Result { + ObjectIdentifier::new(pkcs7::PKCS7_ID_DATA_OID) + .map_err(|e| anyhow!("parse CMS id-data OID: {e}")) +} + +fn pkcs7_id_data_remote_prehash(content: &[u8], digest: PortableSignDigest) -> Result> { + let econtent_der = id_data_econtent_der(content)?; + pkcs7::pkcs7_remote_rsa_signed_attrs_digest(pkcs7_id_data_oid()?, &econtent_der, digest.into()) +} + +fn sign_pkcs7_id_data_with_external_signature( + content: &[u8], + cert: &Path, + chain_certs: Vec, + digest: PortableSignDigest, + signature: &[u8], +) -> Result> { + let (signer_cert, chain) = load_cms_signer_material(cert, chain_certs)?; + let econtent_der = id_data_econtent_der(content)?; + pkcs7::create_pkcs7_signed_data_der_with_rsa_signature( + pkcs7_id_data_oid()?, + &econtent_der, + digest.into(), + signer_cert, + chain, + signature, + true, + ) +} + +fn detach_pkcs7_econtent(pkcs7_der: &[u8]) -> Result> { + let mut detached = pkcs7::parse_pkcs7_signed_data_der(pkcs7_der) + .context("parse generated CMS before detaching eContent")?; + detached.encap_content_info.econtent = None; + pkcs7::encode_pkcs7_content_info_signed_data_der(&detached) +} + +fn update_attr_for_tags(text: &str, tag: &str, attr: &str, escaped_value: &str) -> Result { + let mut out = String::with_capacity(text.len()); + let mut cursor = 0usize; + let needle = format!("<{tag}"); + while let Some(rel_start) = text[cursor..].find(&needle) { + let start = cursor + rel_start; + let end = text[start..] + .find('>') + .map(|offset| start + offset) + .ok_or_else(|| anyhow!("App Installer <{tag}> tag is not closed"))?; + out.push_str(&text[cursor..start]); + out.push_str(&replace_or_insert_xml_attr( + &text[start..=end], + attr, + escaped_value, + )?); + cursor = end + 1; } + out.push_str(&text[cursor..]); Ok(out) } -fn print_trust_ok(prefix: &str, report: &TrustVerifyPeReport) { - println!( - "{prefix}: ok — verified {} PKCS#7 entr(y/ies); {} anchor thumbprint(s)", - report.pkcs7_entries_verified, report.anchor_thumbprints - ); +fn replace_or_insert_xml_attr(tag: &str, attr: &str, escaped_value: &str) -> Result { + let needle = format!("{attr}=\""); + if let Some(value_start) = tag.find(&needle).map(|idx| idx + needle.len()) { + let value_end = tag[value_start..] + .find('"') + .map(|offset| value_start + offset) + .ok_or_else(|| anyhow!("App Installer {attr} attribute is not closed"))?; + let mut out = String::with_capacity(tag.len() + escaped_value.len()); + out.push_str(&tag[..value_start]); + out.push_str(escaped_value); + out.push_str(&tag[value_end..]); + return Ok(out); + } + + let insert_at = tag + .rfind("/>") + .or_else(|| tag.rfind('>')) + .ok_or_else(|| anyhow!("App Installer tag is not closed"))?; + let mut out = String::with_capacity(tag.len() + attr.len() + escaped_value.len() + 4); + out.push_str(&tag[..insert_at]); + out.push(' '); + out.push_str(attr); + out.push_str("=\""); + out.push_str(escaped_value); + out.push('"'); + out.push_str(&tag[insert_at..]); + Ok(out) } -#[derive(Subcommand)] +fn xml_escape_attr(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} + +fn first_tag<'a>(text: &'a str, tag: &str) -> Option<&'a str> { + let start = text.find(&format!("<{tag}"))?; + let end = text[start..].find('>').map(|offset| start + offset)?; + Some(&text[start..=end]) +} + +fn xml_attr(tag: &str, name: &str) -> Option { + let needle = format!("{name}=\""); + let start = tag.find(&needle)? + needle.len(); + let end = tag[start..].find('"')? + start; + Some(tag[start..end].to_owned()) +} + +fn run_rfc3161_timestamp_resp_inspect( + path: &Path, + expect_digest_hex: Option<&str>, + expect_nonce: Option, +) -> Result<()> { + let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?; + let expected_digest = expect_digest_hex + .map(normalize_even_hex) + .transpose() + .context("parse --expect-digest-hex")?; + let expected_nonce = expect_nonce.map(rfc3161_nonce_hex); + let p = parse_time_stamp_resp_der(&bytes).ok_or_else(|| { + anyhow!("could not parse TimeStampResp DER (definite ASN.1 subset or trailing garbage)") + })?; + let tok_len = p.time_stamp_token.map(|t| t.len()).unwrap_or(0); + println!( + "pki_status={} pki_status_int={} granted={} time_stamp_token_len={}", + pki_status_label(p.pki_status), + p.pki_status.as_raw_integer(), + if p.pki_status.granted() { "yes" } else { "no" }, + tok_len + ); + println!( + "time_stamp_token_prefix_hex={}", + time_stamp_token_prefix_hex(p.time_stamp_token) + ); + println!( + "status_strings_json={}", + serde_json::to_string(&p.status_strings).context("encode PKIStatusInfo.statusString")? + ); + match p.fail_info_tlv { + Some(fi) => println!("fail_info_tlv_hex={}", hex_lower(fi)), + None => println!("fail_info_tlv_hex=-"), + } + let flags_json = match p.fail_info_tlv { + None => serde_json::Value::Array(vec![]), + Some(fi) => match pkifailure_info_flag_labels_from_bit_string_tlv(fi) { + Some(labels) => serde_json::to_value(&labels).context("encode failInfo flags")?, + None => serde_json::Value::Null, + }, + }; + println!("fail_info_flags_json={flags_json}"); + if let Some(tst) = p.time_stamp_token.and_then(parse_time_stamp_token_tst_info) { + println!("tst_info_present=yes"); + println!("tst_info_policy_oid={}", tst.policy_oid); + println!( + "tst_info_message_imprint_digest_alg_oid={}", + tst.message_imprint_digest_alg_oid + ); + println!( + "tst_info_message_imprint_hashed_message_hex={}", + hex_lower(&tst.message_imprint_hashed_message) + ); + println!("tst_info_serial_hex={}", tst.serial_number_hex); + println!("tst_info_gen_time={}", tst.gen_time); + println!( + "tst_info_nonce_hex={}", + tst.nonce_hex.as_deref().unwrap_or("-") + ); + if let Some(expected) = expected_digest.as_deref() { + println!( + "tst_info_message_imprint_match={}", + if hex_lower(&tst.message_imprint_hashed_message) == expected { + "yes" + } else { + "no" + } + ); + } + if let Some(expected) = expected_nonce.as_deref() { + println!( + "tst_info_nonce_match={}", + if tst.nonce_hex.as_deref() == Some(expected) { + "yes" + } else { + "no" + } + ); + } + } else { + println!("tst_info_present=no"); + if expected_digest.is_some() { + println!("tst_info_message_imprint_match=no"); + } + if expected_nonce.is_some() { + println!("tst_info_nonce_match=no"); + } + } + Ok(()) +} + +#[cfg(feature = "timestamp-http")] +fn run_rfc3161_timestamp_http_post( + url: String, + algorithm: HashAlg, + digest_file: Option, + digest_hex: Option, + nonce: Option, + cert_req: bool, + output: Option, +) -> Result<()> { + use std::io::Write; + let preimage = + load_timestamp_imprint_preimage(digest_hex.as_ref(), digest_file.as_ref(), algorithm)?; + let plan = Rfc3161TimestampRequestPlan { + digest_alg_oid: hash_alg_timestamp_oid(algorithm), + nonce, + cert_req, + }; + let der = build_timestamp_request_bytes(&plan, &preimage).ok_or_else(|| { + anyhow!("unsupported digest OID / preimage length for RFC3161 TimeStampReq") + })?; + let client = reqwest::blocking::Client::builder() + .use_rustls_tls() + .timeout(std::time::Duration::from_secs(120)) + .build() + .context("build HTTP client (timestamp-http feature)")?; + let resp = client + .post(url.trim()) + .header("Content-Type", "application/timestamp-query") + .header( + "Accept", + "application/timestamp-reply, application/timestamp-response", + ) + .body(der) + .send() + .with_context(|| format!("POST TimeStampReq to {}", url.trim()))?; + let status = resp.status(); + let body = resp.bytes().context("read TSA response body")?; + if !status.is_success() { + return Err(anyhow!( + "TSA HTTP {} — first {} body bytes (hex): {}", + status, + body.len().min(256), + hex_lower(&body[..body.len().min(256)]) + )); + } + match output.as_ref() { + Some(p) => std::fs::write(p, &body).with_context(|| format!("write {}", p.display()))?, + None => std::io::stdout() + .write_all(&body) + .context("write TimeStampResp DER to stdout")?, + } + Ok(()) +} + +#[cfg(feature = "timestamp-http")] +fn post_rfc3161_timestamp_request( + url: &str, + algorithm: HashAlg, + message_imprint: &[u8], +) -> Result> { + if message_imprint.len() != digest_byte_len_for_hash_alg(algorithm) { + return Err(anyhow!( + "timestamp message imprint must be exactly {} bytes for {:?}, got {}", + digest_byte_len_for_hash_alg(algorithm), + algorithm, + message_imprint.len() + )); + } + let plan = Rfc3161TimestampRequestPlan { + digest_alg_oid: hash_alg_timestamp_oid(algorithm), + nonce: None, + cert_req: true, + }; + let der = build_timestamp_request_bytes(&plan, message_imprint).ok_or_else(|| { + anyhow!("unsupported digest OID / preimage length for RFC3161 TimeStampReq") + })?; + let client = reqwest::blocking::Client::builder() + .use_rustls_tls() + .timeout(std::time::Duration::from_secs(120)) + .build() + .context("build HTTP client (timestamp-http feature)")?; + let resp = client + .post(url.trim()) + .header("Content-Type", "application/timestamp-query") + .header( + "Accept", + "application/timestamp-reply, application/timestamp-response", + ) + .body(der) + .send() + .with_context(|| format!("POST TimeStampReq to {}", url.trim()))?; + let status = resp.status(); + let body = resp.bytes().context("read TSA response body")?; + if !status.is_success() { + return Err(anyhow!( + "TSA HTTP {} — first {} body bytes (hex): {}", + status, + body.len().min(256), + hex_lower(&body[..body.len().min(256)]) + )); + } + Ok(body.to_vec()) +} + +#[cfg(feature = "timestamp-http")] +fn timestamp_pkcs7_der_rfc3161( + pkcs7_der: &[u8], + timestamp_url: &str, + timestamp_digest: HashAlg, +) -> Result> { + let sd = pkcs7::parse_pkcs7_signed_data_der(pkcs7_der).context("parse PKCS#7 SignedData")?; + let signer = sd + .signer_infos + .0 + .as_slice() + .first() + .ok_or_else(|| anyhow!("PKCS#7 SignedData has no SignerInfo to timestamp"))?; + let imprint = digest_bytes_for_hash_alg(timestamp_digest, signer.signature.as_bytes()); + let response = post_rfc3161_timestamp_request(timestamp_url, timestamp_digest, &imprint)?; + let parsed = parse_time_stamp_resp_der(&response) + .ok_or_else(|| anyhow!("could not parse TimeStampResp DER from TSA response"))?; + if !parsed.pki_status.granted() { + return Err(anyhow!( + "TimeStampResp status is not granted (status={})", + parsed.pki_status.as_raw_integer() + )); + } + let token = parsed + .time_stamp_token + .ok_or_else(|| anyhow!("TimeStampResp has no timeStampToken"))?; + let stamped = pkcs7::signed_data_add_rfc3161_timestamp_token(&sd, 0, token) + .context("attach RFC3161 timestamp token")?; + pkcs7::encode_pkcs7_content_info_signed_data_der(&stamped) +} + +fn timestamp_pkcs7_if_requested( + pkcs7_der: &[u8], + timestamp_url: Option, + timestamp_digest: Option, + context: &str, +) -> Result> { + match (timestamp_url, timestamp_digest) { + (Some(url), Some(timestamp_digest)) => { + #[cfg(feature = "timestamp-http")] + { + timestamp_pkcs7_der_rfc3161(pkcs7_der, &url, timestamp_digest) + .with_context(|| format!("RFC3161 timestamp {context}")) + } + #[cfg(not(feature = "timestamp-http"))] + { + let _ = (url, timestamp_digest); + Err(anyhow!( + "{context} RFC3161 timestamping requires the timestamp-http feature" + )) + } + } + (Some(_), None) => Err(anyhow!("{context} requires --timestamp-digest with --timestamp-url")), + (None, Some(_)) => Err(anyhow!("{context} requires --timestamp-url with --timestamp-digest")), + (None, None) => Ok(pkcs7_der.to_vec()), + } +} + +fn parse_sha256_hex(s: &str) -> Result<[u8; 32]> { + let hex = s.trim().strip_prefix("0x").unwrap_or(s.trim()); + let hex = hex.strip_prefix("0X").unwrap_or(hex); + if hex.len() != 64 { + return Err(anyhow!( + "expect 64 hex chars for SHA-256, got {}", + hex.len() + )); + } + let mut out = [0u8; 32]; + for i in 0..32 { + let byte = + u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).map_err(|_| anyhow!("invalid hex"))?; + out[i] = byte; + } + Ok(out) +} + +fn print_trust_ok(prefix: &str, report: &TrustVerifyPeReport) { + println!( + "{prefix}: ok — verified {} PKCS#7 entr(y/ies); {} anchor thumbprint(s)", + report.pkcs7_entries_verified, report.anchor_thumbprints + ); +} + +#[derive(Subcommand)] enum Command { /// Print lowercase hex of the PE/WinMD **Authenticode image digest** (unsigned PE is OK). PeDigest { path: PathBuf, - #[arg(long, value_enum, default_value_t = HashAlg::Sha256)] - algorithm: HashAlg, - /// **`hex`** (default): one lowercase hex line. **`raw`**: raw digest bytes (e.g. for **`artifact-signing-submit`** `--digest-file`). + #[arg(long, value_enum, default_value_t = HashAlg::Sha256)] + algorithm: HashAlg, + /// **`hex`** (default): one lowercase hex line. **`raw`**: raw digest bytes (e.g. for **`artifact-signing-submit`** `--digest-file`). + #[arg(long, value_enum, default_value_t = DigestEncoding::Hex)] + encoding: DigestEncoding, + /// Write output here instead of stdout. + #[arg(long, value_name = "PATH")] + output: Option, + }, + /// Compare PE **`Optional Header.CheckSum`** to **`pe_compute_image_checksum`** (Windows **`CheckSumMappedFile`** style). + /// + /// Prints one line each: **`stored=0x…`**, **`computed=0x…`**, **`match=yes|no`**, **`file_bytes=N`**. **`--strict`**: exit with failure when **`match=no`** (CI / parity gate). + PeChecksum { + path: PathBuf, + #[arg(long)] + strict: bool, + }, + /// Require embedded PKCS#7; compare indirect digest to Rust PE recomputation for each Authenticode cert. + VerifyPe { path: PathBuf }, + /// Verify PE Authenticode **trust**: PKCS#7 CMS validation + certificate chain to **explicit** anchors (no OS store). + /// + /// Supply **`--anchor-dir`** (Phase A: `.crt`/`.cer`/`.pem`) and/or **`--authroot-cab`** (extract certs + CTL thumbs from AuthRoot-style CAB `.stl` payloads). **`verify-pe`** remains digest-only; this subcommand adds chain + policy checks. + TrustVerifyPe { + path: PathBuf, + #[command(flatten)] + shared: TrustVerifySharedArgs, + }, + /// Same trust pipeline as **`trust-verify-pe`** after CAB SIP digest consistency (**`verify-cab`**). + TrustVerifyCab { + path: PathBuf, + #[command(flatten)] + shared: TrustVerifySharedArgs, + }, + /// Same trust pipeline as **`trust-verify-pe`** after MSI/MSP SIP digest consistency (**`verify-msi`**). + TrustVerifyMsi { + path: PathBuf, + #[command(flatten)] + shared: TrustVerifySharedArgs, + }, + /// Same trust pipeline as **`trust-verify-pe`** after WIM/ESD SIP digest consistency (**`verify-esd`**). + TrustVerifyEsd { + path: PathBuf, + #[command(flatten)] + shared: TrustVerifySharedArgs, + }, + /// CMS catalog digest consistency (**`verify-catalog`**) plus PKCS#7 chain to anchors when Authenticode-wrapped. + TrustVerifyCatalog { + path: PathBuf, + #[command(flatten)] + shared: TrustVerifySharedArgs, + }, + /// Detached PKCS#7 vs raw **`content`** bytes (digest inferred from PKCS#7 indirect length); PKCS#7 blob normalized like Win32 `CryptVerifyDetachedMessageSignature` helpers. + TrustVerifyDetached { + content: PathBuf, + signature: PathBuf, + #[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). + PeHasPageHashes { path: PathBuf }, + /// Print structured **`SPC_PE_IMAGE_PAGE_HASHES`** rows from CMS **signed** attributes (one line per signer location). + /// + /// Includes **`parsed_page_hash_pairs`** when DER peeling + flat-table parsing succeeds (`-` otherwise). + /// Empty stdout means no matching authenticated attributes were found. Does **not** validate pages vs file bytes. + PePageHashInfo { path: PathBuf }, + /// **Experimental:** parse embedded page-hash tables and verify **contiguous raw file ranges** (see `psign_sip_digest::page_hashes::verify_pe_embedded_page_hash_tables`). + /// + /// Not a full `WinVerifyTrust` `/ph` clone — checksum / cert-directory exclusions may differ from native. + VerifyPePageHashes { path: PathBuf }, + /// Print ordered **[`start`,`end`)** file byte ranges included in **PE Authenticode image digest** (same layout as `authenticode-rs` / `pe_authenticode_digest`). + /// + /// One line per range: `start=N end=M` (half-open end). Useful on Linux for tooling / future page-hash alignment vs `WinTrust`. + PeAuthenticodeRanges { path: PathBuf }, + /// Decode **`SpcIndirectDataContent`** from an embedded Authenticode PKCS#7 (**JSON** to stdout; certificate-table order; default **`--index`** **`0`**). + /// + /// Intended for Linux-side inspection and PKCS#7 rebuild experiments (Rust **`pkcs7`** module in **`psign-sip-digest`**); does **not** sign or embed signatures. + InspectPeSpcIndirect { + path: PathBuf, + /// **`WIN_CERT_TYPE_PKCS_SIGNED_DATA`** row index (**`0`** = first; same order as **`extract-pe-pkcs7`** / **`list-pe-pkcs7`**). + #[arg(long, default_value_t = 0)] + index: usize, + /// Include lowercase hex of **`image_data.value`** DER (**`SpcPeImageData`**) — output can be large. + #[arg(long)] + include_image_value_der_hex: bool, + }, + /// Write an embedded Authenticode PKCS#7 (**raw DER**) to stdout or **`--output`** (certificate-table order; default **`--index`** **`0`**). + ExtractPePkcs7 { + path: PathBuf, + /// **`WIN_CERT_TYPE_PKCS_SIGNED_DATA`** row index (**`0`** = first). + #[arg(long, default_value_t = 0)] + index: usize, + #[arg(long, value_name = "PATH")] + output: Option, + }, + /// List **`WIN_CERT_TYPE_PKCS_SIGNED_DATA`** PKCS#7 rows in the PE certificate table (**`pkcs7_entries=N`** then **`index=i byte_len=L`** per line). + ListPePkcs7 { path: PathBuf }, + /// **SHA-256** (**32** octets) over a signer’s authenticated-attribute **`SET OF Attribute`** DER (**RFC 5652** §5.4). + /// + /// Same raw digest Azure Key Vault **`keys/sign`** expects for **`RS256`** (base64 **`value`** in JSON) when re-signing **CMS `SignerInfo`** on **RSA SHA-256** Authenticode. Differs from **`pe-digest`** (PE **image** hash). Requires **`SignerInfo.digestAlgorithm`** **SHA-256** and **`signedAttrs`**. **`--index`**: **`WIN_CERT_TYPE_PKCS_SIGNED_DATA`** row (**`0`** = first). **`--signer-index`**: **`SignerInfo`** within that PKCS#7’s **`SignedData`** (**`0`** = first; same as **`pkcs7-signer-rs256-prehash --signer-index`** after **`extract-pe-pkcs7`**). + PeSignerRs256Prehash { + path: PathBuf, + #[arg(long, default_value_t = 0)] + index: usize, + #[arg(long, default_value_t = 0)] + signer_index: usize, + #[arg(long, value_enum, default_value_t = DigestEncoding::Hex)] + encoding: DigestEncoding, + #[arg(long, value_name = "PATH")] + output: Option, + }, + /// Same digest as **`pe-signer-rs256-prehash`**, but **`path`** is **PKCS#7** DER (**`ContentInfo`** wrapping **`SignedData`**, or bare **`SignedData`** normalized like **`extract-pe-pkcs7`** output). + /// + /// **`--signer-index`**: **`SignerInfo`** within this **`SignedData`** (**`0`** = first). For PE workflows, extract PKCS#7 first (**`extract-pe-pkcs7`**) then run this command. + Pkcs7SignerRs256Prehash { + path: PathBuf, + #[arg(long, default_value_t = 0)] + signer_index: usize, + #[arg(long, value_enum, default_value_t = DigestEncoding::Hex)] + encoding: DigestEncoding, + #[arg(long, value_name = "PATH")] + output: Option, + }, + /// **Experimental:** Append raw PKCS#7 (**`SignedData`**) DER as a new **`WIN_CERTIFICATE`** row (**`pe_embed`**). + /// + /// Updates the PE security directory and recomputes **`Optional Header.CheckSum`** (**`pe_compute_image_checksum`**). Does **not** validate PKCS#7 ↔ image digest or replace **`SignerSignEx3`**. For hybrid tooling and future portable sign pipelines. + AppendPePkcs7 { + /// Input PE path (**read fully** before writing **`--output`**; same path allowed). + #[arg(long = "pe", value_name = "PATH")] + pe_path: PathBuf, + /// PKCS#7 DER file (**bare `SignedData`** is normalized like other portable PKCS#7 paths). + #[arg(long = "pkcs7", value_name = "PATH")] + pkcs7_path: PathBuf, + #[arg(long, value_name = "PATH")] + output: PathBuf, + }, + /// Sign an unsigned PE image with portable Authenticode CMS + `WIN_CERTIFICATE` embedding. + /// + /// This is the first production-oriented portable Authenticode signing path. It supports local RSA + /// PKCS#1 v1.5 keys or Azure Key Vault RSA signing and SHA-2 digests; timestamp embedding and + /// non-PE formats remain separate backlog. + SignPe { + /// Input PE path. + #[arg(value_name = "PATH")] + path: PathBuf, + /// Signer certificate as DER or PEM. + #[arg(long, value_name = "PATH")] + cert: Option, + /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. + #[arg(long, value_name = "PATH")] + key: Option, + /// Additional certificate to include in the PKCS#7 certificate set. + #[arg(long = "chain-cert", value_name = "PATH")] + chain_certs: Vec, + /// File digest algorithm for the PE Authenticode indirect digest and CMS signer. + #[arg(long, value_enum, default_value_t = PortableSignDigest::Sha256)] + digest: PortableSignDigest, + /// RFC3161 timestamp URL to timestamp the primary PE signature after signing. + #[arg(long = "timestamp-url", visible_alias = "tr")] + timestamp_url: Option, + /// RFC3161 timestamp digest algorithm. + #[arg(long = "timestamp-digest", visible_alias = "td", value_enum)] + timestamp_digest: Option, + /// Azure Key Vault URL for remote RSA signing. + #[arg(long = "azure-key-vault-url", visible_alias = "kvu")] + azure_key_vault_url: Option, + /// Azure Key Vault certificate name for remote RSA signing. + #[arg(long = "azure-key-vault-certificate", visible_alias = "kvc")] + azure_key_vault_certificate: Option, + /// Optional Azure Key Vault certificate version. + #[arg(long = "azure-key-vault-certificate-version", visible_alias = "kvcv")] + azure_key_vault_certificate_version: Option, + #[arg(long = "azure-key-vault-accesstoken")] + azure_key_vault_access_token: Option, + #[arg(long = "azure-key-vault-managed-identity")] + azure_key_vault_managed_identity: bool, + #[arg(long = "azure-key-vault-tenant-id")] + azure_key_vault_tenant_id: Option, + #[arg(long = "azure-key-vault-client-id")] + azure_key_vault_client_id: Option, + #[arg(long = "azure-key-vault-client-secret")] + azure_key_vault_client_secret: Option, + #[arg(long = "azure-authority")] + azure_authority: Option, + #[command(flatten)] + artifact_signing: Box, + /// Output signed PE path. + #[arg(long, value_name = "PATH")] + output: PathBuf, + }, + /// Sign an unsigned CAB file with portable Authenticode CMS and CAB reserve-header embedding. + /// + /// Supports single-volume unsigned CABs without an existing reserve header. + SignCab { + /// Input CAB path. + #[arg(value_name = "PATH")] + path: PathBuf, + /// Signer certificate as DER or PEM. + #[arg(long, value_name = "PATH")] + cert: PathBuf, + /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. + #[arg(long, value_name = "PATH")] + key: PathBuf, + /// Additional certificate to include in the PKCS#7 certificate set. + #[arg(long = "chain-cert", value_name = "PATH")] + chain_certs: Vec, + /// File digest algorithm for the CAB Authenticode indirect digest and CMS signer. + #[arg(long, value_enum, default_value_t = PortableSignDigest::Sha256)] + digest: PortableSignDigest, + /// Output signed CAB path. + #[arg(long, value_name = "PATH")] + output: PathBuf, + }, + /// Sign an MSI/MSP OLE package with portable Authenticode CMS and a DigitalSignature stream. + SignMsi { + /// Input MSI/MSP path. + #[arg(value_name = "PATH")] + path: PathBuf, + /// Signer certificate as DER or PEM. + #[arg(long, value_name = "PATH")] + cert: PathBuf, + /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. + #[arg(long, value_name = "PATH")] + key: PathBuf, + /// Additional certificate to include in the PKCS#7 certificate set. + #[arg(long = "chain-cert", value_name = "PATH")] + chain_certs: Vec, + /// File digest algorithm for the MSI Authenticode indirect digest and CMS signer. + #[arg(long, value_enum, default_value_t = PortableSignDigest::Sha256)] + digest: PortableSignDigest, + /// Output signed MSI/MSP path. + #[arg(long, value_name = "PATH")] + output: PathBuf, + }, + /// Sign a portable generic file catalog (`.cat`) with CTL members and CMS `SignedData`. + /// + /// This authors explicit file membership entries for the provided subjects. It does not implement + /// driver/INF policy, OS catalog database installation/search, or MakeCat byte-for-byte output. + SignCatalog { + /// Subject file(s) to include as catalog members. + #[arg(required = true, value_name = "PATH")] + files: Vec, + /// Signer certificate as DER or PEM. + #[arg(long, value_name = "PATH")] + cert: PathBuf, + /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. + #[arg(long, value_name = "PATH")] + key: PathBuf, + /// Additional certificate to include in the PKCS#7 certificate set. + #[arg(long = "chain-cert", value_name = "PATH")] + chain_certs: Vec, + /// File digest algorithm for catalog member digests and CMS signer. + #[arg(long, value_enum, default_value_t = PortableSignDigest::Sha256)] + digest: PortableSignDigest, + /// Output signed catalog path. + #[arg(long, value_name = "PATH")] + output: PathBuf, + }, + /// Attach an RFC3161 timestamp token to an existing embedded PE Authenticode signature. + /// + /// Accepts either a raw `timeStampToken` `ContentInfo` DER file or a `TimeStampResp` DER file containing one. + TimestampPeRfc3161 { + /// Signed PE path to mutate. + #[arg(value_name = "PATH")] + path: PathBuf, + /// Embedded PKCS#7 row index in the PE certificate table. + #[arg(long, default_value_t = 0)] + index: usize, + /// SignerInfo index inside the selected PKCS#7 SignedData. + #[arg(long, default_value_t = 0)] + signer_index: usize, + /// Raw RFC3161 timeStampToken ContentInfo DER. + #[arg(long, value_name = "PATH", conflicts_with = "response")] + token: Option, + /// RFC3161 TimeStampResp DER containing a granted timeStampToken. + #[arg(long, value_name = "PATH", conflicts_with = "token")] + response: Option, + /// Output PE path. + #[arg(long, value_name = "PATH")] + output: PathBuf, + }, + /// Sign `.rdp` files using portable RDP SignScope/SecureSettingsBlob logic. + /// + /// Supply either **`--cert`** + **`--key`** for local RSA/SHA-256 PKCS#7 creation, or + /// **`--signature-pkcs7`** to embed an externally-created detached PKCS#7 for a single input. + Rdp { + /// Signer certificate as DER or PEM. + #[arg( + long, + value_name = "PATH", + requires = "key", + conflicts_with = "signature_pkcs7" + )] + cert: Option, + /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. + #[arg( + long, + value_name = "PATH", + requires = "cert", + conflicts_with = "signature_pkcs7" + )] + key: Option, + /// Additional certificate to include in the PKCS#7 certificate set. + #[arg( + long = "chain-cert", + value_name = "PATH", + requires = "cert", + conflicts_with = "signature_pkcs7" + )] + chain_certs: Vec, + /// Detached PKCS#7 DER to serialize into the RDP `Signature` record. + #[arg(long = "signature-pkcs7", value_name = "PATH", conflicts_with_all = ["cert", "key", "chain_certs"])] + signature_pkcs7: Option, + /// Build and validate the signed shape without writing files. + #[arg(long)] + dry_run: bool, + /// Write output here instead of overwriting the input. Only valid with one input file. + #[arg(long, value_name = "PATH")] + output: Option, + /// `.rdp` file(s) to sign. + #[arg(required = true)] + files: Vec, + }, + /// Write embedded Authenticode PKCS#7 (**raw DER**) from a signed **`.cab`** tail to stdout or **`--output`**. + /// + /// Layout: **`cab_digest::cab_signature_pkcs7_der`** (same bytes you would pass to **`pkcs7-signer-rs256-prehash`**). + ExtractCabPkcs7 { + path: PathBuf, + #[arg(long, value_name = "PATH")] + output: Option, + }, + /// Same digest as **`pkcs7-signer-rs256-prehash`** on PKCS#7 embedded at the end of a signed **`.cab`** (after **`extract-cab-pkcs7`**). + /// + /// **`--signer-index`**: **`SignerInfo`** within that **`SignedData`**. For AzureSignTool-style **KV `RS256`**, use **`--encoding raw`** (distinct from **`cab-digest`** MSCF subject hash). + CabSignerRs256Prehash { + path: PathBuf, + #[arg(long, default_value_t = 0)] + signer_index: usize, + #[arg(long, value_enum, default_value_t = DigestEncoding::Hex)] + encoding: DigestEncoding, + #[arg(long, value_name = "PATH")] + output: Option, + }, + /// 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. + ExtractMsiPkcs7 { + path: PathBuf, + #[arg(long, value_name = "PATH")] + output: Option, + }, + /// Same digest as **`pkcs7-signer-rs256-prehash`** on PKCS#7 from **`\\u{5}DigitalSignature`** (after **`extract-msi-pkcs7`**). + /// + /// **`--signer-index`**: **`SignerInfo`** within **`SignedData`**. **`--encoding raw`** for Azure KV **`RS256`** (distinct from MSI SIP fingerprint / **`verify-msi`** subject hash). + MsiSignerRs256Prehash { + path: PathBuf, + #[arg(long, default_value_t = 0)] + signer_index: usize, #[arg(long, value_enum, default_value_t = DigestEncoding::Hex)] encoding: DigestEncoding, - /// Write output here instead of stdout. #[arg(long, value_name = "PATH")] output: Option, }, - /// Compare PE **`Optional Header.CheckSum`** to **`pe_compute_image_checksum`** (Windows **`CheckSumMappedFile`** style). - /// - /// Prints one line each: **`stored=0x…`**, **`computed=0x…`**, **`match=yes|no`**, **`file_bytes=N`**. **`--strict`**: exit with failure when **`match=no`** (CI / parity gate). - PeChecksum { + /// Signed MSI: compare PKCS#7 indirect digest to Rust OLE fingerprint (and extended stream if present). + VerifyMsi { path: PathBuf }, + /// Signed WIM/ESD: compare PKCS#7 indirect digest to Rust prefix hash. + VerifyEsd { path: PathBuf }, + /// Cleartext MSIX/APPX/bundle: compare PKCS#7 indirect digest to Rust ZIP rehash (encrypted extensions rejected). + VerifyMsix { path: PathBuf }, + /// Inspect cleartext MSIX/AppX `AppxManifest.xml` Identity metadata. + MsixManifestInfo { path: PathBuf }, + /// Update cleartext MSIX/AppX `AppxManifest.xml` Identity Publisher before final signing. + MsixSetPublisher { path: PathBuf, - #[arg(long)] - strict: bool, + #[arg(long, value_name = "SUBJECT")] + publisher: String, + #[arg(long, value_name = "PATH")] + output: PathBuf, }, - /// Require embedded PKCS#7; compare indirect digest to Rust PE recomputation for each Authenticode cert. - VerifyPe { path: PathBuf }, - /// Verify PE Authenticode **trust**: PKCS#7 CMS validation + certificate chain to **explicit** anchors (no OS store). - /// - /// Supply **`--anchor-dir`** (Phase A: `.crt`/`.cer`/`.pem`) and/or **`--authroot-cab`** (extract certs + CTL thumbs from AuthRoot-style CAB `.stl` payloads). **`verify-pe`** remains digest-only; this subcommand adds chain + policy checks. - TrustVerifyPe { + /// Inspect a ClickOnce `.deploy` payload and report the undeployed content name. + ClickonceDeployInfo { path: PathBuf }, + /// Copy a ClickOnce `.deploy` payload to an explicit undeployed output path. + ClickonceCopyDeployPayload { path: PathBuf, - #[command(flatten)] - shared: TrustVerifySharedArgs, + #[arg(long, value_name = "PATH")] + output: PathBuf, }, - /// Same trust pipeline as **`trust-verify-pe`** after CAB SIP digest consistency (**`verify-cab`**). - TrustVerifyCab { + /// Verify file hashes recorded in a ClickOnce application or deployment manifest. + ClickonceManifestHashes { path: PathBuf, - #[command(flatten)] - shared: TrustVerifySharedArgs, + #[arg(long, value_name = "DIR")] + base_directory: Option, }, - /// Same trust pipeline as **`trust-verify-pe`** after MSI/MSP SIP digest consistency (**`verify-msi`**). - TrustVerifyMsi { + /// Update file size and digest values in a ClickOnce application or deployment manifest. + ClickonceUpdateManifestHashes { path: PathBuf, - #[command(flatten)] - shared: TrustVerifySharedArgs, + #[arg(long, value_name = "DIR")] + base_directory: Option, + #[arg(long, value_enum, default_value_t = HashAlg::Sha256)] + algorithm: HashAlg, + #[arg(long, value_name = "PATH")] + output: PathBuf, }, - /// Same trust pipeline as **`trust-verify-pe`** after WIM/ESD SIP digest consistency (**`verify-esd`**). - TrustVerifyEsd { + /// Add a deterministic portable XMLDSig signature to a ClickOnce manifest. + /// + /// This is a portable structural helper for tests and non-Mage workflows; it does not claim + /// byte-for-byte Mage output or ClickOnce policy validation. + ClickonceSignManifest { path: PathBuf, - #[command(flatten)] - shared: TrustVerifySharedArgs, + #[arg(long, value_enum, default_value_t = PortableSignDigest::Sha256)] + digest: PortableSignDigest, + /// Signer certificate as DER or PEM. + #[arg(long, value_name = "PATH")] + cert: PathBuf, + /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. + #[arg(long, value_name = "PATH")] + key: PathBuf, + #[arg(long, value_name = "PATH")] + output: PathBuf, }, - /// CMS catalog digest consistency (**`verify-catalog`**) plus PKCS#7 chain to anchors when Authenticode-wrapped. - TrustVerifyCatalog { + /// Compute the SignedInfo digest for externally signing ClickOnce manifest XMLDSig. + ClickonceSignManifestPrehash { path: PathBuf, - #[command(flatten)] - shared: TrustVerifySharedArgs, + #[arg(long, value_enum, default_value_t = PortableSignDigest::Sha256)] + digest: PortableSignDigest, + #[arg(long, value_enum, default_value_t = DigestEncoding::Hex)] + encoding: DigestEncoding, + #[arg(long, value_name = "PATH")] + output: Option, }, - /// Detached PKCS#7 vs raw **`content`** bytes (digest inferred from PKCS#7 indirect length); PKCS#7 blob normalized like Win32 `CryptVerifyDetachedMessageSignature` helpers. - TrustVerifyDetached { - content: PathBuf, + /// Add a deterministic ClickOnce manifest XMLDSig from externally produced RSA signature bytes. + ClickonceSignManifestFromSignature { + path: PathBuf, + #[arg(long, value_enum, default_value_t = PortableSignDigest::Sha256)] + digest: PortableSignDigest, + /// Signer certificate as DER or PEM. + #[arg(long, value_name = "PATH")] + cert: PathBuf, + /// Raw RSA PKCS#1 v1.5 signature bytes produced over `clickonce-sign-manifest-prehash`. + #[arg(long, value_name = "PATH")] signature: PathBuf, - #[command(flatten)] - shared: TrustVerifySharedArgs, + #[arg(long, value_name = "PATH")] + output: PathBuf, }, - /// Custom ZIP Authenticode comment signature: verify ZIP digest binding plus PKCS#7 chain to anchors. - TrustVerifyZip { + /// Verify the deterministic portable XMLDSig signature in a ClickOnce manifest. + ClickonceVerifyManifestSignature { path: PathBuf, + /// Signer certificate as DER or PEM. Defaults to the embedded KeyInfo certificate. + #[arg(long, value_name = "PATH")] + cert: Option, + #[arg(long, value_enum, default_value_t = PortableSignDigest::Sha256)] + digest: PortableSignDigest, #[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). - PeHasPageHashes { path: PathBuf }, - /// Print structured **`SPC_PE_IMAGE_PAGE_HASHES`** rows from CMS **signed** attributes (one line per signer location). - /// - /// Includes **`parsed_page_hash_pairs`** when DER peeling + flat-table parsing succeeds (`-` otherwise). - /// Empty stdout means no matching authenticated attributes were found. Does **not** validate pages vs file bytes. - PePageHashInfo { path: PathBuf }, - /// **Experimental:** parse embedded page-hash tables and verify **contiguous raw file ranges** (see `psign_sip_digest::page_hashes::verify_pe_embedded_page_hash_tables`). - /// - /// Not a full `WinVerifyTrust` `/ph` clone — checksum / cert-directory exclusions may differ from native. - VerifyPePageHashes { path: PathBuf }, - /// Print ordered **[`start`,`end`)** file byte ranges included in **PE Authenticode image digest** (same layout as `authenticode-rs` / `pe_authenticode_digest`). - /// - /// One line per range: `start=N end=M` (half-open end). Useful on Linux for tooling / future page-hash alignment vs `WinTrust`. - PeAuthenticodeRanges { path: PathBuf }, - /// Decode **`SpcIndirectDataContent`** from an embedded Authenticode PKCS#7 (**JSON** to stdout; certificate-table order; default **`--index`** **`0`**). + /// Same digest as **`pkcs7-signer-rs256-prehash`** when **`path`** is raw PKCS#7 **`SignedData`** (typical **`.cat`** body — CTL or other CMS **`ContentInfo`**). /// - /// Intended for Linux-side inspection and PKCS#7 rebuild experiments (Rust **`pkcs7`** module in **`psign-sip-digest`**); does **not** sign or embed signatures. - InspectPeSpcIndirect { + /// For **KV `RS256`** over **`SignerInfo.signedAttrs`**, use **`--encoding raw`**. Does **not** run **`verify-catalog`** (CTL **`messageDigest`** vs **`eContent`** rules differ from Authenticode PE PKCS#7). + CatalogSignerRs256Prehash { path: PathBuf, - /// **`WIN_CERT_TYPE_PKCS_SIGNED_DATA`** row index (**`0`** = first; same order as **`extract-pe-pkcs7`** / **`list-pe-pkcs7`**). #[arg(long, default_value_t = 0)] - index: usize, - /// Include lowercase hex of **`image_data.value`** DER (**`SpcPeImageData`**) — output can be large. + signer_index: usize, + #[arg(long, value_enum, default_value_t = DigestEncoding::Hex)] + encoding: DigestEncoding, + #[arg(long, value_name = "PATH")] + output: Option, + }, + /// Signed catalog `.cat`: compare PKCS#7 indirect digest to Rust catalog digest scan. + VerifyCatalog { path: PathBuf }, + /// Verify that a subject file is represented by a MakeCat-style CTL member in a signed catalog. + VerifyCatalogMember { + /// Catalog `.cat` file. + #[arg(long, value_name = "PATH")] + catalog: PathBuf, + /// Subject file whose catalog membership should be checked. + #[arg(value_name = "PATH")] + subject: PathBuf, + }, + /// Script signed file (PowerShell-class or WSH): compare PKCS#7 indirect digest to Rust heuristic strip/hash. + VerifyScript { path: PathBuf }, + /// Inspect PKCS#7 layers: signers, timestamp-related attribute OIDs, nested signatures (`1.3.6.1.4.1.311.2.4.1`). JSON to stdout. + InspectAuthenticode { + path: PathBuf, + /// Treat **`path`** as a PE image (**embedded** attribute certs) vs raw PKCS#7 bytes. + #[arg(long, value_enum, default_value_t = InspectInputKind::Pe)] + input: InspectInputKind, + }, + /// Validate JSON metadata shape for Microsoft Artifact Signing (`Endpoint`, `CodeSigningAccountName`, `CertificateProfileName`; optional `ExcludeCredentials` string array). No network / no signing. + /// + /// Reads **`--path`** or stdin when omitted (use `-` for stdin explicitly). + ArtifactSigningMetadataCheck { + #[arg(long, value_name = "PATH")] + path: Option, + }, + /// Azure Code Signing **`…:sign`** LRO (same REST contract as **`psign-tool artifact-signing-submit`**). Requires **`--features artifact-signing-rest`** at build time. + #[cfg(feature = "artifact-signing-rest")] + ArtifactSigningSubmit { + #[command(flatten)] + args: ArtifactSigningSubmitPortableArgs, + }, + /// Azure Key Vault **`keys/sign`** over a **precomputed digest file** (RSA PKCS#1 or ECDSA). Requires **`--features azure-kv-sign-portable`**. Does **not** embed Authenticode — use **`psign-tool`** for that. + #[cfg(feature = "azure-kv-sign-portable")] + AzureKeyVaultSignDigest { + #[command(flatten)] + args: AzureKvSignDigestPortableArgs, + }, + /// Build **RFC 3161** **`TimeStampReq`** DER from a **message imprint** preimage (raw digest bytes for **`MessageImprint.hashedMessage`** — not a second hash). For **`curl`** / OpenSSL **`ts -query`** against a TSA (**`application/timestamp-query`**). + /// + /// Supply exactly one of **`--digest-hex`** or **`--digest-file`**. Does **not** POST to a TSA. + Rfc3161TimestampReq { + #[arg(long, value_enum, default_value_t = HashAlg::Sha256)] + algorithm: HashAlg, + /// Raw digest bytes; length must match **`--algorithm`** (e.g. 32 for SHA-256). + #[arg(long, value_name = "PATH")] + digest_file: Option, + /// Lowercase hex digest (no **`0x`**); length must match **`--algorithm`**. + #[arg(long, value_name = "HEX")] + digest_hex: Option, + /// Optional **`nonce`** (**`INTEGER`**) in the request. #[arg(long)] - include_image_value_der_hex: bool, + nonce: Option, + /// Set **`certReq`** to **TRUE** (request certs inside **`TimeStampToken`**). + #[arg(long, default_value_t = false)] + cert_req: bool, + #[arg(long, value_enum, default_value_t = TimestampReqOutput::Der)] + output: TimestampReqOutput, }, - /// Write an embedded Authenticode PKCS#7 (**raw DER**) to stdout or **`--output`** (certificate-table order; default **`--index`** **`0`**). - ExtractPePkcs7 { + /// Parse **RFC 3161** **`TimeStampResp`** DER (**`application/timestamp-reply`**) and print **`pki_status`**, **`pki_status_int`**, **`granted`**, optional **`time_stamp_token`** length, first **16** octets of the token TLV as hex (**`time_stamp_token_prefix_hex`**, for CMS **`ContentInfo`** sniffing), **`status_strings_json`**, **`fail_info_tlv_hex`**, **`fail_info_flags_json`**. Does **not** verify CMS / TSA crypto. + Rfc3161TimestampRespInspect { path: PathBuf, - /// **`WIN_CERT_TYPE_PKCS_SIGNED_DATA`** row index (**`0`** = first). - #[arg(long, default_value_t = 0)] - index: usize, + /// Expected **`TSTInfo.messageImprint.hashedMessage`** hex for request-binding diagnostics. + #[arg(long, value_name = "HEX")] + expect_digest_hex: Option, + /// Expected **`TSTInfo.nonce`** integer for request-binding diagnostics. + #[arg(long)] + expect_nonce: Option, + }, + /// POST **`TimeStampReq`** DER to a TSA (**`Content-Type: application/timestamp-query`**) and write **`TimeStampResp`** DER to stdout or **`--output`**. Requires **`--features timestamp-http`**. Does **not** verify the timestamp token. + #[cfg(feature = "timestamp-http")] + Rfc3161TimestampHttpPost { + /// TSA endpoint (**HTTPS** URL; POST body is raw **`TimeStampReq`** DER). + #[arg(long, value_name = "URL")] + url: String, + #[arg(long, value_enum, default_value_t = HashAlg::Sha256)] + algorithm: HashAlg, + #[arg(long, value_name = "PATH")] + digest_file: Option, + #[arg(long, value_name = "HEX")] + digest_hex: Option, + #[arg(long)] + nonce: Option, + #[arg(long, default_value_t = false)] + cert_req: bool, #[arg(long, value_name = "PATH")] output: Option, }, - /// List **`WIN_CERT_TYPE_PKCS_SIGNED_DATA`** PKCS#7 rows in the PE certificate table (**`pkcs7_entries=N`** then **`index=i byte_len=L`** per line). - ListPePkcs7 { path: PathBuf }, - /// **SHA-256** (**32** octets) over a signer’s authenticated-attribute **`SET OF Attribute`** DER (**RFC 5652** §5.4). + /// Print CAB Authenticode digest **without** requiring PKCS#7 (unsigned / structural check). /// - /// Same raw digest Azure Key Vault **`keys/sign`** expects for **`RS256`** (base64 **`value`** in JSON) when re-signing **CMS `SignerInfo`** on **RSA SHA-256** Authenticode. Differs from **`pe-digest`** (PE **image** hash). Requires **`SignerInfo.digestAlgorithm`** **SHA-256** and **`signedAttrs`**. **`--index`**: **`WIN_CERT_TYPE_PKCS_SIGNED_DATA`** row (**`0`** = first). **`--signer-index`**: **`SignerInfo`** within that PKCS#7’s **`SignedData`** (**`0`** = first; same as **`pkcs7-signer-rs256-prehash --signer-index`** after **`extract-pe-pkcs7`**). - PeSignerRs256Prehash { + /// Algorithm must match what will be used at signing time (default SHA-256). **`--encoding raw`** matches **`pe-digest`** for hash-file workflows. + CabDigest { path: PathBuf, - #[arg(long, default_value_t = 0)] - index: usize, - #[arg(long, default_value_t = 0)] - signer_index: usize, + #[arg(long, value_enum, default_value_t = HashAlg::Sha256)] + algorithm: HashAlg, #[arg(long, value_enum, default_value_t = DigestEncoding::Hex)] encoding: DigestEncoding, #[arg(long, value_name = "PATH")] output: Option, }, - /// Same digest as **`pe-signer-rs256-prehash`**, but **`path`** is **PKCS#7** DER (**`ContentInfo`** wrapping **`SignedData`**, or bare **`SignedData`** normalized like **`extract-pe-pkcs7`** output). + /// Inspect NuGet package-signature marker state (`.signature.p7s`) without validating CMS. + NupkgSignatureInfo { path: PathBuf }, + /// Hash an unsigned NuGet package exactly as the package-signature properties document records it. /// - /// **`--signer-index`**: **`SignerInfo`** within this **`SignedData`** (**`0`** = first). For PE workflows, extract PKCS#7 first (**`extract-pe-pkcs7`**) then run this command. - Pkcs7SignerRs256Prehash { + /// This is the unsigned ZIP byte hash used before adding `.signature.p7s`; signed packages are rejected. + NupkgDigest { path: PathBuf, - #[arg(long, default_value_t = 0)] - signer_index: usize, + #[arg(long, value_enum, default_value_t = NugetHashAlg::Sha256)] + algorithm: NugetHashAlg, #[arg(long, value_enum, default_value_t = DigestEncoding::Hex)] encoding: DigestEncoding, #[arg(long, value_name = "PATH")] output: Option, }, - /// **Experimental:** Append raw PKCS#7 (**`SignedData`**) DER as a new **`WIN_CERTIFICATE`** row (**`pe_embed`**). - /// - /// Updates the PE security directory and recomputes **`Optional Header.CheckSum`** (**`pe_compute_image_checksum`**). Does **not** validate PKCS#7 ↔ image digest or replace **`SignerSignEx3`**. For hybrid tooling and future portable sign pipelines. - AppendPePkcs7 { - /// Input PE path (**read fully** before writing **`--output`**; same path allowed). - #[arg(long = "pe", value_name = "PATH")] - pe_path: PathBuf, - /// PKCS#7 DER file (**bare `SignedData`** is normalized like other portable PKCS#7 paths). - #[arg(long = "pkcs7", value_name = "PATH")] - pkcs7_path: PathBuf, - #[arg(long, value_name = "PATH")] - output: PathBuf, - }, - /// Sign an unsigned PE image with portable Authenticode CMS + `WIN_CERTIFICATE` embedding. - /// - /// This is the first production-oriented portable Authenticode signing path. It supports local RSA - /// PKCS#1 v1.5 keys or Azure Key Vault RSA signing and SHA-2 digests; timestamp embedding and - /// non-PE formats remain separate backlog. - SignPe { - /// Input PE path. - #[arg(value_name = "PATH")] + /// Write NuGet signature content bytes (`Version` + package hash property) for an unsigned package. + /// + /// This is the CMS encapsulated content used by NuGet author signatures before `.signature.p7s` is embedded. + NupkgSignatureContent { + path: PathBuf, + #[arg(long, value_enum, default_value_t = NugetHashAlg::Sha256)] + algorithm: NugetHashAlg, + #[arg(long, value_name = "PATH")] + output: Option, + }, + /// Create local RSA/SHA-2 CMS for NuGet signature content bytes. + NupkgSignaturePkcs7 { path: PathBuf, + /// Package hash and CMS signer digest algorithm. + #[arg(long, value_enum, default_value_t = NugetHashAlg::Sha256)] + algorithm: NugetHashAlg, /// Signer certificate as DER or PEM. #[arg(long, value_name = "PATH")] - cert: Option, + cert: PathBuf, /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. #[arg(long, value_name = "PATH")] - key: Option, + key: PathBuf, /// Additional certificate to include in the PKCS#7 certificate set. #[arg(long = "chain-cert", value_name = "PATH")] chain_certs: Vec, - /// File digest algorithm for the PE Authenticode indirect digest and CMS signer. - #[arg(long, value_enum, default_value_t = PortableSignDigest::Sha256)] - digest: PortableSignDigest, - /// RFC3161 timestamp URL to timestamp the primary PE signature after signing. + /// RFC3161 timestamp URL to timestamp the NuGet CMS signature after signing. #[arg(long = "timestamp-url", visible_alias = "tr")] timestamp_url: Option, /// RFC3161 timestamp digest algorithm. #[arg(long = "timestamp-digest", visible_alias = "td", value_enum)] timestamp_digest: Option, - /// Azure Key Vault URL for remote RSA signing. - #[arg(long = "azure-key-vault-url", visible_alias = "kvu")] - azure_key_vault_url: Option, - /// Azure Key Vault certificate name for remote RSA signing. - #[arg(long = "azure-key-vault-certificate", visible_alias = "kvc")] - azure_key_vault_certificate: Option, - /// Optional Azure Key Vault certificate version. - #[arg(long = "azure-key-vault-certificate-version", visible_alias = "kvcv")] - azure_key_vault_certificate_version: Option, - #[arg(long = "azure-key-vault-accesstoken")] - azure_key_vault_access_token: Option, - #[arg(long = "azure-key-vault-managed-identity")] - azure_key_vault_managed_identity: bool, - #[arg(long = "azure-key-vault-tenant-id")] - azure_key_vault_tenant_id: Option, - #[arg(long = "azure-key-vault-client-id")] - azure_key_vault_client_id: Option, - #[arg(long = "azure-key-vault-client-secret")] - azure_key_vault_client_secret: Option, - #[arg(long = "azure-authority")] - azure_authority: Option, - #[command(flatten)] - artifact_signing: Box, - /// Output signed PE path. #[arg(long, value_name = "PATH")] output: PathBuf, }, - /// Sign an unsigned CAB file with portable Authenticode CMS and CAB reserve-header embedding. + /// Compute the signed-attributes digest for externally signing NuGet CMS. /// - /// Supports single-volume unsigned CABs without an existing reserve header. - SignCab { - /// Input CAB path. - #[arg(value_name = "PATH")] + /// Sign this digest with RSA PKCS#1 v1.5 using the selected SHA-2 algorithm, then pass the + /// signature bytes to `nupkg-signature-pkcs7-from-signature`. + NupkgSignaturePkcs7Prehash { path: PathBuf, - /// Signer certificate as DER or PEM. - #[arg(long, value_name = "PATH")] - cert: PathBuf, - /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. - #[arg(long, value_name = "PATH")] - key: PathBuf, - /// Additional certificate to include in the PKCS#7 certificate set. - #[arg(long = "chain-cert", value_name = "PATH")] - chain_certs: Vec, - /// File digest algorithm for the CAB Authenticode indirect digest and CMS signer. - #[arg(long, value_enum, default_value_t = PortableSignDigest::Sha256)] - digest: PortableSignDigest, - /// Output signed CAB path. + /// Package hash and CMS signer digest algorithm. + #[arg(long, value_enum, default_value_t = NugetHashAlg::Sha256)] + algorithm: NugetHashAlg, + #[arg(long, value_enum, default_value_t = DigestEncoding::Hex)] + encoding: DigestEncoding, #[arg(long, value_name = "PATH")] - output: PathBuf, + output: Option, }, - /// Sign an MSI/MSP OLE package with portable Authenticode CMS and a DigitalSignature stream. - SignMsi { - /// Input MSI/MSP path. - #[arg(value_name = "PATH")] + /// Create NuGet CMS from externally produced RSA PKCS#1 v1.5 signature bytes. + NupkgSignaturePkcs7FromSignature { path: PathBuf, + /// Package hash and CMS signer digest algorithm. + #[arg(long, value_enum, default_value_t = NugetHashAlg::Sha256)] + algorithm: NugetHashAlg, /// Signer certificate as DER or PEM. #[arg(long, value_name = "PATH")] cert: PathBuf, - /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. - #[arg(long, value_name = "PATH")] - key: PathBuf, /// Additional certificate to include in the PKCS#7 certificate set. #[arg(long = "chain-cert", value_name = "PATH")] chain_certs: Vec, - /// File digest algorithm for the MSI Authenticode indirect digest and CMS signer. - #[arg(long, value_enum, default_value_t = PortableSignDigest::Sha256)] - digest: PortableSignDigest, - /// Output signed MSI/MSP path. + /// Raw RSA PKCS#1 v1.5 signature bytes produced over `nupkg-signature-pkcs7-prehash`. + #[arg(long, value_name = "PATH")] + signature: PathBuf, + /// RFC3161 timestamp URL to timestamp the NuGet CMS signature after assembly. + #[arg(long = "timestamp-url", visible_alias = "tr")] + timestamp_url: Option, + /// RFC3161 timestamp digest algorithm. + #[arg(long = "timestamp-digest", visible_alias = "td", value_enum)] + timestamp_digest: Option, #[arg(long, value_name = "PATH")] output: PathBuf, }, - /// Sign a portable generic file catalog (`.cat`) with CTL members and CMS `SignedData`. - /// - /// This authors explicit file membership entries for the provided subjects. It does not implement - /// driver/INF policy, OS catalog database installation/search, or MakeCat byte-for-byte output. - SignCatalog { - /// Subject file(s) to include as catalog members. - #[arg(required = true, value_name = "PATH")] - files: Vec, + /// Create and embed a local RSA/SHA-2 NuGet `.signature.p7s` signature. + NupkgSign { + path: PathBuf, + /// Package hash and CMS signer digest algorithm. + #[arg(long, value_enum, default_value_t = NugetHashAlg::Sha256)] + algorithm: NugetHashAlg, /// Signer certificate as DER or PEM. #[arg(long, value_name = "PATH")] cert: PathBuf, @@ -798,257 +2376,238 @@ enum Command { /// Additional certificate to include in the PKCS#7 certificate set. #[arg(long = "chain-cert", value_name = "PATH")] chain_certs: Vec, - /// File digest algorithm for catalog member digests and CMS signer. - #[arg(long, value_enum, default_value_t = PortableSignDigest::Sha256)] - digest: PortableSignDigest, - /// Output signed catalog path. + /// RFC3161 timestamp URL to timestamp the NuGet CMS signature after signing. + #[arg(long = "timestamp-url", visible_alias = "tr")] + timestamp_url: Option, + /// RFC3161 timestamp digest algorithm. + #[arg(long = "timestamp-digest", visible_alias = "td", value_enum)] + timestamp_digest: Option, #[arg(long, value_name = "PATH")] output: PathBuf, + #[arg(long, default_value_t = false)] + overwrite: bool, }, - /// Attach an RFC3161 timestamp token to an existing embedded PE Authenticode signature. - /// - /// Accepts either a raw `timeStampToken` `ContentInfo` DER file or a `TimeStampResp` DER file containing one. - TimestampPeRfc3161 { - /// Signed PE path to mutate. - #[arg(value_name = "PATH")] + /// Verify NuGet signature content bytes against an unsigned package hash. + NupkgVerifySignatureContent { path: PathBuf, - /// Embedded PKCS#7 row index in the PE certificate table. - #[arg(long, default_value_t = 0)] - index: usize, - /// SignerInfo index inside the selected PKCS#7 SignedData. - #[arg(long, default_value_t = 0)] - signer_index: usize, - /// Raw RFC3161 timeStampToken ContentInfo DER. - #[arg(long, value_name = "PATH", conflicts_with = "response")] - token: Option, - /// RFC3161 TimeStampResp DER containing a granted timeStampToken. - #[arg(long, value_name = "PATH", conflicts_with = "token")] - response: Option, - /// Output PE path. #[arg(long, value_name = "PATH")] - output: PathBuf, + content: PathBuf, }, - /// Sign `.rdp` files using portable RDP SignScope/SecureSettingsBlob logic. - /// - /// Supply either **`--cert`** + **`--key`** for local RSA/SHA-256 PKCS#7 creation, or - /// **`--signature-pkcs7`** to embed an externally-created detached PKCS#7 for a single input. - Rdp { - /// Signer certificate as DER or PEM. - #[arg( - long, - value_name = "PATH", - requires = "key", - conflicts_with = "signature_pkcs7" - )] - cert: Option, - /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. - #[arg( - long, - value_name = "PATH", - requires = "cert", - conflicts_with = "signature_pkcs7" - )] - key: Option, - /// Additional certificate to include in the PKCS#7 certificate set. - #[arg( - long = "chain-cert", - value_name = "PATH", - requires = "cert", - conflicts_with = "signature_pkcs7" - )] - chain_certs: Vec, - /// Detached PKCS#7 DER to serialize into the RDP `Signature` record. - #[arg(long = "signature-pkcs7", value_name = "PATH", conflicts_with_all = ["cert", "key", "chain_certs"])] - signature_pkcs7: Option, - /// Build and validate the signed shape without writing files. - #[arg(long)] - dry_run: bool, - /// Write output here instead of overwriting the input. Only valid with one input file. - #[arg(long, value_name = "PATH")] - output: Option, - /// `.rdp` file(s) to sign. - #[arg(required = true)] - files: Vec, + /// Verify an embedded NuGet `.signature.p7s` against package content and explicit trust anchors. + NupkgVerifySignature { + path: PathBuf, + /// Package hash and CMS signer digest algorithm used to reconstruct NuGet signature content. + #[arg(long, value_enum, default_value_t = NugetHashAlg::Sha256)] + algorithm: NugetHashAlg, + #[command(flatten)] + shared: TrustVerifySharedArgs, }, - /// Write embedded Authenticode PKCS#7 (**raw DER**) from a signed **`.cab`** tail to stdout or **`--output`**. + /// Embed a NuGet package author signature blob as root `.signature.p7s`. /// - /// Layout: **`cab_digest::cab_signature_pkcs7_der`** (same bytes you would pass to **`pkcs7-signer-rs256-prehash`**). - ExtractCabPkcs7 { + /// This is a package-native write primitive for split signing workflows; it does not create CMS. + NupkgEmbedSignature { path: PathBuf, #[arg(long, value_name = "PATH")] - output: Option, + signature: PathBuf, + #[arg(long, value_name = "PATH")] + output: PathBuf, + #[arg(long, default_value_t = false)] + overwrite: bool, }, - /// Same digest as **`pkcs7-signer-rs256-prehash`** on PKCS#7 embedded at the end of a signed **`.cab`** (after **`extract-cab-pkcs7`**). + /// Inspect VSIX OPC signature marker state without validating XMLDSig. + VsixSignatureInfo { path: PathBuf }, + /// Embed a VSIX OPC signature XML part and signature-origin marker. /// - /// **`--signer-index`**: **`SignerInfo`** within that **`SignedData`**. For AzureSignTool-style **KV `RS256`**, use **`--encoding raw`** (distinct from **`cab-digest`** MSCF subject hash). - CabSignerRs256Prehash { + /// This is a structural write primitive for split XMLDSig workflows; it does not create XMLDSig. + VsixEmbedSignatureXml { path: PathBuf, - #[arg(long, default_value_t = 0)] - signer_index: usize, - #[arg(long, value_enum, default_value_t = DigestEncoding::Hex)] - encoding: DigestEncoding, #[arg(long, value_name = "PATH")] - output: Option, + signature_xml: PathBuf, + #[arg(long, value_name = "PATH")] + output: PathBuf, + #[arg(long, default_value_t = false)] + overwrite: bool, }, - /// 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. - ExtractMsiPkcs7 { + /// Write deterministic VSIX XMLDSig Reference/DigestValue XML for package parts. + VsixSignatureReferenceXml { path: PathBuf, + #[arg(long, value_enum, default_value_t = VsixHashAlg::Sha256)] + algorithm: VsixHashAlg, #[arg(long, value_name = "PATH")] output: Option, }, - /// Same digest as **`pkcs7-signer-rs256-prehash`** on PKCS#7 from **`\\u{5}DigitalSignature`** (after **`extract-msi-pkcs7`**). - /// - /// **`--signer-index`**: **`SignerInfo`** within **`SignedData`**. **`--encoding raw`** for Azure KV **`RS256`** (distinct from MSI SIP fingerprint / **`verify-msi`** subject hash). - MsiSignerRs256Prehash { + /// Create deterministic VSIX XMLDSig XML with local RSA/SHA-2 SignatureValue. + VsixSignatureXml { path: PathBuf, - #[arg(long, default_value_t = 0)] - signer_index: usize, - #[arg(long, value_enum, default_value_t = DigestEncoding::Hex)] - encoding: DigestEncoding, + #[arg(long, value_enum, default_value_t = VsixHashAlg::Sha256)] + algorithm: VsixHashAlg, + /// Signer certificate as DER or PEM. + #[arg(long, value_name = "PATH")] + cert: PathBuf, + /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. + #[arg(long, value_name = "PATH")] + key: PathBuf, #[arg(long, value_name = "PATH")] output: Option, }, - /// Signed MSI: compare PKCS#7 indirect digest to Rust OLE fingerprint (and extended stream if present). - VerifyMsi { path: PathBuf }, - /// Signed WIM/ESD: compare PKCS#7 indirect digest to Rust prefix hash. - VerifyEsd { path: PathBuf }, - /// Cleartext MSIX/APPX/bundle: compare PKCS#7 indirect digest to Rust ZIP rehash (encrypted extensions rejected). - VerifyMsix { path: PathBuf }, - /// Same digest as **`pkcs7-signer-rs256-prehash`** when **`path`** is raw PKCS#7 **`SignedData`** (typical **`.cat`** body — CTL or other CMS **`ContentInfo`**). + /// Compute the SignedInfo digest for externally signing VSIX XMLDSig. /// - /// For **KV `RS256`** over **`SignerInfo.signedAttrs`**, use **`--encoding raw`**. Does **not** run **`verify-catalog`** (CTL **`messageDigest`** vs **`eContent`** rules differ from Authenticode PE PKCS#7). - CatalogSignerRs256Prehash { + /// Sign this digest with RSA PKCS#1 v1.5 using the selected SHA-2 algorithm, then pass the + /// signature bytes to `vsix-signature-xml-from-signature`. + VsixSignatureXmlPrehash { path: PathBuf, - #[arg(long, default_value_t = 0)] - signer_index: usize, + #[arg(long, value_enum, default_value_t = VsixHashAlg::Sha256)] + algorithm: VsixHashAlg, #[arg(long, value_enum, default_value_t = DigestEncoding::Hex)] encoding: DigestEncoding, #[arg(long, value_name = "PATH")] output: Option, }, - /// Signed catalog `.cat`: compare PKCS#7 indirect digest to Rust catalog digest scan. - VerifyCatalog { path: PathBuf }, - /// Verify that a subject file is represented by a MakeCat-style CTL member in a signed catalog. - VerifyCatalogMember { - /// Catalog `.cat` file. + /// Create deterministic VSIX XMLDSig XML from externally produced RSA signature bytes. + VsixSignatureXmlFromSignature { + path: PathBuf, + #[arg(long, value_enum, default_value_t = VsixHashAlg::Sha256)] + algorithm: VsixHashAlg, + /// Signer certificate as DER or PEM. #[arg(long, value_name = "PATH")] - catalog: PathBuf, - /// Subject file whose catalog membership should be checked. - #[arg(value_name = "PATH")] - subject: PathBuf, + cert: PathBuf, + /// Raw RSA PKCS#1 v1.5 signature bytes produced over `vsix-signature-xml-prehash`. + #[arg(long, value_name = "PATH")] + signature: PathBuf, + #[arg(long, value_name = "PATH")] + output: Option, }, - /// Script signed file (PowerShell-class or WSH): compare PKCS#7 indirect digest to Rust heuristic strip/hash. - VerifyScript { path: PathBuf }, - /// Inspect PKCS#7 layers: signers, timestamp-related attribute OIDs, nested signatures (`1.3.6.1.4.1.311.2.4.1`). JSON to stdout. - InspectAuthenticode { + /// Create and embed deterministic VSIX XMLDSig XML with local RSA/SHA-2 SignatureValue. + VsixSign { path: PathBuf, - /// Treat **`path`** as a PE image (**embedded** attribute certs) vs raw PKCS#7 bytes. - #[arg(long, value_enum, default_value_t = InspectInputKind::Pe)] - input: InspectInputKind, + #[arg(long, value_enum, default_value_t = VsixHashAlg::Sha256)] + algorithm: VsixHashAlg, + /// Signer certificate as DER or PEM. + #[arg(long, value_name = "PATH")] + cert: PathBuf, + /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. + #[arg(long, value_name = "PATH")] + key: PathBuf, + #[arg(long, value_name = "PATH")] + output: PathBuf, + #[arg(long, default_value_t = false)] + overwrite: bool, }, - /// Validate JSON metadata shape for Microsoft Artifact Signing (`Endpoint`, `CodeSigningAccountName`, `CertificateProfileName`; optional `ExcludeCredentials` string array). No network / no signing. - /// - /// Reads **`--path`** or stdin when omitted (use `-` for stdin explicitly). - ArtifactSigningMetadataCheck { + /// Verify VSIX XMLDSig Reference/DigestValue XML against package parts. + VsixVerifySignatureReferenceXml { + path: PathBuf, #[arg(long, value_name = "PATH")] - path: Option, + signature_xml: PathBuf, + #[arg(long, value_enum, default_value_t = VsixHashAlg::Sha256)] + algorithm: VsixHashAlg, }, - /// Azure Code Signing **`…:sign`** LRO (same REST contract as **`psign-tool artifact-signing-submit`**). Requires **`--features artifact-signing-rest`** at build time. - #[cfg(feature = "artifact-signing-rest")] - ArtifactSigningSubmit { + /// Verify deterministic VSIX XMLDSig references and local RSA/SHA-2 SignatureValue. + VsixVerifySignatureXml { + path: PathBuf, + #[arg(long, value_name = "PATH")] + signature_xml: PathBuf, + #[arg(long, value_name = "PATH")] + cert: PathBuf, + #[arg(long, value_enum, default_value_t = VsixHashAlg::Sha256)] + algorithm: VsixHashAlg, #[command(flatten)] - args: ArtifactSigningSubmitPortableArgs, + shared: TrustVerifySharedArgs, }, - /// Azure Key Vault **`keys/sign`** over a **precomputed digest file** (RSA PKCS#1 or ECDSA). Requires **`--features azure-kv-sign-portable`**. Does **not** embed Authenticode — use **`psign-tool`** for that. - #[cfg(feature = "azure-kv-sign-portable")] - AzureKeyVaultSignDigest { + /// Verify an embedded VSIX OPC XMLDSig signature part. + VsixVerifySignature { + path: PathBuf, + #[arg(long, value_name = "PATH")] + cert: Option, + #[arg(long, value_enum, default_value_t = VsixHashAlg::Sha256)] + algorithm: VsixHashAlg, #[command(flatten)] - args: AzureKvSignDigestPortableArgs, + shared: TrustVerifySharedArgs, }, - /// Build **RFC 3161** **`TimeStampReq`** DER from a **message imprint** preimage (raw digest bytes for **`MessageImprint.hashedMessage`** — not a second hash). For **`curl`** / OpenSSL **`ts -query`** against a TSA (**`application/timestamp-query`**). - /// - /// Supply exactly one of **`--digest-hex`** or **`--digest-file`**. Does **not** POST to a TSA. - Rfc3161TimestampReq { - #[arg(long, value_enum, default_value_t = HashAlg::Sha256)] - algorithm: HashAlg, - /// Raw digest bytes; length must match **`--algorithm`** (e.g. 32 for SHA-256). + /// Inspect an App Installer descriptor and optional detached PKCS#7 companion signature. + AppinstallerInfo { + path: PathBuf, #[arg(long, value_name = "PATH")] - digest_file: Option, - /// Lowercase hex digest (no **`0x`**); length must match **`--algorithm`**. - #[arg(long, value_name = "HEX")] - digest_hex: Option, - /// Optional **`nonce`** (**`INTEGER`**) in the request. - #[arg(long)] - nonce: Option, - /// Set **`certReq`** to **TRUE** (request certs inside **`TimeStampToken`**). - #[arg(long, default_value_t = false)] - cert_req: bool, - #[arg(long, value_enum, default_value_t = TimestampReqOutput::Der)] - output: TimestampReqOutput, + signature: Option, }, - /// Parse **RFC 3161** **`TimeStampResp`** DER (**`application/timestamp-reply`**) and print **`pki_status`**, **`pki_status_int`**, **`granted`**, optional **`time_stamp_token`** length, first **16** octets of the token TLV as hex (**`time_stamp_token_prefix_hex`**, for CMS **`ContentInfo`** sniffing), **`status_strings_json`**, **`fail_info_tlv_hex`**, **`fail_info_flags_json`**. Does **not** verify CMS / TSA crypto. - Rfc3161TimestampRespInspect { + /// Verify an App Installer XML descriptor against its detached PKCS#7 companion signature. + AppinstallerVerifyCompanion { path: PathBuf, - /// Expected **`TSTInfo.messageImprint.hashedMessage`** hex for request-binding diagnostics. - #[arg(long, value_name = "HEX")] - expect_digest_hex: Option, - /// Expected **`TSTInfo.nonce`** integer for request-binding diagnostics. - #[arg(long)] - expect_nonce: Option, + #[arg(long, value_name = "PATH")] + signature: PathBuf, + #[command(flatten)] + shared: TrustVerifySharedArgs, }, - /// POST **`TimeStampReq`** DER to a TSA (**`Content-Type: application/timestamp-query`**) and write **`TimeStampResp`** DER to stdout or **`--output`**. Requires **`--features timestamp-http`**. Does **not** verify the timestamp token. - #[cfg(feature = "timestamp-http")] - Rfc3161TimestampHttpPost { - /// TSA endpoint (**HTTPS** URL; POST body is raw **`TimeStampReq`** DER). - #[arg(long, value_name = "URL")] - url: String, - #[arg(long, value_enum, default_value_t = HashAlg::Sha256)] - algorithm: HashAlg, + /// Create a detached PKCS#7 companion signature for an App Installer XML descriptor. + AppinstallerSignCompanion { + path: PathBuf, + /// Signer certificate as DER or PEM. #[arg(long, value_name = "PATH")] - digest_file: Option, - #[arg(long, value_name = "HEX")] - digest_hex: Option, - #[arg(long)] - nonce: Option, - #[arg(long, default_value_t = false)] - cert_req: bool, + cert: PathBuf, + /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. #[arg(long, value_name = "PATH")] - output: Option, + key: PathBuf, + /// Additional certificate to include in the PKCS#7 certificate set. + #[arg(long = "chain-cert", value_name = "PATH")] + chain_certs: Vec, + /// CMS signer digest algorithm. + #[arg(long, value_enum, default_value_t = PortableSignDigest::Sha256)] + digest: PortableSignDigest, + /// RFC3161 timestamp URL to timestamp the companion CMS signature after signing. + #[arg(long = "timestamp-url", visible_alias = "tr")] + timestamp_url: Option, + /// RFC3161 timestamp digest algorithm. + #[arg(long = "timestamp-digest", visible_alias = "td", value_enum)] + timestamp_digest: Option, + /// Output detached PKCS#7 companion path. + #[arg(long, value_name = "PATH")] + output: PathBuf, }, - /// Print CAB Authenticode digest **without** requiring PKCS#7 (unsigned / structural check). - /// - /// Algorithm must match what will be used at signing time (default SHA-256). **`--encoding raw`** matches **`pe-digest`** for hash-file workflows. - CabDigest { + /// Compute the CMS authenticated-attributes digest for externally signing an App Installer companion. + AppinstallerSignCompanionPrehash { path: PathBuf, - #[arg(long, value_enum, default_value_t = HashAlg::Sha256)] - algorithm: HashAlg, + /// CMS signer digest algorithm. + #[arg(long, value_enum, default_value_t = PortableSignDigest::Sha256)] + digest: PortableSignDigest, #[arg(long, value_enum, default_value_t = DigestEncoding::Hex)] encoding: DigestEncoding, #[arg(long, value_name = "PATH")] output: Option, }, - /// Inspect NuGet package-signature marker state (`.signature.p7s`) without validating CMS. - NupkgSignatureInfo { path: PathBuf }, - /// Hash an unsigned NuGet package exactly as the package-signature properties document records it. - /// - /// This is the unsigned ZIP byte hash used before adding `.signature.p7s`; signed packages are rejected. - NupkgDigest { + /// Create an App Installer companion PKCS#7 from externally produced RSA signature bytes. + AppinstallerSignCompanionFromSignature { path: PathBuf, - #[arg(long, value_enum, default_value_t = NugetHashAlg::Sha256)] - algorithm: NugetHashAlg, - #[arg(long, value_enum, default_value_t = DigestEncoding::Hex)] - encoding: DigestEncoding, + /// Signer certificate as DER or PEM. #[arg(long, value_name = "PATH")] - output: Option, + cert: PathBuf, + /// Additional certificate to include in the PKCS#7 certificate set. + #[arg(long = "chain-cert", value_name = "PATH")] + chain_certs: Vec, + /// CMS signer digest algorithm. + #[arg(long, value_enum, default_value_t = PortableSignDigest::Sha256)] + digest: PortableSignDigest, + /// Raw RSA PKCS#1 v1.5 signature bytes produced over `appinstaller-sign-companion-prehash`. + #[arg(long, value_name = "PATH")] + signature: PathBuf, + /// RFC3161 timestamp URL to timestamp the companion CMS signature after assembly. + #[arg(long = "timestamp-url", visible_alias = "tr")] + timestamp_url: Option, + /// RFC3161 timestamp digest algorithm. + #[arg(long = "timestamp-digest", visible_alias = "td", value_enum)] + timestamp_digest: Option, + /// Output detached PKCS#7 companion path. + #[arg(long, value_name = "PATH")] + output: PathBuf, }, - /// Inspect VSIX OPC signature marker state without validating XMLDSig. - VsixSignatureInfo { path: PathBuf }, + /// Update MainPackage/MainBundle Publisher attributes in an App Installer descriptor. + AppinstallerSetPublisher { + path: PathBuf, + #[arg(long, value_name = "SUBJECT")] + publisher: String, + #[arg(long, value_name = "PATH")] + output: PathBuf, + }, + /// Inspect a Dynamics 365 Business Central `.app` package header. + BusinessCentralAppInfo { path: PathBuf }, } #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] @@ -1124,6 +2683,182 @@ impl From for nuget::NuGetHashAlgorithm { } } +impl From for PortableSignDigest { + fn from(value: NugetHashAlg) -> Self { + match value { + NugetHashAlg::Sha256 => Self::Sha256, + NugetHashAlg::Sha384 => Self::Sha384, + NugetHashAlg::Sha512 => Self::Sha512, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum VsixHashAlg { + Sha256, + Sha384, + Sha512, +} + +impl From for vsix::VsixHashAlgorithm { + fn from(value: VsixHashAlg) -> Self { + match value { + VsixHashAlg::Sha256 => Self::Sha256, + VsixHashAlg::Sha384 => Self::Sha384, + VsixHashAlg::Sha512 => Self::Sha512, + } + } +} + +fn nuget_hash_alg_label(value: nuget::NuGetHashAlgorithm) -> &'static str { + match value { + nuget::NuGetHashAlgorithm::Sha256 => "sha256", + nuget::NuGetHashAlgorithm::Sha384 => "sha384", + nuget::NuGetHashAlgorithm::Sha512 => "sha512", + } +} + +fn vsix_hash_alg_label(value: vsix::VsixHashAlgorithm) -> &'static str { + match value { + vsix::VsixHashAlgorithm::Sha256 => "sha256", + vsix::VsixHashAlgorithm::Sha384 => "sha384", + vsix::VsixHashAlgorithm::Sha512 => "sha512", + } +} + +fn sign_xml_signed_info( + algorithm: vsix::VsixHashAlgorithm, + private_key: rsa::RsaPrivateKey, + signed_info: &[u8], +) -> Result> { + let signature = match algorithm { + vsix::VsixHashAlgorithm::Sha256 => { + let key = rsa::pkcs1v15::SigningKey::::new(private_key); + key.sign(signed_info).to_vec() + } + vsix::VsixHashAlgorithm::Sha384 => { + let key = rsa::pkcs1v15::SigningKey::::new(private_key); + key.sign(signed_info).to_vec() + } + vsix::VsixHashAlgorithm::Sha512 => { + let key = rsa::pkcs1v15::SigningKey::::new(private_key); + key.sign(signed_info).to_vec() + } + }; + Ok(signature) +} + +fn sign_clickonce_signed_info( + digest: PortableSignDigest, + private_key: rsa::RsaPrivateKey, + signed_info: &[u8], +) -> Result> { + let signature = match digest { + PortableSignDigest::Sha256 => { + let key = rsa::pkcs1v15::SigningKey::::new(private_key); + key.sign(signed_info).to_vec() + } + PortableSignDigest::Sha384 => { + let key = rsa::pkcs1v15::SigningKey::::new(private_key); + key.sign(signed_info).to_vec() + } + PortableSignDigest::Sha512 => { + let key = rsa::pkcs1v15::SigningKey::::new(private_key); + key.sign(signed_info).to_vec() + } + }; + Ok(signature) +} + +fn xml_signed_info_remote_prehash( + algorithm: vsix::VsixHashAlgorithm, + signed_info: &[u8], +) -> Vec { + match algorithm { + vsix::VsixHashAlgorithm::Sha256 => Sha256::digest(signed_info).to_vec(), + vsix::VsixHashAlgorithm::Sha384 => Sha384::digest(signed_info).to_vec(), + vsix::VsixHashAlgorithm::Sha512 => Sha512::digest(signed_info).to_vec(), + } +} + +fn verify_xml_signed_info( + algorithm: vsix::VsixHashAlgorithm, + signer_cert: &x509_cert::Certificate, + signed_info: &[u8], + signature: &[u8], +) -> Result<()> { + let spki_der = signer_cert + .tbs_certificate + .subject_public_key_info + .to_der() + .map_err(|e| anyhow!("encode signer certificate SubjectPublicKeyInfo: {e}"))?; + let public_key = rsa::RsaPublicKey::from_public_key_der(&spki_der) + .map_err(|e| anyhow!("RSA public key from signer certificate: {e}"))?; + match algorithm { + vsix::VsixHashAlgorithm::Sha256 => { + let signature = rsa::pkcs1v15::Signature::try_from(signature) + .map_err(|e| anyhow!("VSIX SignatureValue PKCS#1 v1.5 octets: {e}"))?; + rsa::pkcs1v15::VerifyingKey::::new(public_key) + .verify(signed_info, &signature) + .map_err(|e| anyhow!("verify VSIX SignatureValue: {e}"))?; + } + vsix::VsixHashAlgorithm::Sha384 => { + let signature = rsa::pkcs1v15::Signature::try_from(signature) + .map_err(|e| anyhow!("VSIX SignatureValue PKCS#1 v1.5 octets: {e}"))?; + rsa::pkcs1v15::VerifyingKey::::new(public_key) + .verify(signed_info, &signature) + .map_err(|e| anyhow!("verify VSIX SignatureValue: {e}"))?; + } + vsix::VsixHashAlgorithm::Sha512 => { + let signature = rsa::pkcs1v15::Signature::try_from(signature) + .map_err(|e| anyhow!("VSIX SignatureValue PKCS#1 v1.5 octets: {e}"))?; + rsa::pkcs1v15::VerifyingKey::::new(public_key) + .verify(signed_info, &signature) + .map_err(|e| anyhow!("verify VSIX SignatureValue: {e}"))?; + } + } + Ok(()) +} + +fn verify_clickonce_signed_info( + digest: PortableSignDigest, + signer_cert: &x509_cert::Certificate, + signed_info: &[u8], + signature: &[u8], +) -> Result<()> { + let spki_der = signer_cert + .tbs_certificate + .subject_public_key_info + .to_der() + .map_err(|e| anyhow!("encode signer certificate SubjectPublicKeyInfo: {e}"))?; + let public_key = rsa::RsaPublicKey::from_public_key_der(&spki_der) + .map_err(|e| anyhow!("RSA public key from signer certificate: {e}"))?; + match digest { + PortableSignDigest::Sha256 => { + let signature = rsa::pkcs1v15::Signature::try_from(signature) + .map_err(|e| anyhow!("ClickOnce SignatureValue PKCS#1 v1.5 octets: {e}"))?; + rsa::pkcs1v15::VerifyingKey::::new(public_key) + .verify(signed_info, &signature) + .map_err(|e| anyhow!("verify ClickOnce SignatureValue: {e}"))?; + } + PortableSignDigest::Sha384 => { + let signature = rsa::pkcs1v15::Signature::try_from(signature) + .map_err(|e| anyhow!("ClickOnce SignatureValue PKCS#1 v1.5 octets: {e}"))?; + rsa::pkcs1v15::VerifyingKey::::new(public_key) + .verify(signed_info, &signature) + .map_err(|e| anyhow!("verify ClickOnce SignatureValue: {e}"))?; + } + PortableSignDigest::Sha512 => { + let signature = rsa::pkcs1v15::Signature::try_from(signature) + .map_err(|e| anyhow!("ClickOnce SignatureValue PKCS#1 v1.5 octets: {e}"))?; + rsa::pkcs1v15::VerifyingKey::::new(public_key) + .verify(signed_info, &signature) + .map_err(|e| anyhow!("verify ClickOnce SignatureValue: {e}"))?; + } + } + Ok(()) +} + impl From for PeAuthenticodeHashKind { fn from(value: HashAlg) -> Self { match value { @@ -2821,6 +4556,168 @@ where msix_digest::verify_msix_digest_consistency(&path) .with_context(|| format!("verify-msix {}", path.display()))?; } + Command::MsixManifestInfo { path } => { + let info = inspect_msix_manifest_path(&path) + .with_context(|| format!("msix-manifest-info {}", path.display()))?; + println!("package_name={}", info.package_name.unwrap_or("-".to_string())); + println!("publisher={}", info.publisher.unwrap_or("-".to_string())); + println!("version={}", info.version.unwrap_or("-".to_string())); + println!( + "processor_architecture={}", + info.processor_architecture.unwrap_or("-".to_string()) + ); + } + Command::MsixSetPublisher { + path, + publisher, + output, + } => { + set_msix_manifest_publisher_path(&path, &output, &publisher) + .with_context(|| format!("msix-set-publisher {}", path.display()))?; + println!("output={}", output.display()); + println!("publisher={publisher}"); + } + Command::ClickonceDeployInfo { path } => { + let info = inspect_clickonce_deploy_payload(&path) + .with_context(|| format!("clickonce-deploy-info {}", path.display()))?; + println!("deployed={}", if info.deployed { "yes" } else { "no" }); + println!( + "content_name={}", + info.content_name.unwrap_or("-".to_string()) + ); + println!("len={}", info.len); + } + Command::ClickonceCopyDeployPayload { path, output } => { + let content_name = clickonce_deploy_content_name(&path).ok_or_else(|| { + anyhow!( + "ClickOnce deploy payload name must end with .deploy: {}", + path.display() + ) + })?; + let bytes = copy_clickonce_deploy_payload(&path, &output) + .with_context(|| format!("clickonce-copy-deploy-payload {}", path.display()))?; + println!("content_name={content_name}"); + println!("output={}", output.display()); + println!("bytes={bytes}"); + } + Command::ClickonceManifestHashes { + path, + base_directory, + } => { + let entries = clickonce_manifest_hashes(&path, base_directory.as_deref()) + .with_context(|| format!("clickonce-manifest-hashes {}", path.display()))?; + println!("references={}", entries.len()); + let mut mismatches = 0usize; + for entry in entries { + if entry.status() != "valid" { + mismatches += 1; + } + println!( + "path={} algorithm={} expected_size={} actual_size={} status={}", + entry.path, + hash_alg_label(entry.algorithm), + entry + .expected_size + .map(|size| size.to_string()) + .unwrap_or_else(|| "-".to_string()), + entry.actual_size, + entry.status() + ); + println!("expected_digest_b64={}", entry.expected_digest_b64); + println!("actual_digest_b64={}", entry.actual_digest_b64); + } + println!("mismatches={mismatches}"); + if mismatches > 0 { + return Err(anyhow!( + "ClickOnce manifest file hash verification failed ({mismatches} mismatch(es))" + )); + } + } + Command::ClickonceUpdateManifestHashes { + path, + base_directory, + algorithm, + output, + } => { + let updated = + update_clickonce_manifest_hashes(&path, base_directory.as_deref(), &output, algorithm) + .with_context(|| { + format!("clickonce-update-manifest-hashes {}", path.display()) + })?; + println!("output={}", output.display()); + println!("updated={updated}"); + println!("algorithm={}", hash_alg_label(algorithm)); + } + Command::ClickonceSignManifest { + path, + digest, + cert, + key, + output, + } => { + let report = sign_clickonce_manifest_path(&path, &cert, &key, digest, &output) + .with_context(|| format!("clickonce-sign-manifest {}", path.display()))?; + println!("output={}", output.display()); + println!("digest={:?}", report.digest); + println!("manifest_digest_b64={}", report.manifest_digest_b64); + println!("signature_len={}", report.signature_len); + } + Command::ClickonceSignManifestPrehash { + path, + digest, + encoding, + output, + } => { + let text = std::fs::read_to_string(&path) + .with_context(|| format!("read ClickOnce manifest {}", path.display()))?; + let unsigned = unsigned_clickonce_manifest_text(&text)?; + let signed_info = clickonce_manifest_signed_info_xml(&unsigned, digest); + let prehash = clickonce_signed_info_remote_prehash(digest, &signed_info); + write_digest_output(encoding, &prehash, output.as_deref()) + .with_context(|| format!("clickonce-sign-manifest-prehash {}", path.display()))?; + } + Command::ClickonceSignManifestFromSignature { + path, + digest, + cert, + signature, + output, + } => { + let report = sign_clickonce_manifest_from_external_signature_path( + &path, &cert, &signature, digest, &output, + ) + .with_context(|| { + format!( + "clickonce-sign-manifest-from-signature {}", + path.display() + ) + })?; + println!("output={}", output.display()); + println!("digest={:?}", report.digest); + println!("manifest_digest_b64={}", report.manifest_digest_b64); + println!("signature_len={}", report.signature_len); + } + Command::ClickonceVerifyManifestSignature { + path, + cert, + digest, + shared, + } => { + let report = + verify_clickonce_manifest_signature_path(&path, cert.as_deref(), digest, &shared) + .with_context(|| { + format!("clickonce-verify-manifest-signature {}", path.display()) + })?; + println!("clickonce-verify-manifest-signature: ok"); + println!("digest={:?}", report.digest); + println!("manifest_digest_b64={}", report.manifest_digest_b64); + println!("manifest_digest_match=yes"); + println!("signature_value_match=yes"); + println!("signature_len={}", report.signature_len); + if trust_verify_args_present(&shared) { + println!("signer_trust_chain=yes"); + } + } Command::CatalogSignerRs256Prehash { path, signer_index, @@ -2979,6 +4876,191 @@ where .with_context(|| format!("nupkg-digest {}", path.display()))?; write_digest_output(encoding, &digest, output.as_deref())?; } + Command::NupkgSignatureContent { + path, + algorithm, + output, + } => { + use std::io::Write; + let content = nuget::unsigned_package_signature_content_path(&path, algorithm.into()) + .with_context(|| format!("nupkg-signature-content {}", path.display()))?; + match output { + Some(path) => std::fs::write(&path, &content) + .with_context(|| format!("write {}", path.display()))?, + None => std::io::stdout() + .write_all(&content) + .context("write NuGet signature content to stdout")?, + } + } + Command::NupkgSignaturePkcs7 { + path, + algorithm, + cert, + key, + chain_certs, + timestamp_url, + timestamp_digest, + output, + } => { + let content = nuget::unsigned_package_signature_content_path(&path, algorithm.into()) + .with_context(|| format!("nupkg-signature-pkcs7 {}", path.display()))?; + let pkcs7 = + sign_pkcs7_id_data(&content, &cert, &key, chain_certs, algorithm.into()) + .with_context(|| { + format!( + "nupkg-signature-pkcs7 create CMS for {}", + path.display() + ) + })?; + let pkcs7 = timestamp_pkcs7_if_requested( + &pkcs7, + timestamp_url, + timestamp_digest, + "nupkg-signature-pkcs7", + )?; + std::fs::write(&output, &pkcs7).with_context(|| format!("write {}", output.display()))?; + println!("output={}", output.display()); + println!("package_hash_algorithm={}", nuget_hash_alg_label(algorithm.into())); + println!("signature_len={}", pkcs7.len()); + } + Command::NupkgSignaturePkcs7Prehash { + path, + algorithm, + encoding, + output, + } => { + let content = nuget::unsigned_package_signature_content_path(&path, algorithm.into()) + .with_context(|| { + format!("nupkg-signature-pkcs7-prehash {}", path.display()) + })?; + let prehash = pkcs7_id_data_remote_prehash(&content, algorithm.into())?; + write_digest_output(encoding, &prehash, output.as_deref())?; + } + Command::NupkgSignaturePkcs7FromSignature { + path, + algorithm, + cert, + chain_certs, + signature, + timestamp_url, + timestamp_digest, + output, + } => { + let content = nuget::unsigned_package_signature_content_path(&path, algorithm.into()) + .with_context(|| { + format!( + "nupkg-signature-pkcs7-from-signature {}", + path.display() + ) + })?; + let signature_bytes = std::fs::read(&signature) + .with_context(|| format!("read {}", signature.display()))?; + let pkcs7 = sign_pkcs7_id_data_with_external_signature( + &content, + &cert, + chain_certs, + algorithm.into(), + &signature_bytes, + ) + .with_context(|| { + format!( + "nupkg-signature-pkcs7-from-signature create CMS for {}", + path.display() + ) + })?; + let pkcs7 = timestamp_pkcs7_if_requested( + &pkcs7, + timestamp_url, + timestamp_digest, + "nupkg-signature-pkcs7-from-signature", + )?; + std::fs::write(&output, &pkcs7).with_context(|| format!("write {}", output.display()))?; + println!("output={}", output.display()); + println!("package_hash_algorithm={}", nuget_hash_alg_label(algorithm.into())); + println!("signature_len={}", pkcs7.len()); + } + Command::NupkgSign { + path, + algorithm, + cert, + key, + chain_certs, + timestamp_url, + timestamp_digest, + output, + overwrite, + } => { + let content = nuget::unsigned_package_signature_content_path(&path, algorithm.into()) + .with_context(|| format!("nupkg-sign {}", path.display()))?; + let pkcs7 = + sign_pkcs7_id_data(&content, &cert, &key, chain_certs, algorithm.into()) + .with_context(|| { + format!("nupkg-sign create CMS for {}", path.display()) + })?; + let pkcs7 = timestamp_pkcs7_if_requested( + &pkcs7, + timestamp_url, + timestamp_digest, + "nupkg-sign", + )?; + nuget::embed_signature_path(&path, &output, &pkcs7, overwrite) + .with_context(|| format!("nupkg-sign embed signature into {}", output.display()))?; + println!("output={}", output.display()); + println!("package_hash_algorithm={}", nuget_hash_alg_label(algorithm.into())); + println!("embedded_signature={}", nuget::PACKAGE_SIGNATURE_FILE_NAME); + println!("signature_len={}", pkcs7.len()); + } + Command::NupkgVerifySignatureContent { path, content } => { + let content_bytes = + std::fs::read(&content).with_context(|| format!("read {}", content.display()))?; + let parsed = nuget::verify_unsigned_package_signature_content_path(&path, &content_bytes) + .with_context(|| format!("nupkg-verify-signature-content {}", path.display()))?; + println!( + "package_hash_algorithm={}", + nuget_hash_alg_label(parsed.hash_algorithm) + ); + println!("package_hash={}", hex_lower(&parsed.package_hash)); + println!("package_hash_match=yes"); + } + Command::NupkgVerifySignature { + path, + algorithm, + shared, + } => { + let alg = algorithm.into(); + let signature_der = nuget::extract_signature_path(&path) + .with_context(|| format!("nupkg-verify-signature {}", path.display()))?; + let content = nuget::signed_package_signature_content_path(&path, alg) + .with_context(|| format!("nupkg-verify-signature {}", path.display()))?; + let parsed = nuget::parse_signature_content(&content) + .context("parse reconstructed NuGet signature content")?; + let opts = trust_verify_options_from_shared(&shared)?; + let report = trust_verify_detached_bytes(&content, &signature_der, &opts) + .with_context(|| format!("nupkg-verify-signature {}", path.display()))?; + print_trust_ok("nupkg-verify-signature", &report); + println!("signature_present=yes"); + println!("package_hash_algorithm={}", nuget_hash_alg_label(alg)); + println!("package_hash={}", hex_lower(&parsed.package_hash)); + println!("package_hash_match=yes"); + println!("signature_len={}", signature_der.len()); + } + Command::NupkgEmbedSignature { + path, + signature, + output, + overwrite, + } => { + let signature_der = + std::fs::read(&signature).with_context(|| format!("read {}", signature.display()))?; + nuget::embed_signature_path(&path, &output, &signature_der, overwrite) + .with_context(|| format!("nupkg-embed-signature {}", path.display()))?; + println!( + "embedded_signature={}\noutput={}\nsignature_len={}", + nuget::PACKAGE_SIGNATURE_FILE_NAME, + output.display(), + signature_der.len() + ); + } Command::VsixSignatureInfo { path } => { let info = vsix::inspect_vsix_path(&path) .with_context(|| format!("vsix-signature-info {}", path.display()))?; @@ -3000,6 +5082,382 @@ where } println!("entries={}", info.package.entries.len()); } + Command::VsixEmbedSignatureXml { + path, + signature_xml, + output, + overwrite, + } => { + let xml = std::fs::read(&signature_xml) + .with_context(|| format!("read {}", signature_xml.display()))?; + vsix::embed_signature_xml_path(&path, &output, &xml, overwrite) + .with_context(|| format!("vsix-embed-signature-xml {}", path.display()))?; + println!( + "embedded_signature_xml={}\noutput={}\nsignature_xml_len={}", + vsix::DEFAULT_VSIX_SIGNATURE_PART, + output.display(), + xml.len() + ); + } + Command::VsixSignatureReferenceXml { + path, + algorithm, + output, + } => { + use std::io::Write; + let xml = vsix::signature_reference_xml_path(&path, algorithm.into()) + .with_context(|| format!("vsix-signature-reference-xml {}", path.display()))?; + match output { + Some(path) => { + std::fs::write(&path, &xml).with_context(|| format!("write {}", path.display()))? + } + None => std::io::stdout() + .write_all(&xml) + .context("write VSIX signature reference XML to stdout")?, + } + } + Command::VsixSignatureXml { + path, + algorithm, + cert, + key, + output, + } => { + use std::io::Write; + let algorithm = vsix::VsixHashAlgorithm::from(algorithm); + let cert_bytes = std::fs::read(&cert).with_context(|| format!("read {}", cert.display()))?; + rdp::parse_certificate(&cert_bytes) + .with_context(|| format!("parse signer certificate {}", cert.display()))?; + let key_bytes = + std::fs::read(&key).with_context(|| format!("read {}", key.display()))?; + let private_key = rdp::parse_rsa_private_key(&key_bytes) + .with_context(|| format!("parse RSA private key {}", key.display()))?; + let signed_info = vsix::signed_info_xml_path(&path, algorithm) + .with_context(|| format!("vsix-signature-xml {}", path.display()))?; + let signature = sign_xml_signed_info(algorithm, private_key, &signed_info)?; + let xml = vsix::signature_xml_from_signed_info(&signed_info, &signature, Some(&cert_bytes)) + .into_bytes(); + match output { + Some(path) => { + std::fs::write(&path, &xml).with_context(|| format!("write {}", path.display()))? + } + None => std::io::stdout() + .write_all(&xml) + .context("write VSIX signature XML to stdout")?, + } + } + Command::VsixSignatureXmlPrehash { + path, + algorithm, + encoding, + output, + } => { + let algorithm = vsix::VsixHashAlgorithm::from(algorithm); + let signed_info = vsix::signed_info_xml_path(&path, algorithm) + .with_context(|| format!("vsix-signature-xml-prehash {}", path.display()))?; + let prehash = xml_signed_info_remote_prehash(algorithm, &signed_info); + write_digest_output(encoding, &prehash, output.as_deref())?; + } + Command::VsixSignatureXmlFromSignature { + path, + algorithm, + cert, + signature, + output, + } => { + use std::io::Write; + let algorithm = vsix::VsixHashAlgorithm::from(algorithm); + let cert_bytes = + std::fs::read(&cert).with_context(|| format!("read {}", cert.display()))?; + rdp::parse_certificate(&cert_bytes) + .with_context(|| format!("parse signer certificate {}", cert.display()))?; + let signature_bytes = std::fs::read(&signature) + .with_context(|| format!("read {}", signature.display()))?; + let signed_info = vsix::signed_info_xml_path(&path, algorithm).with_context(|| { + format!("vsix-signature-xml-from-signature {}", path.display()) + })?; + let xml = + vsix::signature_xml_from_signed_info(&signed_info, &signature_bytes, Some(&cert_bytes)) + .into_bytes(); + match output { + Some(path) => { + std::fs::write(&path, &xml).with_context(|| format!("write {}", path.display()))? + } + None => std::io::stdout() + .write_all(&xml) + .context("write VSIX signature XML to stdout")?, + } + } + Command::VsixSign { + path, + algorithm, + cert, + key, + output, + overwrite, + } => { + let algorithm = vsix::VsixHashAlgorithm::from(algorithm); + let cert_bytes = std::fs::read(&cert).with_context(|| format!("read {}", cert.display()))?; + rdp::parse_certificate(&cert_bytes) + .with_context(|| format!("parse signer certificate {}", cert.display()))?; + let key_bytes = + std::fs::read(&key).with_context(|| format!("read {}", key.display()))?; + let private_key = rdp::parse_rsa_private_key(&key_bytes) + .with_context(|| format!("parse RSA private key {}", key.display()))?; + let signed_info = vsix::signed_info_xml_path(&path, algorithm) + .with_context(|| format!("vsix-sign {}", path.display()))?; + let signature = sign_xml_signed_info(algorithm, private_key, &signed_info)?; + let xml = + vsix::signature_xml_from_signed_info(&signed_info, &signature, Some(&cert_bytes)) + .into_bytes(); + vsix::embed_signature_xml_path(&path, &output, &xml, overwrite) + .with_context(|| format!("vsix-sign embed signature XML into {}", output.display()))?; + println!("output={}", output.display()); + println!("signature_xml_part={}", vsix::DEFAULT_VSIX_SIGNATURE_PART); + println!("reference_digest_algorithm={}", vsix_hash_alg_label(algorithm)); + println!("signature_xml_len={}", xml.len()); + } + Command::VsixVerifySignatureReferenceXml { + path, + signature_xml, + algorithm, + } => { + let algorithm = vsix::VsixHashAlgorithm::from(algorithm); + let xml = std::fs::read(&signature_xml) + .with_context(|| format!("read {}", signature_xml.display()))?; + let references = vsix::verify_signature_reference_xml_path(&path, &xml, algorithm) + .with_context(|| { + format!( + "vsix-verify-signature-reference-xml {}", + path.display() + ) + })?; + println!("reference_digest_algorithm={}", vsix_hash_alg_label(algorithm)); + println!("reference_count={references}"); + println!("reference_digest_match=yes"); + } + Command::VsixVerifySignatureXml { + path, + signature_xml, + cert, + algorithm, + shared, + } => { + let algorithm = vsix::VsixHashAlgorithm::from(algorithm); + let xml = std::fs::read(&signature_xml) + .with_context(|| format!("read {}", signature_xml.display()))?; + let references = vsix::verify_signature_reference_xml_path(&path, &xml, algorithm) + .with_context(|| format!("vsix-verify-signature-xml {}", path.display()))?; + let cert_bytes = std::fs::read(&cert).with_context(|| format!("read {}", cert.display()))?; + let signer_cert = rdp::parse_certificate(&cert_bytes) + .with_context(|| format!("parse signer certificate {}", cert.display()))?; + let signed_info = vsix::signed_info_xml_from_signature_xml(&xml)?; + let signature = vsix::signature_value_from_signature_xml(&xml)?; + verify_xml_signed_info(algorithm, &signer_cert, &signed_info, &signature)?; + let trust_anchor_count = if trust_verify_args_present(&shared) { + Some(verify_xml_signer_certificate_trust(&cert_bytes, &shared)?) + } else { + None + }; + println!("reference_digest_algorithm={}", vsix_hash_alg_label(algorithm)); + println!("reference_count={references}"); + println!("reference_digest_match=yes"); + println!("signature_value_match=yes"); + if let Some(count) = trust_anchor_count { + println!("signer_trust_chain=yes"); + println!("trust_anchor_count={count}"); + } + } + Command::VsixVerifySignature { + path, + cert, + algorithm, + shared, + } => { + let algorithm = vsix::VsixHashAlgorithm::from(algorithm); + let xml = vsix::extract_signature_xml_path(&path) + .with_context(|| format!("vsix-verify-signature {}", path.display()))?; + let references = vsix::verify_signature_reference_xml_path(&path, &xml, algorithm) + .with_context(|| format!("vsix-verify-signature {}", path.display()))?; + let cert_bytes = match cert { + Some(cert) => std::fs::read(&cert) + .with_context(|| format!("read {}", cert.display()))?, + None => vsix::signer_certificate_from_signature_xml(&xml) + .context("read embedded VSIX signer certificate")?, + }; + let signer_cert = + rdp::parse_certificate(&cert_bytes).context("parse VSIX signer certificate")?; + let signed_info = vsix::signed_info_xml_from_signature_xml(&xml)?; + let signature = vsix::signature_value_from_signature_xml(&xml)?; + verify_xml_signed_info(algorithm, &signer_cert, &signed_info, &signature)?; + let trust_anchor_count = if trust_verify_args_present(&shared) { + Some(verify_xml_signer_certificate_trust(&cert_bytes, &shared)?) + } else { + None + }; + println!("vsix-verify-signature: ok"); + println!("signature_xml_present=yes"); + println!("signature_xml_part={}", vsix::DEFAULT_VSIX_SIGNATURE_PART); + println!("reference_digest_algorithm={}", vsix_hash_alg_label(algorithm)); + println!("reference_count={references}"); + println!("reference_digest_match=yes"); + println!("signature_value_match=yes"); + if let Some(count) = trust_anchor_count { + println!("signer_trust_chain=yes"); + println!("trust_anchor_count={count}"); + } + } + Command::AppinstallerInfo { path, signature } => { + let text = + std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + let info = parse_appinstaller_descriptor(&text) + .with_context(|| format!("appinstaller-info {}", path.display()))?; + println!("root={}", info.root); + println!("namespace={}", info.namespace.unwrap_or("-".to_string())); + println!( + "main_package={}", + if info.has_main_package { "yes" } else { "no" } + ); + println!( + "main_bundle={}", + if info.has_main_bundle { "yes" } else { "no" } + ); + println!("publisher={}", info.publisher.unwrap_or("-".to_string())); + if let Some(signature) = signature { + let metadata = std::fs::metadata(&signature) + .with_context(|| format!("stat {}", signature.display()))?; + println!("companion_signature={}", signature.display()); + println!("companion_signature_len={}", metadata.len()); + } else { + println!("companion_signature=-"); + println!("companion_signature_len=-"); + } + } + Command::AppinstallerVerifyCompanion { + path, + signature, + shared, + } => { + let text = + std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + parse_appinstaller_descriptor(&text) + .with_context(|| format!("appinstaller-verify-companion {}", path.display()))?; + let sig_bytes = std::fs::read(&signature) + .with_context(|| format!("read {}", signature.display()))?; + let opts = trust_verify_options_from_shared(&shared)?; + let report = trust_verify_detached_bytes(text.as_bytes(), &sig_bytes, &opts) + .with_context(|| format!("appinstaller-verify-companion {}", path.display()))?; + print_trust_ok("appinstaller-verify-companion", &report); + } + Command::AppinstallerSignCompanion { + path, + cert, + key, + chain_certs, + digest, + timestamp_url, + timestamp_digest, + output, + } => { + let text = + std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + parse_appinstaller_descriptor(&text) + .with_context(|| format!("appinstaller-sign-companion {}", path.display()))?; + let pkcs7 = sign_pkcs7_id_data(text.as_bytes(), &cert, &key, chain_certs, digest) + .with_context(|| { + format!( + "appinstaller-sign-companion create detached PKCS#7 for {}", + path.display() + ) + })?; + let pkcs7 = timestamp_pkcs7_if_requested( + &pkcs7, + timestamp_url, + timestamp_digest, + "appinstaller-sign-companion", + )?; + std::fs::write(&output, &pkcs7).with_context(|| format!("write {}", output.display()))?; + println!("output={}", output.display()); + println!("digest={digest:?}"); + println!("companion_signature_len={}", pkcs7.len()); + } + Command::AppinstallerSignCompanionPrehash { + path, + digest, + encoding, + output, + } => { + let text = + std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + parse_appinstaller_descriptor(&text) + .with_context(|| format!("appinstaller-sign-companion-prehash {}", path.display()))?; + let prehash = pkcs7_id_data_remote_prehash(text.as_bytes(), digest)?; + write_digest_output(encoding, &prehash, output.as_deref())?; + } + Command::AppinstallerSignCompanionFromSignature { + path, + cert, + chain_certs, + digest, + signature, + timestamp_url, + timestamp_digest, + output, + } => { + let text = + std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + parse_appinstaller_descriptor(&text).with_context(|| { + format!( + "appinstaller-sign-companion-from-signature {}", + path.display() + ) + })?; + let signature_bytes = std::fs::read(&signature) + .with_context(|| format!("read {}", signature.display()))?; + let pkcs7 = sign_pkcs7_id_data_with_external_signature( + text.as_bytes(), + &cert, + chain_certs, + digest, + &signature_bytes, + ) + .with_context(|| { + format!( + "appinstaller-sign-companion-from-signature create detached PKCS#7 for {}", + path.display() + ) + })?; + let pkcs7 = timestamp_pkcs7_if_requested( + &pkcs7, + timestamp_url, + timestamp_digest, + "appinstaller-sign-companion-from-signature", + )?; + std::fs::write(&output, &pkcs7).with_context(|| format!("write {}", output.display()))?; + println!("output={}", output.display()); + println!("digest={digest:?}"); + println!("companion_signature_len={}", pkcs7.len()); + } + Command::AppinstallerSetPublisher { + path, + publisher, + output, + } => { + let text = + std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + let updated = update_appinstaller_publisher(&text, &publisher) + .with_context(|| format!("appinstaller-set-publisher {}", path.display()))?; + std::fs::write(&output, updated).with_context(|| format!("write {}", output.display()))?; + println!("output={}", output.display()); + println!("publisher={publisher}"); + } + Command::BusinessCentralAppInfo { path } => { + let info = inspect_business_central_app(&path) + .with_context(|| format!("business-central-app-info {}", path.display()))?; + println!("business_central_app={}", if info.is_navx { "yes" } else { "no" }); + println!("header={}", if info.is_navx { "NAVX" } else { "-" }); + println!("len={}", info.len); + } } Ok(()) } diff --git a/crates/psign-opc-sign/Cargo.toml b/crates/psign-opc-sign/Cargo.toml index c742ba7..8a6186a 100644 --- a/crates/psign-opc-sign/Cargo.toml +++ b/crates/psign-opc-sign/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true [dependencies] anyhow = "1" +base64 = "0.22" sha2 = "0.10" zip = { version = "0.6.6", default-features = false, features = ["deflate"] } diff --git a/crates/psign-opc-sign/src/nuget.rs b/crates/psign-opc-sign/src/nuget.rs index d6fe58f..3ac9675 100644 --- a/crates/psign-opc-sign/src/nuget.rs +++ b/crates/psign-opc-sign/src/nuget.rs @@ -1,9 +1,14 @@ -use crate::opc::{PackageSummary, inspect_package_path}; +use crate::opc::{PackageSummary, inspect_package_path, normalize_zip_part_name}; use anyhow::{Context, Result, anyhow}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use sha2::{Digest, Sha256, Sha384, Sha512}; +use std::fs::File; +use std::io::{Read, Seek, Write}; use std::path::Path; +use zip::write::FileOptions; pub const PACKAGE_SIGNATURE_FILE_NAME: &str = ".signature.p7s"; +pub const SIGNATURE_CONTENT_VERSION: &str = "1"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum NuGetHashAlgorithm { @@ -28,6 +33,15 @@ impl NuGetHashAlgorithm { Self::Sha512 => Sha512::digest(bytes).to_vec(), } } + + pub fn from_oid(oid: &str) -> Option { + match oid { + "2.16.840.1.101.3.4.2.1" => Some(Self::Sha256), + "2.16.840.1.101.3.4.2.2" => Some(Self::Sha384), + "2.16.840.1.101.3.4.2.3" => Some(Self::Sha512), + _ => None, + } + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -38,6 +52,12 @@ pub struct NuGetPackageInfo { pub signature_is_stored: Option, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NuGetSignatureContent { + pub hash_algorithm: NuGetHashAlgorithm, + pub package_hash: Vec, +} + pub fn inspect_nupkg_path(path: &Path) -> Result { let package = inspect_package_path(path)?; let signature_len = package @@ -67,10 +87,286 @@ pub fn unsigned_package_digest_path(path: &Path, algorithm: NuGetHashAlgorithm) Ok(algorithm.hash(&bytes)) } +pub fn canonical_unsigned_package_bytes_path(path: &Path) -> Result> { + let reader = File::open(path).with_context(|| format!("open {}", path.display()))?; + canonical_unsigned_package_bytes(reader) + .with_context(|| format!("canonicalize unsigned NuGet package {}", path.display())) +} + +pub fn canonical_unsigned_package_bytes(reader: R) -> Result> +where + R: Read + Seek, +{ + let mut out = std::io::Cursor::new(Vec::new()); + write_package_without_signature_impl(reader, &mut out, false)?; + Ok(out.into_inner()) +} + +pub fn signed_package_unsigned_bytes_path(path: &Path) -> Result> { + let reader = File::open(path).with_context(|| format!("open {}", path.display()))?; + let mut out = std::io::Cursor::new(Vec::new()); + write_package_without_signature_impl(reader, &mut out, true) + .with_context(|| format!("remove NuGet signature from {}", path.display()))?; + Ok(out.into_inner()) +} + +pub fn signed_package_signature_content_path( + path: &Path, + algorithm: NuGetHashAlgorithm, +) -> Result> { + let unsigned = signed_package_unsigned_bytes_path(path)?; + Ok(signature_content_bytes( + algorithm, + &algorithm.hash(&unsigned), + )) +} + pub fn package_hash_property_name(algorithm: NuGetHashAlgorithm) -> String { format!("{}-Hash", algorithm.oid()) } +pub fn unsigned_package_signature_content_path( + path: &Path, + algorithm: NuGetHashAlgorithm, +) -> Result> { + let unsigned = canonical_unsigned_package_bytes_path(path)?; + let digest = algorithm.hash(&unsigned); + Ok(signature_content_bytes(algorithm, &digest)) +} + +pub fn signature_content_bytes(algorithm: NuGetHashAlgorithm, package_hash: &[u8]) -> Vec { + format!( + "Version:{}\n\n{}:{}\n\n", + SIGNATURE_CONTENT_VERSION, + package_hash_property_name(algorithm), + BASE64_STANDARD.encode(package_hash) + ) + .into_bytes() +} + +pub fn parse_signature_content(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).context("NuGet signature content is not UTF-8")?; + let mut sections = text.split("\n\n"); + let header = sections + .next() + .ok_or_else(|| anyhow!("NuGet signature content is missing header section"))?; + let hash_section = sections + .next() + .ok_or_else(|| anyhow!("NuGet signature content is missing package hash section"))?; + + let mut version = None; + for line in header.lines().filter(|line| !line.is_empty()) { + let (key, value) = split_signature_content_pair(line)?; + if key == "Version" { + version = Some(value); + } + } + match version { + Some(SIGNATURE_CONTENT_VERSION) => {} + Some(value) => { + return Err(anyhow!( + "unsupported NuGet signature content version {value}; expected {SIGNATURE_CONTENT_VERSION}" + )); + } + None => return Err(anyhow!("NuGet signature content is missing Version")), + } + + for line in hash_section.lines().filter(|line| !line.is_empty()) { + let (key, value) = split_signature_content_pair(line)?; + if let Some(oid) = key.strip_suffix("-Hash") + && let Some(hash_algorithm) = NuGetHashAlgorithm::from_oid(oid) + { + let package_hash = BASE64_STANDARD + .decode(value) + .context("NuGet signature content package hash is not valid base64")?; + if package_hash.is_empty() { + return Err(anyhow!("NuGet signature content package hash is empty")); + } + return Ok(NuGetSignatureContent { + hash_algorithm, + package_hash, + }); + } + } + + Err(anyhow!( + "NuGet signature content does not contain a supported package hash property" + )) +} + +pub fn verify_unsigned_package_signature_content_path( + path: &Path, + content: &[u8], +) -> Result { + let parsed = parse_signature_content(content)?; + let unsigned = canonical_unsigned_package_bytes_path(path)?; + let actual = parsed.hash_algorithm.hash(&unsigned); + if actual != parsed.package_hash { + return Err(anyhow!( + "NuGet package hash mismatch for {}; signature content records a different unsigned package digest", + path.display() + )); + } + Ok(parsed) +} + +pub fn extract_signature_path(path: &Path) -> Result> { + let reader = File::open(path).with_context(|| format!("open {}", path.display()))?; + extract_signature(reader) + .with_context(|| format!("extract NuGet signature from {}", path.display())) +} + +pub fn extract_signature(reader: R) -> Result> +where + R: Read + Seek, +{ + let mut input = zip::ZipArchive::new(reader).context("open NuGet ZIP")?; + let mut file = input + .by_name(PACKAGE_SIGNATURE_FILE_NAME) + .with_context(|| format!("package does not contain {PACKAGE_SIGNATURE_FILE_NAME}"))?; + let mut signature = Vec::new(); + file.read_to_end(&mut signature) + .context("read NuGet package signature")?; + if signature.is_empty() { + return Err(anyhow!("NuGet package signature payload is empty")); + } + Ok(signature) +} + +pub fn write_package_without_signature(reader: R, writer: W) -> Result<()> +where + R: Read + Seek, + W: Write + Seek, +{ + write_package_without_signature_impl(reader, writer, true) +} + +fn write_package_without_signature_impl( + reader: R, + writer: W, + require_signature: bool, +) -> Result<()> +where + R: Read + Seek, + W: Write + Seek, +{ + let mut input = zip::ZipArchive::new(reader).context("open NuGet ZIP")?; + let mut output = zip::ZipWriter::new(writer); + let mut had_signature = false; + + for i in 0..input.len() { + let mut file = input.by_index(i).context("read NuGet ZIP entry")?; + let name = normalize_zip_part_name(file.name())?; + if name == PACKAGE_SIGNATURE_FILE_NAME { + had_signature = true; + continue; + } + + let options = FileOptions::default().compression_method(file.compression()); + if file.is_dir() { + output.add_directory(name, options)?; + } else { + output.start_file(name, options)?; + std::io::copy(&mut file, &mut output)?; + } + } + + if require_signature && !had_signature { + return Err(anyhow!( + "package does not contain {PACKAGE_SIGNATURE_FILE_NAME}" + )); + } + + output.finish()?; + Ok(()) +} + +fn split_signature_content_pair(line: &str) -> Result<(&str, &str)> { + line.split_once(':') + .ok_or_else(|| anyhow!("invalid NuGet signature content line {line:?}")) + .and_then(|(key, value)| { + if key.is_empty() || value.is_empty() { + Err(anyhow!("invalid NuGet signature content line {line:?}")) + } else { + Ok((key, value)) + } + }) +} + +pub fn embed_signature_path( + input: &Path, + output: &Path, + signature_der: &[u8], + overwrite: bool, +) -> Result<()> { + if signature_der.is_empty() { + return Err(anyhow!("NuGet package signature payload is empty")); + } + let info = inspect_nupkg_path(input)?; + if info.signed && !overwrite { + return Err(anyhow!( + "{} already contains {}; pass overwrite to replace it", + input.display(), + PACKAGE_SIGNATURE_FILE_NAME + )); + } + let reader = File::open(input).with_context(|| format!("open {}", input.display()))?; + let writer = File::create(output).with_context(|| format!("create {}", output.display()))?; + embed_signature(reader, writer, signature_der, overwrite) + .with_context(|| format!("embed NuGet signature into {}", output.display())) +} + +pub fn embed_signature( + reader: R, + writer: W, + signature_der: &[u8], + overwrite: bool, +) -> Result<()> +where + R: Read + Seek, + W: Write + Seek, +{ + if signature_der.is_empty() { + return Err(anyhow!("NuGet package signature payload is empty")); + } + let mut input = zip::ZipArchive::new(reader).context("open NuGet ZIP")?; + let mut output = zip::ZipWriter::new(writer); + let mut had_signature = false; + + for i in 0..input.len() { + let mut file = input.by_index(i).context("read NuGet ZIP entry")?; + let name = normalize_zip_part_name(file.name())?; + if name == PACKAGE_SIGNATURE_FILE_NAME { + had_signature = true; + if overwrite { + continue; + } + return Err(anyhow!( + "package already contains {}; pass overwrite to replace it", + PACKAGE_SIGNATURE_FILE_NAME + )); + } + + let options = FileOptions::default().compression_method(file.compression()); + if file.is_dir() { + output.add_directory(name, options)?; + } else { + output.start_file(name, options)?; + std::io::copy(&mut file, &mut output)?; + } + } + + if !had_signature || overwrite { + output.start_file( + PACKAGE_SIGNATURE_FILE_NAME, + FileOptions::default().compression_method(zip::CompressionMethod::Stored), + )?; + output.write_all(signature_der)?; + } + output.finish()?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -117,6 +413,141 @@ mod tests { ); } + #[test] + fn signature_content_round_trips_package_hash_property() { + let bytes = signature_content_bytes(NuGetHashAlgorithm::Sha384, b"package-digest"); + + let parsed = parse_signature_content(&bytes).unwrap(); + + assert_eq!(parsed.hash_algorithm, NuGetHashAlgorithm::Sha384); + assert_eq!(parsed.package_hash, b"package-digest"); + assert_eq!( + String::from_utf8(bytes).unwrap(), + "Version:1\n\n2.16.840.1.101.3.4.2.2-Hash:cGFja2FnZS1kaWdlc3Q=\n\n" + ); + } + + #[test] + fn signature_content_rejects_unsupported_version() { + let err = parse_signature_content( + b"Version:2\n\n2.16.840.1.101.3.4.2.1-Hash:cGFja2FnZS1kaWdlc3Q=\n\n", + ) + .unwrap_err(); + + assert!(err.to_string().contains("unsupported")); + } + + #[test] + fn unsigned_package_signature_content_verifies_matching_digest() { + let zip = zip_with(&[("lib/net8.0/a.dll", b"pe")]); + let tmp = tempfile_path("unsigned-for-content.nupkg"); + std::fs::write(&tmp, &zip).unwrap(); + + let content = + unsigned_package_signature_content_path(&tmp, NuGetHashAlgorithm::Sha256).unwrap(); + let parsed = verify_unsigned_package_signature_content_path(&tmp, &content).unwrap(); + let canonical = canonical_unsigned_package_bytes_path(&tmp).unwrap(); + + assert_eq!(parsed.hash_algorithm, NuGetHashAlgorithm::Sha256); + assert_eq!( + parsed.package_hash, + NuGetHashAlgorithm::Sha256.hash(&canonical) + ); + let _ = std::fs::remove_file(tmp); + } + + #[test] + fn unsigned_package_signature_content_rejects_tampered_package() { + let tmp = tempfile_path("tampered-for-content.nupkg"); + std::fs::write(&tmp, zip_with(&[("lib/net8.0/a.dll", b"pe")])).unwrap(); + let content = + unsigned_package_signature_content_path(&tmp, NuGetHashAlgorithm::Sha256).unwrap(); + std::fs::write(&tmp, zip_with(&[("lib/net8.0/a.dll", b"changed")])).unwrap(); + + let err = verify_unsigned_package_signature_content_path(&tmp, &content).unwrap_err(); + + assert!(err.to_string().contains("hash mismatch")); + let _ = std::fs::remove_file(tmp); + } + + #[test] + fn embed_signature_adds_stored_root_signature() { + let zip = zip_with(&[("lib/net8.0/a.dll", b"pe")]); + let mut out = Cursor::new(Vec::new()); + + embed_signature(Cursor::new(zip), &mut out, b"cms", false).unwrap(); + let info = inspect_package_reader_for_test(out.into_inner()); + + assert_eq!( + info.entry(PACKAGE_SIGNATURE_FILE_NAME) + .map(|e| e.uncompressed_size), + Some(3) + ); + assert_eq!( + info.entry(PACKAGE_SIGNATURE_FILE_NAME) + .map(|e| e.compression.as_str()), + Some("Stored") + ); + } + + #[test] + fn embed_signature_rejects_existing_signature_without_overwrite() { + let zip = zip_with(&[(PACKAGE_SIGNATURE_FILE_NAME, b"old")]); + let err = + embed_signature(Cursor::new(zip), Cursor::new(Vec::new()), b"new", false).unwrap_err(); + + assert!(err.to_string().contains("already contains")); + } + + #[test] + fn embed_signature_replaces_existing_signature_with_overwrite() { + let zip = zip_with(&[(PACKAGE_SIGNATURE_FILE_NAME, b"old")]); + let mut out = Cursor::new(Vec::new()); + + embed_signature(Cursor::new(zip), &mut out, b"new-signature", true).unwrap(); + let info = inspect_package_reader_for_test(out.into_inner()); + + assert_eq!( + info.entry(PACKAGE_SIGNATURE_FILE_NAME) + .map(|e| e.uncompressed_size), + Some(13) + ); + } + + #[test] + fn extract_signature_returns_embedded_signature_bytes() { + let zip = zip_with(&[ + ("lib/net8.0/a.dll", b"pe"), + (PACKAGE_SIGNATURE_FILE_NAME, b"cms"), + ]); + + let signature = extract_signature(Cursor::new(zip)).unwrap(); + + assert_eq!(signature, b"cms"); + } + + #[test] + fn write_package_without_signature_removes_root_signature() { + let zip = zip_with(&[ + ("lib/net8.0/a.dll", b"pe"), + (PACKAGE_SIGNATURE_FILE_NAME, b"cms"), + ]); + let mut out = Cursor::new(Vec::new()); + + write_package_without_signature(Cursor::new(zip), &mut out).unwrap(); + let info = inspect_package_reader_for_test(out.into_inner()); + + assert!(info.entry(PACKAGE_SIGNATURE_FILE_NAME).is_none()); + assert_eq!( + info.entry("lib/net8.0/a.dll").map(|e| e.uncompressed_size), + Some(2) + ); + } + + fn inspect_package_reader_for_test(bytes: Vec) -> PackageSummary { + crate::opc::inspect_package_reader(Cursor::new(bytes)).unwrap() + } + fn tempfile_path(name: &str) -> std::path::PathBuf { let mut path = std::env::temp_dir(); path.push(format!("psign-opc-sign-{}-{name}", std::process::id())); diff --git a/crates/psign-opc-sign/src/vsix.rs b/crates/psign-opc-sign/src/vsix.rs index 25aa9de..1186562 100644 --- a/crates/psign-opc-sign/src/vsix.rs +++ b/crates/psign-opc-sign/src/vsix.rs @@ -1,6 +1,28 @@ -use crate::opc::{PackageSummary, inspect_package_path}; -use anyhow::Result; +use crate::opc::{ + CONTENT_TYPES_PART, OPC_SIGNATURE_ORIGIN_PART, OPC_SIGNATURES_PREFIX, PackageSummary, + ROOT_RELATIONSHIPS_PART, inspect_package_path, normalize_zip_part_name, +}; +use anyhow::{Context, Result, anyhow}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use sha2::{Digest, Sha256, Sha384, Sha512}; +use std::collections::BTreeMap; +use std::fs::File; +use std::io::{Read, Seek, Write}; use std::path::Path; +use zip::write::FileOptions; + +pub const DEFAULT_VSIX_SIGNATURE_PART: &str = + "package/services/digital-signature/xml-signature/psign-signature.psdsxs"; +const OPC_SIGNATURE_ORIGIN_RELS_PART: &str = + "package/services/digital-signature/_rels/origin.psdsor.rels"; +const OPC_SIGNATURE_ORIGIN_CONTENT_TYPE: &str = + "application/vnd.openxmlformats-package.digital-signature-origin"; +const OPC_SIGNATURE_XML_CONTENT_TYPE: &str = + "application/vnd.openxmlformats-package.digital-signature-xmlsignature+xml"; +const OPC_SIGNATURE_ORIGIN_REL_TYPE: &str = + "http://schemas.openxmlformats.org/package/2006/relationships/digital-signature/origin"; +const OPC_SIGNATURE_REL_TYPE: &str = + "http://schemas.openxmlformats.org/package/2006/relationships/digital-signature/signature"; #[derive(Clone, Debug, Eq, PartialEq)] pub struct VsixPackageInfo { @@ -8,6 +30,39 @@ pub struct VsixPackageInfo { pub has_opc_signature: bool, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VsixHashAlgorithm { + Sha256, + Sha384, + Sha512, +} + +impl VsixHashAlgorithm { + pub fn digest_uri(self) -> &'static str { + match self { + Self::Sha256 => "http://www.w3.org/2001/04/xmlenc#sha256", + Self::Sha384 => "http://www.w3.org/2001/04/xmldsig-more#sha384", + Self::Sha512 => "http://www.w3.org/2001/04/xmlenc#sha512", + } + } + + pub fn signature_uri(self) -> &'static str { + match self { + Self::Sha256 => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + Self::Sha384 => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384", + Self::Sha512 => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512", + } + } + + pub fn hash(self, bytes: &[u8]) -> Vec { + match self { + Self::Sha256 => Sha256::digest(bytes).to_vec(), + Self::Sha384 => Sha384::digest(bytes).to_vec(), + Self::Sha512 => Sha512::digest(bytes).to_vec(), + } + } +} + pub fn inspect_vsix_path(path: &Path) -> Result { let package = inspect_package_path(path)?; let has_opc_signature = @@ -17,3 +72,624 @@ pub fn inspect_vsix_path(path: &Path) -> Result { has_opc_signature, }) } + +pub fn signature_reference_xml_path(path: &Path, algorithm: VsixHashAlgorithm) -> Result> { + let reader = File::open(path).with_context(|| format!("open {}", path.display()))?; + signature_reference_xml(reader, algorithm) + .with_context(|| format!("create VSIX signature reference XML for {}", path.display())) +} + +pub fn signature_reference_xml(reader: R, algorithm: VsixHashAlgorithm) -> Result> +where + R: Read + Seek, +{ + let signed_info = signed_info_xml(reader, algorithm)?; + Ok(signature_xml_from_signed_info(&signed_info, &[], None).into_bytes()) +} + +pub fn signed_info_xml_path(path: &Path, algorithm: VsixHashAlgorithm) -> Result> { + let reader = File::open(path).with_context(|| format!("open {}", path.display()))?; + signed_info_xml(reader, algorithm) + .with_context(|| format!("create VSIX SignedInfo XML for {}", path.display())) +} + +pub fn signed_info_xml(reader: R, algorithm: VsixHashAlgorithm) -> Result> +where + R: Read + Seek, +{ + let references = reference_digests(reader, algorithm)?; + if references.is_empty() { + return Err(anyhow!( + "VSIX package has no non-signature parts to reference" + )); + } + + let mut xml = String::new(); + xml.push_str(""); + xml.push_str( + r#""#, + ); + xml.push_str(&format!( + r#""#, + algorithm.signature_uri() + )); + for (name, digest) in references { + xml.push_str(&format!( + r#"{}"#, + xml_escape_attr(&name), + algorithm.digest_uri(), + BASE64_STANDARD.encode(digest) + )); + } + xml.push_str(""); + Ok(xml.into_bytes()) +} + +pub fn signature_xml_path( + path: &Path, + algorithm: VsixHashAlgorithm, + signature_value: &[u8], + signer_cert_der: Option<&[u8]>, +) -> Result> { + let signed_info = signed_info_xml_path(path, algorithm)?; + Ok(signature_xml_from_signed_info(&signed_info, signature_value, signer_cert_der).into_bytes()) +} + +pub fn signature_xml_from_signed_info( + signed_info_xml: &[u8], + signature_value: &[u8], + signer_cert_der: Option<&[u8]>, +) -> String { + let signed_info = String::from_utf8_lossy(signed_info_xml); + let mut xml = String::new(); + xml.push_str(r#""#); + xml.push_str(&signed_info); + xml.push_str(""); + xml.push_str(&BASE64_STANDARD.encode(signature_value)); + xml.push_str(""); + if let Some(cert) = signer_cert_der { + xml.push_str(""); + xml.push_str(&BASE64_STANDARD.encode(cert)); + xml.push_str(""); + } + xml.push_str(""); + xml +} + +pub fn verify_signature_reference_xml_path( + path: &Path, + signature_xml: &[u8], + algorithm: VsixHashAlgorithm, +) -> Result { + let reader = File::open(path).with_context(|| format!("open {}", path.display()))?; + verify_signature_reference_xml(reader, signature_xml, algorithm) + .with_context(|| format!("verify VSIX signature references for {}", path.display())) +} + +pub fn verify_signature_reference_xml( + reader: R, + signature_xml: &[u8], + algorithm: VsixHashAlgorithm, +) -> Result +where + R: Read + Seek, +{ + let expected = reference_digests(reader, algorithm)?; + let actual = parse_reference_digests(signature_xml, algorithm)?; + if actual.len() != expected.len() { + return Err(anyhow!( + "VSIX signature reference count mismatch: expected {}, found {}", + expected.len(), + actual.len() + )); + } + for (name, digest) in &expected { + match actual.get(name) { + Some(actual_digest) if actual_digest == digest => {} + Some(_) => { + return Err(anyhow!( + "VSIX signature reference digest mismatch for {name}" + )); + } + None => return Err(anyhow!("VSIX signature reference missing for {name}")), + } + } + Ok(expected.len()) +} + +pub fn extract_signature_xml_path(path: &Path) -> Result> { + let reader = File::open(path).with_context(|| format!("open {}", path.display()))?; + extract_signature_xml(reader) + .with_context(|| format!("extract VSIX signature XML from {}", path.display())) +} + +pub fn extract_signature_xml(reader: R) -> Result> +where + R: Read + Seek, +{ + let mut archive = zip::ZipArchive::new(reader).context("open VSIX ZIP")?; + let mut signature_part = None; + for i in 0..archive.len() { + let file = archive.by_index(i).context("read VSIX ZIP entry")?; + let name = normalize_zip_part_name(file.name())?; + if !file.is_dir() + && name.starts_with(OPC_SIGNATURES_PREFIX) + && signature_part.replace(name.clone()).is_some() + { + return Err(anyhow!( + "VSIX package contains multiple OPC signature XML parts" + )); + } + } + let signature_part = + signature_part.ok_or_else(|| anyhow!("VSIX package does not contain OPC signature XML"))?; + let mut file = archive + .by_name(&signature_part) + .with_context(|| format!("read VSIX signature XML part {signature_part}"))?; + let mut xml = Vec::new(); + file.read_to_end(&mut xml) + .context("read VSIX signature XML")?; + if xml.is_empty() { + return Err(anyhow!("VSIX signature XML payload is empty")); + } + Ok(xml) +} + +fn reference_digests( + reader: R, + algorithm: VsixHashAlgorithm, +) -> Result>> +where + R: Read + Seek, +{ + let mut archive = zip::ZipArchive::new(reader).context("open VSIX ZIP")?; + let mut references = BTreeMap::new(); + for i in 0..archive.len() { + let mut file = archive.by_index(i).context("read VSIX ZIP entry")?; + let name = normalize_zip_part_name(file.name())?; + if file.is_dir() + || name == CONTENT_TYPES_PART + || name == ROOT_RELATIONSHIPS_PART + || is_opc_signature_entry(&name) + { + continue; + } + let mut bytes = Vec::with_capacity(file.size() as usize); + file.read_to_end(&mut bytes)?; + references.insert(name, algorithm.hash(&bytes)); + } + Ok(references) +} + +fn parse_reference_digests( + signature_xml: &[u8], + algorithm: VsixHashAlgorithm, +) -> Result>> { + let text = std::str::from_utf8(signature_xml).context("VSIX signature XML is not UTF-8")?; + let mut references = BTreeMap::new(); + let mut cursor = 0usize; + while let Some(reference_start) = text[cursor..].find("') + .map(|offset| reference_start + offset) + .ok_or_else(|| anyhow!("VSIX signature XML Reference tag is not closed"))?; + let close_start = text[tag_end..] + .find("") + .map(|offset| tag_end + offset) + .ok_or_else(|| anyhow!("VSIX signature XML Reference element is not closed"))?; + let tag = &text[reference_start..=tag_end]; + let body = &text[tag_end + 1..close_start]; + let uri = xml_attr(tag, "URI") + .ok_or_else(|| anyhow!("VSIX signature XML Reference is missing URI"))?; + let name = uri + .strip_prefix('/') + .ok_or_else(|| anyhow!("VSIX signature XML Reference URI must be package-absolute"))?; + let name = normalize_zip_part_name(name)?; + if !body.contains(&format!( + r#""#, + algorithm.digest_uri() + )) { + return Err(anyhow!( + "VSIX signature XML Reference for {name} does not use expected digest algorithm" + )); + } + let digest_value = element_text(body, "DigestValue").ok_or_else(|| { + anyhow!("VSIX signature XML Reference for {name} is missing DigestValue") + })?; + let digest = BASE64_STANDARD + .decode(digest_value) + .context("VSIX signature XML DigestValue is not valid base64")?; + if references.insert(name.clone(), digest).is_some() { + return Err(anyhow!("duplicate VSIX signature XML Reference for {name}")); + } + cursor = close_start + "".len(); + } + Ok(references) +} + +pub fn signed_info_xml_from_signature_xml(signature_xml: &[u8]) -> Result> { + let text = std::str::from_utf8(signature_xml).context("VSIX signature XML is not UTF-8")?; + let start = text + .find("") + .ok_or_else(|| anyhow!("VSIX signature XML is missing SignedInfo"))?; + let end = text[start..] + .find("") + .map(|offset| start + offset + "".len()) + .ok_or_else(|| anyhow!("VSIX signature XML SignedInfo element is not closed"))?; + Ok(text.as_bytes()[start..end].to_vec()) +} + +pub fn signature_value_from_signature_xml(signature_xml: &[u8]) -> Result> { + let text = std::str::from_utf8(signature_xml).context("VSIX signature XML is not UTF-8")?; + let value = element_text(text, "SignatureValue") + .ok_or_else(|| anyhow!("VSIX signature XML is missing SignatureValue"))?; + let signature = BASE64_STANDARD + .decode(value) + .context("VSIX SignatureValue is not valid base64")?; + if signature.is_empty() { + return Err(anyhow!("VSIX SignatureValue is empty")); + } + Ok(signature) +} + +pub fn signer_certificate_from_signature_xml(signature_xml: &[u8]) -> Result> { + let text = std::str::from_utf8(signature_xml).context("VSIX signature XML is not UTF-8")?; + let value = element_text(text, "X509Certificate") + .ok_or_else(|| anyhow!("VSIX signature XML is missing X509Certificate"))?; + let cert = BASE64_STANDARD + .decode(value) + .context("VSIX X509Certificate is not valid base64")?; + if cert.is_empty() { + return Err(anyhow!("VSIX X509Certificate is empty")); + } + Ok(cert) +} + +fn element_text<'a>(text: &'a str, name: &str) -> Option<&'a str> { + let open = format!("<{name}>"); + let close = format!(""); + let start = text.find(&open)? + open.len(); + let end = text[start..].find(&close)? + start; + Some(&text[start..end]) +} + +fn xml_attr(tag: &str, name: &str) -> Option { + let needle = format!("{name}=\""); + let start = tag.find(&needle)? + needle.len(); + let end = tag[start..].find('"')? + start; + Some(tag[start..end].to_owned()) +} + +fn xml_escape_attr(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} + +fn is_opc_signature_entry(name: &str) -> bool { + name == OPC_SIGNATURE_ORIGIN_PART + || name == OPC_SIGNATURE_ORIGIN_RELS_PART + || name.starts_with(OPC_SIGNATURES_PREFIX) +} + +fn ensure_signature_content_types(bytes: &[u8]) -> Result> { + let text = if bytes.is_empty() { + r#""#.to_string() + } else { + std::str::from_utf8(bytes) + .context("OPC [Content_Types].xml is not UTF-8")? + .to_string() + }; + let mut text = ensure_content_type_default(&text, "psdsor", OPC_SIGNATURE_ORIGIN_CONTENT_TYPE)?; + text = ensure_content_type_default(&text, "psdsxs", OPC_SIGNATURE_XML_CONTENT_TYPE)?; + Ok(text.into_bytes()) +} + +fn ensure_content_type_default(text: &str, extension: &str, content_type: &str) -> Result { + let text = expand_self_closing_xml_root(text, "Types"); + if text.contains(&format!(r#"Extension="{extension}""#)) + || text.contains(&format!(r#"ContentType="{content_type}""#)) + { + return Ok(text); + } + let insert_at = text + .rfind("") + .ok_or_else(|| anyhow!("OPC [Content_Types].xml is missing "))?; + let default = format!(r#""#); + let mut out = String::with_capacity(text.len() + default.len()); + out.push_str(&text[..insert_at]); + out.push_str(&default); + out.push_str(&text[insert_at..]); + Ok(out) +} + +fn ensure_root_signature_origin_relationship(bytes: &[u8]) -> Result> { + ensure_relationship( + bytes, + OPC_SIGNATURE_ORIGIN_REL_TYPE, + &format!("/{OPC_SIGNATURE_ORIGIN_PART}"), + "PsignSignatureOrigin", + ) +} + +fn signature_origin_relationships_xml() -> Vec { + format!( + r#""# + ) + .into_bytes() +} + +fn ensure_relationship( + bytes: &[u8], + rel_type: &str, + target: &str, + preferred_id: &str, +) -> Result> { + let mut text = if bytes.is_empty() { + r#""#.to_string() + } else { + std::str::from_utf8(bytes) + .context("OPC relationships part is not UTF-8")? + .to_string() + }; + text = expand_self_closing_xml_root(&text, "Relationships"); + if text.contains(&format!(r#"Type="{rel_type}""#)) { + return Ok(text.into_bytes()); + } + let insert_at = text + .rfind("") + .ok_or_else(|| anyhow!("OPC relationships part is missing "))?; + let id = unique_relationship_id(&text, preferred_id); + let relationship = format!(r#""#); + let mut out = String::with_capacity(text.len() + relationship.len()); + out.push_str(&text[..insert_at]); + out.push_str(&relationship); + out.push_str(&text[insert_at..]); + Ok(out.into_bytes()) +} + +fn expand_self_closing_xml_root(text: &str, root: &str) -> String { + let Some(start) = text.find(&format!("<{root}")) else { + return text.to_string(); + }; + let Some(end) = text[start..].find("/>") else { + return text.to_string(); + }; + let end = start + end; + let mut out = String::with_capacity(text.len() + root.len() + 3); + out.push_str(&text[..end]); + out.push('>'); + out.push_str(&format!("")); + out.push_str(&text[end + 2..]); + out +} + +fn unique_relationship_id(text: &str, preferred_id: &str) -> String { + if !text.contains(&format!(r#"Id="{preferred_id}""#)) { + return preferred_id.to_string(); + } + for i in 2usize.. { + let candidate = format!("{preferred_id}{i}"); + if !text.contains(&format!(r#"Id="{candidate}""#)) { + return candidate; + } + } + unreachable!() +} + +pub fn embed_signature_xml_path( + input: &Path, + output: &Path, + signature_xml: &[u8], + overwrite: bool, +) -> Result<()> { + if signature_xml.is_empty() { + return Err(anyhow!("VSIX signature XML payload is empty")); + } + let info = inspect_vsix_path(input)?; + if info.has_opc_signature && !overwrite { + return Err(anyhow!( + "{} already contains OPC signature parts; pass overwrite to replace them", + input.display() + )); + } + let reader = File::open(input).with_context(|| format!("open {}", input.display()))?; + let writer = File::create(output).with_context(|| format!("create {}", output.display()))?; + embed_signature_xml(reader, writer, signature_xml, overwrite) + .with_context(|| format!("embed VSIX signature XML into {}", output.display())) +} + +pub fn embed_signature_xml( + reader: R, + writer: W, + signature_xml: &[u8], + overwrite: bool, +) -> Result<()> +where + R: Read + std::io::Seek, + W: Write + std::io::Seek, +{ + if signature_xml.is_empty() { + return Err(anyhow!("VSIX signature XML payload is empty")); + } + let mut input = zip::ZipArchive::new(reader).context("open VSIX ZIP")?; + let mut output = zip::ZipWriter::new(writer); + let mut had_signature = false; + let mut wrote_content_types = false; + let mut wrote_root_relationships = false; + + for i in 0..input.len() { + let mut file = input.by_index(i).context("read VSIX ZIP entry")?; + let name = normalize_zip_part_name(file.name())?; + if is_opc_signature_entry(&name) { + had_signature = true; + if overwrite { + continue; + } + return Err(anyhow!( + "package already contains OPC signature parts; pass overwrite to replace them" + )); + } + + let options = FileOptions::default().compression_method(file.compression()); + if file.is_dir() { + output.add_directory(name, options)?; + } else { + let mut bytes = Vec::with_capacity(file.size() as usize); + file.read_to_end(&mut bytes)?; + if name == CONTENT_TYPES_PART { + wrote_content_types = true; + bytes = ensure_signature_content_types(&bytes)?; + } else if name == ROOT_RELATIONSHIPS_PART { + wrote_root_relationships = true; + bytes = ensure_root_signature_origin_relationship(&bytes)?; + } + output.start_file(name, options)?; + output.write_all(&bytes)?; + } + } + + if !had_signature || overwrite { + let stored = FileOptions::default().compression_method(zip::CompressionMethod::Stored); + if !wrote_content_types { + output.start_file(CONTENT_TYPES_PART, stored)?; + output.write_all(&ensure_signature_content_types(b"")?)?; + } + if !wrote_root_relationships { + output.start_file(ROOT_RELATIONSHIPS_PART, stored)?; + output.write_all(&ensure_root_signature_origin_relationship(b"")?)?; + } + output.start_file(OPC_SIGNATURE_ORIGIN_PART, stored)?; + output.write_all(&[])?; + output.start_file(OPC_SIGNATURE_ORIGIN_RELS_PART, stored)?; + output.write_all(&signature_origin_relationships_xml())?; + output.start_file(DEFAULT_VSIX_SIGNATURE_PART, stored)?; + output.write_all(signature_xml)?; + } + output.finish()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + fn zip_with(entries: &[(&str, &[u8])]) -> Vec { + let mut out = Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut out); + let options = FileOptions::default(); + for (name, bytes) in entries { + writer.start_file(*name, options).unwrap(); + writer.write_all(bytes).unwrap(); + } + writer.finish().unwrap(); + } + out.into_inner() + } + + #[test] + fn embed_signature_xml_adds_opc_signature_markers() { + let zip = zip_with(&[("[Content_Types].xml", b"")]); + let mut out = Cursor::new(Vec::new()); + + embed_signature_xml(Cursor::new(zip), &mut out, b"", false).unwrap(); + let out = out.into_inner(); + let info = crate::opc::inspect_package_reader(Cursor::new(out.clone())).unwrap(); + let mut archive = zip::ZipArchive::new(Cursor::new(out)).unwrap(); + let mut content_types = String::new(); + archive + .by_name(CONTENT_TYPES_PART) + .unwrap() + .read_to_string(&mut content_types) + .unwrap(); + let mut root_rels = String::new(); + archive + .by_name(ROOT_RELATIONSHIPS_PART) + .unwrap() + .read_to_string(&mut root_rels) + .unwrap(); + let mut origin_rels = String::new(); + archive + .by_name(OPC_SIGNATURE_ORIGIN_RELS_PART) + .unwrap() + .read_to_string(&mut origin_rels) + .unwrap(); + + assert!(info.has_opc_signature_origin); + assert_eq!(info.opc_signature_parts, [DEFAULT_VSIX_SIGNATURE_PART]); + assert!(content_types.contains(OPC_SIGNATURE_ORIGIN_CONTENT_TYPE)); + assert!(content_types.contains(OPC_SIGNATURE_XML_CONTENT_TYPE)); + assert!(root_rels.contains(OPC_SIGNATURE_ORIGIN_REL_TYPE)); + assert!(origin_rels.contains(OPC_SIGNATURE_REL_TYPE)); + } + + #[test] + fn embed_signature_xml_rejects_existing_signature_without_overwrite() { + let zip = zip_with(&[(DEFAULT_VSIX_SIGNATURE_PART, b"")]); + let err = embed_signature_xml(Cursor::new(zip), Cursor::new(Vec::new()), b"", false) + .unwrap_err(); + + assert!(err.to_string().contains("already contains OPC signature")); + } + + #[test] + fn signature_reference_xml_covers_non_signature_parts() { + let zip = zip_with(&[ + ("[Content_Types].xml", b""), + ("extension.vsixmanifest", b"manifest"), + (DEFAULT_VSIX_SIGNATURE_PART, b""), + ]); + + let xml = signature_reference_xml(Cursor::new(zip), VsixHashAlgorithm::Sha256).unwrap(); + let text = String::from_utf8(xml.clone()).unwrap(); + + assert!(text.contains(r#""#)); + assert!(!text.contains(r#""#)); + assert!(!text.contains(r#""#)); + assert!(!text.contains(DEFAULT_VSIX_SIGNATURE_PART)); + assert_eq!( + verify_signature_reference_xml( + Cursor::new(zip_with(&[ + ("[Content_Types].xml", b""), + ("extension.vsixmanifest", b"manifest"), + ])), + &xml, + VsixHashAlgorithm::Sha256 + ) + .unwrap(), + 1 + ); + } + + #[test] + fn signature_reference_xml_detects_tampered_part() { + let zip = zip_with(&[("extension.vsixmanifest", b"manifest")]); + let xml = signature_reference_xml(Cursor::new(zip), VsixHashAlgorithm::Sha256).unwrap(); + + let err = verify_signature_reference_xml( + Cursor::new(zip_with(&[("extension.vsixmanifest", b"changed")])), + &xml, + VsixHashAlgorithm::Sha256, + ) + .unwrap_err(); + + assert!(err.to_string().contains("digest mismatch")); + } + + #[test] + fn extract_signature_xml_returns_embedded_signature_part() { + let zip = zip_with(&[ + ("[Content_Types].xml", b""), + (DEFAULT_VSIX_SIGNATURE_PART, b""), + ]); + + let xml = extract_signature_xml(Cursor::new(zip)).unwrap(); + + assert_eq!(xml, b""); + } +} diff --git a/crates/psign-sip-digest/src/pkcs7.rs b/crates/psign-sip-digest/src/pkcs7.rs index 9f6712c..7803b80 100644 --- a/crates/psign-sip-digest/src/pkcs7.rs +++ b/crates/psign-sip-digest/src/pkcs7.rs @@ -473,6 +473,81 @@ pub fn create_authenticode_pkcs7_der_with_rsa_signature( encode_pkcs7_content_info_signed_data_der(&sd) } +/// Return the digest a remote RSA signer must sign for a generic CMS `SignedData` document. +/// +/// The returned bytes are the SHA-2 digest of DER `SignedAttributes` (`SET OF Attribute`) per +/// RFC 5652 §5.4. Pass the raw RSA PKCS#1 v1.5 signature over this digest to +/// [`create_pkcs7_signed_data_der_with_rsa_signature`]. +pub fn pkcs7_remote_rsa_signed_attrs_digest( + econtent_type: ObjectIdentifier, + econtent_der: &[u8], + digest_algorithm: AuthenticodeSigningDigest, +) -> Result> { + let attrs = pkcs7_signed_attrs(econtent_type, econtent_der, digest_algorithm)?; + let der = signed_attributes_der(&attrs)?; + Ok(digest_algorithm.digest_bytes(&der)) +} + +/// Create generic PKCS#7 `ContentInfo(SignedData)` DER from externally produced RSA signature bytes. +/// +/// `econtent_der` must be one complete DER TLV for the value whose digest appears in PKCS#9 +/// `messageDigest`. Set `detached` to omit the encapsulated content from the resulting CMS while +/// still signing the supplied `econtent_der`; this matches NuGet/App Installer companion flows. +pub fn create_pkcs7_signed_data_der_with_rsa_signature( + econtent_type: ObjectIdentifier, + econtent_der: &[u8], + digest_algorithm: AuthenticodeSigningDigest, + signer_cert: Certificate, + chain_certs: Vec, + encrypted_digest: &[u8], + detached: bool, +) -> Result> { + let attrs = pkcs7_signed_attrs(econtent_type, econtent_der, digest_algorithm)?; + let signer_id = SignerIdentifier::IssuerAndSerialNumber(IssuerAndSerialNumber { + issuer: signer_cert.tbs_certificate.issuer.clone(), + serial_number: signer_cert.tbs_certificate.serial_number.clone(), + }); + let signer_info = SignerInfo { + version: CmsVersion::V1, + sid: signer_id, + digest_alg: digest_algorithm.digest_algorithm(), + signed_attrs: Some(attrs), + signature_algorithm: digest_algorithm.rsa_signature_algorithm(), + signature: SignatureValue::new(encrypted_digest.to_vec()) + .map_err(|e| anyhow!("SignerInfo.signature OCTET STRING: {e}"))?, + unsigned_attrs: None, + }; + let mut rd = SliceReader::new(econtent_der) + .map_err(|e| anyhow!("encapsulated content DER reader: {e}"))?; + let econtent = + Any::decode(&mut rd).map_err(|e| anyhow!("encapsulated content as CMS Any: {e}"))?; + rd.finish(()) + .map_err(|e| anyhow!("trailing octets after encapsulated content DER: {e}"))?; + let digest_algorithms = SetOfVec::try_from(vec![digest_algorithm.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)); + let certificates = Some(CertificateSet( + SetOfVec::try_from(certs).map_err(|e| anyhow!("CertificateSet SET: {e}"))?, + )); + let signer_infos = SignerInfos( + SetOfVec::try_from(vec![signer_info]).map_err(|e| anyhow!("SignerInfos SET: {e}"))?, + ); + let sd = SignedData { + version: CmsVersion::V1, + digest_algorithms, + encap_content_info: EncapsulatedContentInfo { + econtent_type, + econtent: (!detached).then_some(econtent), + }, + certificates, + crls: None, + signer_infos, + }; + encode_pkcs7_content_info_signed_data_der(&sd) +} + /// Attach a raw RFC3161 `timeStampToken` `ContentInfo` as a Microsoft Authenticode unsigned attribute. pub fn signed_data_add_rfc3161_timestamp_token( sd: &SignedData, @@ -1119,6 +1194,28 @@ fn authenticode_signed_attrs( .map_err(|e| anyhow!("SignedAttributes SET OF Attribute canonicalization: {e}")) } +fn pkcs7_signed_attrs( + econtent_type: ObjectIdentifier, + econtent_der: &[u8], + digest_algorithm: AuthenticodeSigningDigest, +) -> Result { + let mut rd = SliceReader::new(econtent_der) + .map_err(|e| anyhow!("encapsulated content DER reader: {e}"))?; + let econtent = + Any::decode(&mut rd).map_err(|e| anyhow!("encapsulated content as CMS Any: {e}"))?; + rd.finish(()) + .map_err(|e| anyhow!("trailing octets after encapsulated content DER: {e}"))?; + let econtent_digest = cms_digest_encapsulated_econtent_bytes( + &digest_algorithm.digest_algorithm().oid, + &econtent, + )?; + SetOfVec::try_from(vec![ + pkcs9_content_type_attribute(econtent_type)?, + pkcs9_message_digest_attribute(&econtent_digest)?, + ]) + .map_err(|e| anyhow!("SignedAttributes SET OF Attribute canonicalization: {e}")) +} + /// Clone authenticated **`SET OF Attribute`** and replace PKCS#9 **`messageDigest`** (**[`PKCS9_MESSAGE_DIGEST_OID`]**) with **`new_message_digest`**. /// /// **`SET`** element ordering is re-canonicalized via **`SetOfVec::try_from`** (DER ordering). Encoding matches RustCrypto **`cms`** **`create_message_digest_attribute`** (**`builder`** feature; **RFC 5652** §11.2). diff --git a/docs/gap-analysis-signing-platforms.md b/docs/gap-analysis-signing-platforms.md index 81677b3..ccbb0dc 100644 --- a/docs/gap-analysis-signing-platforms.md +++ b/docs/gap-analysis-signing-platforms.md @@ -4,7 +4,7 @@ This document compares **Windows SDK `signtool.exe`**, **AzureSignTool**, **Azur **Writable copies of Kits / System32 binaries (read-only install dirs):** [`writable-signing-binaries.md`](writable-signing-binaries.md). -**Linux hybrid pipelines (REST hash sign, verify-only, what is still Windows-only):** [`linux-signing-pipelines.md`](linux-signing-pipelines.md). +**Linux hybrid pipelines (REST hash sign, verify-only, what is still Windows-only):** [`linux-signing-pipelines.md`](linux-signing-pipelines.md). **dotnet/sign migration and package orchestration roadmap:** [`migration-dotnet-sign.md`](migration-dotnet-sign.md). ## Format × capability matrix @@ -42,8 +42,8 @@ This inventory starts from the in-tree supported formats, then expands to inbox | **Catalog** (`.cat`) and driver-package catalogs | Catalog verify paths and `catdb`; can Authenticode-sign an existing `.cat`. | No catalog authoring (`MakeCat`/`Inf2Cat`/`New-FileCatalog` equivalent) or full driver-package workflow. | `sign-catalog` for portable generic CTL catalogs, `verify-catalog`, `verify-catalog-member` for explicit file + MakeCat/psign catalog inputs, `trust-verify-catalog`, catalog PKCS#7 consistency, signer prehash. | No `CryptCATAdmin` database search, driver/INF policy, OS catalog stores, catalog-store revocation policy, or MakeCat byte-for-byte output. | | **MSI family** (`.msi`, `.msp`, `.mst`) | Sign/verify through `MSISIP.DLL`. | Generic SIP remove is not implemented; optional parity corpus depends on external fixtures. | `verify-msi`, local RSA `sign-msi` through the `DigitalSignature` stream, PKCS#7 extraction/prehash. | No timestamp embed, `MsiDigitalSignatureEx` authoring, or installer policy branches such as `DisableSizeVerification` / `DisableLegacyVerification`. | | **WIM / ESD** (`.wim`, `.esd`) | Sign/verify through `EsdSip.dll`. | Positive parity fixtures are limited; no remove. | `verify-esd`. | No WIM/ESD signing/embed, timestamp embed, or WinTrust policy equivalent. | -| **Cleartext AppX/MSIX** (`.appx`, `.msix`, `.appxbundle`, `.msixbundle`) | Sign/verify with AppX client data and dlib bridge. | Remaining native parity failures can occur around `SignerSignEx3` AppX glue, publisher binding, sealing, and package constraints. | `verify-msix` digest consistency. | No `AppxSipCreateIndirectData` equivalent, package PKCS#7 embed, timestamp/signing, manifest publisher-vs-signer policy, or full package policy. | -| **Encrypted AppX/MSIX** (`.eappx`, `.emsix`, `.eappxbundle`, `.emsixbundle`) | Delegates to OS `EappxSip*` / `EappxBundleSip*`. | No in-tree understanding beyond OS delegation and parity fixtures. | Explicitly rejected. | Encrypted package crypto/header handling is absent; ZIP-only digest logic is insufficient. | +| **Cleartext AppX/MSIX** (`.appx`, `.msix`, `.appxbundle`, `.msixbundle`) | Sign/verify with AppX client data and dlib bridge. | Remaining native parity failures can occur around `SignerSignEx3` AppX glue, publisher binding, sealing, and package constraints. | `verify-msix` digest consistency; `msix-manifest-info` / `msix-set-publisher`; guarded `psign-tool code` prepare execution signs nested PE/package entries, updates `AppxManifest.xml` Publisher from `--publisher-name`, regenerates `AppxBlockMap.xml`, and rejects already-final-signed `AppxSignature.p7x` packages before final AppX SIP signing. | No `AppxSipCreateIndirectData` equivalent, package PKCS#7 embed, timestamp/signing, manifest publisher-vs-signer policy, or full package policy. | +| **Encrypted AppX/MSIX** (`.eappx`, `.emsix`, `.eappxbundle`, `.emsixbundle`) | Delegates to OS `EappxSip*` / `EappxBundleSip*`. | No in-tree understanding beyond OS delegation and parity fixtures. | Explicitly rejected by `verify-msix`, MSIX metadata helpers, and `psign-tool code` with Windows AppxSip OS-delegation diagnostics. | Encrypted package crypto/header handling is absent; ZIP-only digest logic is insufficient. | | **AppX extension SIP chain** | Delegates to installed `ExtensionsSip*` providers. | No bundled/provider-specific parity coverage; behavior depends on optional third-party SIP DLLs. | Not implemented. | No extension-provider discovery, DLL contract, or portable provider model. | | **Standalone P7X / PKCX** (`.p7x`) | OS `P7xSip*` can participate when registered; real package signatures are produced as `AppxSignature.p7x` inside signed AppX/MSIX packages. | Direct standalone `.p7x` signing is rejected by current SignTool; first-class commands for extracting/interpreting PKCX remain absent. | Raw PKCS#7 inspection/trust primitives may apply after extraction. | No dedicated PKCX/P7X container command or portable PKCX header handling. | | **PowerShell-class scripts** (`.ps1`, `.psm1`, `.psd1`, `.ps1xml`, `.psc1`, `.cdxml`, `.mof`) | Sign/verify through `pwrshsip.dll`; parity fixtures cover `.ps1`, `.psm1`, `.psd1`. | Need parity fixtures and format detection for `.ps1xml`, `.psc1`, `.cdxml`, `.mof`. | `verify-script` digest consistency for PowerShell-style markers. | No signing/embed; digest remains heuristic for every malformed block and encoding edge case. | @@ -55,10 +55,11 @@ This inventory starts from the in-tree supported formats, then expands to inbox | Surface | Windows mode coverage | Windows-mode gaps | Portable mode coverage | Portable-mode gaps | |---------|-----------------------|-------------------|------------------------|--------------------| | **RDP files** (`.rdp`) | Implemented `rdp` path using Windows certificate stores. | Mostly fixture breadth and native `rdpsign.exe` output-shape parity. | Implemented `portable rdp` with local cert/key or external detached PKCS#7. | No Windows store selection or native `rdpsign.exe` integration by design. | -| **App Installer descriptors** (`.appinstaller`) | Direct embedded signing is rejected by current SignTool; descriptor signing can be represented as unsigned XML plus a PKCS#7 companion artifact generated with SignTool `/p7`. | No first-class App Installer command, XML+companion verification UX, or native parity wrapper. | Detached PKCS#7 trust primitives can verify the XML plus companion signature. | No App Installer-specific command or policy checks. | -| **NuGet packages** (`.nupkg`, `.snupkg`) | Not a `signtool`/WinTrust SIP target in this repo. | No `nuget sign`-compatible author/repository signing workflow in Windows mode. | `psign-opc-sign` groundwork: marker inspection and unsigned package digest. | No CMS author/repository signature creation, timestamping, package embed/update, or NuGet policy verification. | -| **VSIX packages** (`.vsix`) | Not a first-class Windows-mode signing surface here. | No VSIX package signing/verification workflow. | Signature marker inspection. | No XMLDSig generation, package relationship updates, timestamping, or trust verification. | -| **ClickOnce / VSTO manifests** (`.manifest`, `.application`, `.vsto`, `.deploy` workflows) | Not implemented. | No `mage.exe`/manifest XMLDSig workflow, dependency hash graph, certificate embedding, or timestamping. | Not implemented. | Same as Windows mode, plus no XMLDSig primitives or ClickOnce/VSTO policy checks. | +| **App Installer descriptors** (`.appinstaller`) | Direct embedded signing is rejected by current SignTool; descriptor signing can be represented as unsigned XML plus a PKCS#7 companion artifact generated with SignTool `/p7`. | No full XML+companion signing/verification UX or native parity wrapper. | `appinstaller-info` inspects descriptor metadata and companion `.p7`; `appinstaller-set-publisher` updates MainPackage/MainBundle publisher metadata; `appinstaller-sign-companion` creates a local RSA/SHA-2 detached PKCS#7 companion with optional RFC3161 timestamping; `appinstaller-sign-companion-prehash` + `appinstaller-sign-companion-from-signature` assemble companion CMS from external RSA signatures; detached trust primitives verify the XML plus companion signature; `psign-tool code` can update publisher metadata and create top-level companion signatures with local cert/key and explicit output. | No App Installer-specific policy checks, nested orchestration, or direct cloud-provider integration in the `code` orchestrator. | +| **NuGet packages** (`.nupkg`, `.snupkg`) | Not a `signtool`/WinTrust SIP target in this repo. | No `nuget sign`-compatible author/repository signing workflow in Windows mode. | `psign-opc-sign` groundwork: marker inspection, unsigned package digest, NuGet v1 signature-content generation/verification, local RSA/SHA-2 CMS generation for signature content with RFC3161 timestamp tokens, external-signer CMS assembly via `nupkg-signature-pkcs7-prehash` + `nupkg-signature-pkcs7-from-signature`, `.signature.p7s` embed/overwrite, one-step local `nupkg-sign`, embedded `.signature.p7s` package hash + explicit-anchor CMS verification, top-level local `psign-tool code` execution, package-native nested execution when embedded in VSIX/ZIP containers, nested PE/WinMD signing before package signatures, nested exclude filters, `--skip-signed`, and `--overwrite`. | No repository signatures, full NuGet policy verification, non-PE Authenticode nested payload signing, or direct cloud-provider integration in the `code` orchestrator. | +| **VSIX packages** (`.vsix`) | Not a first-class Windows-mode signing surface here. | No VSIX package signing/verification workflow. | Signature marker inspection, signature XML embed primitive with OPC signature content-type and relationship metadata, deterministic XMLDSig Reference/DigestValue generation/verification for package parts, local RSA/SHA-2 XMLDSig `SignatureValue` generation/verification, external-signer XMLDSig assembly via `vsix-signature-xml-prehash` + `vsix-signature-xml-from-signature`, one-step local `vsix-sign`, embedded OPC XMLDSig verification with optional explicit-anchor signer chain validation, top-level local `psign-tool code` execution, package-native nested VSIX/ZIP -> NuGet/VSIX -> PE/WinMD execution, nested exclude filters, `--skip-signed`, and `--overwrite`. | No timestamping, non-PE Authenticode nested payload signing, or direct cloud-provider integration in the `code` orchestrator. | +| **ClickOnce / VSTO manifests** (`.manifest`, `.application`, `.vsto`, `.deploy` workflows) | Not implemented. | No `mage.exe`/manifest XMLDSig workflow, certificate embedding, or timestamping. | `psign-tool code --dry-run` classifies ClickOnce/VSTO workflow nodes; portable helpers inspect/copy `.deploy` payloads; guarded `psign-tool code` execution signs PE-like `.deploy` payloads with local cert/key; portable helpers update and verify manifest file size/digest references; `clickonce-sign-manifest` / `clickonce-sign-manifest-prehash` / `clickonce-sign-manifest-from-signature` / `clickonce-verify-manifest-signature` provide deterministic portable structural local/external XMLDSig signing with embedded signer certificate. | Full Mage-compatible canonicalization/policy, timestamping, full deployment graph orchestration, and ClickOnce/VSTO policy checks remain. | +| **Business Central `.app`** | Format-specific behavior is not implemented. | No confirmed NAVX signing/verification workflow. | `business-central-app-info` detects NAVX headers, `psign-tool code --dry-run` classifies NAVX `.app` files, and signing execution now reports a Business Central-specific unsupported diagnostic instead of silently treating them as generic files. | Actual package signing and verification policy remain pending format confirmation. | | **File catalog authoring** | Can sign/verify an existing `.cat` at the Authenticode layer. | No catalog creation from arbitrary file sets or INF/driver package metadata. | `sign-catalog` authors generic CTL catalogs; catalog PKCS#7 consistency/trust and explicit `verify-catalog-member` cover committed MakeCat-style and psign-authored generic catalogs. | Driver/INF policy, OS catalog database search, and MakeCat byte-for-byte output remain out of scope. | | **WDAC / CI policy signing** | Detached PKCS#7/catalog primitives only. | No policy-specific signing/validation workflow or deployment policy checks. | Detached PKCS#7/catalog primitives only. | No policy-specific workflow, Code Integrity semantics, or Windows deployment policy checks. | @@ -72,7 +73,7 @@ The committed corpus already includes generated unsigned and signed vectors for | **Encrypted AppX/MSIX** (`.eappx`, `.eappxbundle`, `.emsix`, `.emsixbundle`) | Unsigned/placeholder negative files exist. | Real signed encrypted package fixtures, if the project decides to test OS-only Windows delegation. | | **WSH component scripts** (`.wsc`) | Unsigned probe files exist and native SignTool rejection is recorded; `.jse` / `.vbe` have signed generated probes. | Signed `.wsc` fixture if a supported provider/tooling path is identified. | | **Standalone P7X / PKCX** (`.p7x`) | Unsigned direct-signing probe exists and native SignTool rejection is recorded; a real `AppxSignature.p7x` is extracted from a signed MSIX fixture. | First-class PKCX/P7X parsing/verification behavior remains an implementation gap. | -| **App Installer descriptors** (`.appinstaller`) | Unsigned descriptor exists and native direct-signing rejection is recorded; a real SignTool `/p7` companion signature is generated for detached verification coverage. | First-class App Installer XML+signature commands and policy checks remain implementation gaps. | +| **App Installer descriptors** (`.appinstaller`) | Unsigned descriptor exists and native direct-signing rejection is recorded; a real SignTool `/p7` companion signature is generated for detached verification coverage. | Companion PKCS#7 generation and policy checks remain implementation gaps. | | **Optional-provider / XML signing surfaces** (`.application`, `.manifest`, `.vsto`, `.deploy`) | Unsigned probe files exist and native SignTool rejection/provider-unavailable outcomes are recorded. | Signed ClickOnce/VSTO-style fixtures and tool-specific signing metadata. | | **Office macro containers** (`.docm`, `.xlsm`, `.pptm`, `.xlam`) | Unsigned probe files exist. | Signed Office/VBA macro-project fixtures generated with installed Office/VBE SIP, plus verification expectations. | | **Symbols packages** (`.snupkg`) | Unsigned and signed fixtures now exist under `tests/fixtures/package-signing/`. | No remaining fixture gap; implementation gaps are package-signing feature work, not corpus files. | @@ -168,7 +169,7 @@ Portable support is intentionally split by lifecycle stage. This keeps Linux/mac | Local-key signing | Top-level `sign` returns an explicit portable-not-implemented error | `sign-pe`, `sign-cab`, `sign-msi`, `sign-catalog`, `rdp` | Supported for PE, unsigned single-volume CAB, MSI/MSP, generic catalogs, and RDP local RSA signing; other Authenticode SIP subjects remain backlog | | CMS creation from scratch | Not exposed through the native-shaped verb | PE/CAB/MSI Authenticode CMS creation through `sign-pe`, `sign-cab`, `sign-msi`, generic CTL/catalog CMS creation through `sign-catalog`, and `psign-sip-digest` helpers | Supported for PE, CAB, MSI, and generic catalog RSA/SHA-2; reusable CMS work remains to extend MSIX | | Format-specific Authenticode embed | Not implemented | `sign-pe` for PE, `sign-cab` for unsigned single-volume CABs, `sign-msi` for MSI/MSP `DigitalSignature` streams, `sign-catalog` for CTL `eContent` authoring; `append-pe-pkcs7` remains lower-level PE append plumbing | PE supported; CAB initial signing supported; MSI stream signing supported; generic catalog authoring supported; MSIX production embedder is backlog | -| Timestamp embedding | `sign --timestamp-url --timestamp-digest` is routed for portable PE signing; top-level standalone `timestamp` returns an explicit portable-not-implemented error | `sign-pe --timestamp-url --timestamp-digest` timestamps at sign time; `timestamp-pe-rfc3161` attaches a granted RFC3161 `timeStampToken` to existing PE `SignedData`; request/response helpers can prepare or inspect TSA traffic | PE sign-time timestamping and token embedding supported; non-PE timestamp embedding and standalone native-shaped timestamp routing remain backlog | +| Timestamp embedding | `sign --timestamp-url --timestamp-digest` is routed for portable PE signing; top-level standalone `timestamp` returns an explicit portable-not-implemented error | `sign-pe --timestamp-url --timestamp-digest` timestamps at sign time; NuGet `nupkg-signature-pkcs7`, `nupkg-sign`, and `psign-tool code` NuGet/App Installer companion CMS signing can attach RFC3161 tokens; `timestamp-pe-rfc3161` attaches a granted RFC3161 `timeStampToken` to existing PE `SignedData`; request/response helpers can prepare or inspect TSA traffic | PE and NuGet/App Installer local CMS sign-time timestamping supported; VSIX/ClickOnce timestamping and standalone native-shaped timestamp routing remain backlog | | Signature removal / mutation | Top-level `remove` returns an explicit portable-unsupported error | No remove verb | Backlog only after production embedders exist | | Catalog database operations | Top-level `catdb` returns an explicit portable-unsupported error | `sign-catalog` authors explicit generic catalogs; `verify-catalog-member` verifies explicit file + catalog membership without a database | OS catalog database search, driver/INF policy, and catalog store mutation remain out of scope | diff --git a/docs/linux-signing-pipelines.md b/docs/linux-signing-pipelines.md index 44002e6..6769f6d 100644 --- a/docs/linux-signing-pipelines.md +++ b/docs/linux-signing-pipelines.md @@ -87,6 +87,62 @@ psign-tool --mode portable sign \ This path builds Authenticode CMS locally, sends the CMS authenticated-attributes digest to Artifact Signing `:sign`, embeds the returned RSA signature and signing certificate, then attaches the RFC3161 timestamp before PE embedding. For production, keep timestamping enabled because Artifact Signing profile certificates are short-lived. +## 1.4 Package-native helper workflows + +`dotnet/sign`-style package orchestration is being added through `psign-tool code` and package-native helpers. The command can plan nested graphs and has guarded local cert/key execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare, encrypted MSIX/AppX OS-only diagnostics, PE-like ClickOnce `.deploy` payloads, App Installer inputs, `--continue-on-error`, `--skip-signed`, `--overwrite`, and package-native VSIX/ZIP/MSIX -> NuGet -> PE nesting: + +```bash +psign-tool code --dry-run --plan-json --base-directory . --file-list files.txt +psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.exe app.exe +psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.nupkg package.nupkg +psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.vsix extension.vsix +psign-tool code --base-directory . --overwrite --cert signer.der --key signer.pkcs8 --output resigned.nupkg signed-package.nupkg +psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.zip bundle.zip +psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --publisher-name "CN=Publisher" --output prepared.msix app.msix +psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output app.signed.exe.deploy app.exe.deploy +psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output app.appinstaller.p7 app.appinstaller +psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --publisher-name "CN=Publisher" --output updated.appinstaller.p7 app.appinstaller +``` + +Portable package helpers are useful for split-signing experiments and CI assertions: + +```bash +psign-tool portable nupkg-signature-content package.nupkg --output signature-content.txt +psign-tool portable nupkg-signature-pkcs7 package.nupkg --cert signer.der --key signer.pkcs8 --timestamp-url http://tsa --timestamp-digest sha256 --output signature.p7s +psign-tool portable nupkg-signature-pkcs7-prehash package.nupkg --encoding raw --output prehash.bin +psign-tool portable nupkg-signature-pkcs7-from-signature package.nupkg --cert signer.der --signature remote.sig --output signature.p7s +psign-tool portable nupkg-verify-signature-content package.nupkg --content signature-content.txt +psign-tool portable nupkg-embed-signature package.nupkg --signature signature.p7s --output signed.nupkg +psign-tool portable nupkg-sign package.nupkg --cert signer.der --key signer.pkcs8 --timestamp-url http://tsa --timestamp-digest sha256 --output signed.nupkg +psign-tool portable nupkg-verify-signature signed.nupkg --trusted-ca signer.der --allow-loose-signing-cert +psign-tool portable vsix-signature-reference-xml extension.vsix --output signature-reference.xml +psign-tool portable vsix-verify-signature-reference-xml extension.vsix --signature-xml signature-reference.xml +psign-tool portable vsix-signature-xml extension.vsix --cert signer.der --key signer.pkcs8 --output signature.xml +psign-tool portable vsix-signature-xml-prehash extension.vsix --encoding raw --output prehash.bin +psign-tool portable vsix-signature-xml-from-signature extension.vsix --cert signer.der --signature remote.sig --output signature.xml +psign-tool portable vsix-verify-signature-xml extension.vsix --signature-xml signature.xml --cert signer.der --trusted-ca root.der +psign-tool portable vsix-sign extension.vsix --cert signer.der --key signer.pkcs8 --output signed.vsix +psign-tool portable vsix-verify-signature signed.vsix --trusted-ca root.der +psign-tool portable appinstaller-set-publisher app.appinstaller --publisher "CN=Example" --output updated.appinstaller +psign-tool portable appinstaller-sign-companion updated.appinstaller --cert signer.der --key signer.pkcs8 --timestamp-url http://tsa --timestamp-digest sha256 --output updated.appinstaller.p7 +psign-tool portable appinstaller-sign-companion-prehash updated.appinstaller --encoding raw --output prehash.bin +psign-tool portable appinstaller-sign-companion-from-signature updated.appinstaller --cert signer.der --signature remote.sig --output updated.appinstaller.p7 +psign-tool portable appinstaller-verify-companion app.appinstaller --signature app.appinstaller.p7 --anchor-dir anchors +psign-tool portable business-central-app-info package.app +psign-tool portable msix-manifest-info package.msix +psign-tool portable msix-set-publisher package.msix --publisher "CN=Example" --output updated.msix +psign-tool portable clickonce-deploy-info app.exe.deploy +psign-tool portable clickonce-copy-deploy-payload app.exe.deploy --output app.exe +psign-tool portable clickonce-update-manifest-hashes app.exe.manifest --base-directory . --output updated.manifest +psign-tool portable clickonce-manifest-hashes updated.manifest --base-directory . +psign-tool portable clickonce-sign-manifest updated.manifest --cert signer.der --key signer.pkcs8 --output signed.manifest +psign-tool portable clickonce-sign-manifest-prehash updated.manifest --encoding raw --output prehash.bin +psign-tool portable clickonce-sign-manifest-from-signature updated.manifest --cert signer.der --signature remote.sig --output signed.manifest +psign-tool portable clickonce-verify-manifest-signature signed.manifest --trusted-ca signer.der +``` + +These commands do not yet replace `dotnet/sign` for production recursive package signing. They cover deterministic package hashing/reference generation, local PE/WinMD Authenticode signing, local and external-signer NuGet/App Installer CMS signing, NuGet external-signer CMS assembly via `nupkg-signature-pkcs7-prehash` + `nupkg-signature-pkcs7-from-signature`, local and external-signer VSIX XMLDSig signing with optional explicit-anchor signer chain verification, unsigned MSIX/AppX publisher/block-map prepare, encrypted MSIX/AppX OS-only diagnostics, App Installer publisher update before companion signing, marker embedding, package-native nested VSIX/ZIP/MSIX -> NuGet -> PE signing, PE-like ClickOnce `.deploy` payload signing, ClickOnce manifest file hash update/verification plus local/external deterministic portable structural XMLDSig signing, nested exclude filters, and metadata inspection/update while final MSIX signing and full manifest/policy checks are being completed. + ## 1.5 RFC 3161 TSA query/reply (DER only; no embed) **`psign-tool portable rfc3161-timestamp-req`** builds **`TimeStampReq`** DER from **`--digest-hex`** / **`--digest-file`** (message-imprint preimage; optional **`--nonce`**, **`--cert-req`**). **`rfc3161-timestamp-resp-inspect`** prints **`pki_status`** / **`pki_status_int`** (raw **`PKIStatus`** INTEGER) / **`granted`** / token length, **`time_stamp_token_prefix_hex`** (first **16** octets of the **`timeStampToken`** TLV), **`status_strings_json`**, **`fail_info_tlv_hex`**, and **`fail_info_flags_json`** from **`TimeStampResp`** DER. When the token is a parseable CMS **`id-ct-TSTInfo`** timestamp token, it also prints structural **`tst_info_*`** fields for policy OID, message-imprint digest OID/hash, serial, **`genTime`**, and nonce; **`--expect-digest-hex`** and **`--expect-nonce`** add request-binding diagnostics (`tst_info_message_imprint_match`, `tst_info_nonce_match`). These fields are diagnostic only and do not imply TSA trust or CMS signature validation. Build with **`--features timestamp-http`** for **`rfc3161-timestamp-http-post --url …`** (Rustls POST **`application/timestamp-query`**, response DER to stdout / **`--output`**); otherwise use **`curl`** or OpenSSL **`ts`**. **`timestamp-pe-rfc3161`** can attach the granted token to an existing PE Authenticode `SignerInfo`; non-PE timestamp mutation still goes through **`psign-tool`** / **`SignerTimeStampEx3`** today. @@ -142,13 +198,14 @@ For full portable PE signing, prefer **`portable sign-pe --azure-key-vault-*`** | Subject | Prehash for KV **`RS256`** (`--encoding raw`, 32 bytes) | Same bytes via extract + generic PKCS#7 | |---------|------------------------------------------------------------|-------------------------------------------| | PE | **`pe-signer-rs256-prehash`** (`--index` = cert-table row, **`--signer-index`** = **`SignerInfo`**) | **`extract-pe-pkcs7`** → **`pkcs7-signer-rs256-prehash`** | +| NuGet package CMS | **`nupkg-signature-pkcs7-prehash`** | **`nupkg-signature-pkcs7-from-signature`** assembles `.signature.p7s` from the remote RSA signature | | CAB | **`cab-signer-rs256-prehash`** | **`extract-cab-pkcs7`** → **`pkcs7-signer-rs256-prehash`** | | MSI | **`msi-signer-rs256-prehash`** | **`extract-msi-pkcs7`** → **`pkcs7-signer-rs256-prehash`** | | Raw PKCS#7 (e.g. **`.cat`**) | **`catalog-signer-rs256-prehash`** | **`pkcs7-signer-rs256-prehash`** on the same file | Then **`azure-key-vault-sign-digest`** with **`--features azure-kv-sign-portable`** performs **`keys/sign`** (see [`migration-azuresigntool.md`](migration-azuresigntool.md)). **`verify-catalog`** checks CTL-style **`messageDigest` ↔ eContent`** and can disagree with Authenticode-only PKCS#7 bodies—use the right command for catalog *membership* vs *CMS signer* prehash. -PE embedding is portable; CAB/MSI/catalog remote-sign embedding still requires Windows mode or future portable remote-signer support for those formats. +PE embedding and NuGet CMS assembly are portable; CAB/MSI/catalog remote-sign embedding still requires Windows mode or future portable remote-signer support for those formats. Details: [`migration-azuresigntool.md`](migration-azuresigntool.md). diff --git a/docs/migration-dotnet-sign.md b/docs/migration-dotnet-sign.md new file mode 100644 index 0000000..55d239e --- /dev/null +++ b/docs/migration-dotnet-sign.md @@ -0,0 +1,100 @@ +# Migrating from dotnet/sign to psign + +`dotnet/sign` is an orchestration tool: it expands globs, walks directories, opens package containers, signs nested files inside-out, and then signs package-native formats such as NuGet, VSIX, ClickOnce/VSTO, MSIX/AppX, App Installer descriptors, and Business Central `.app` packages. + +`psign-tool` is historically a SignTool/AuthentiCode implementation. The dotnet/sign migration surface is additive and currently lives under `psign-tool code` for orchestration planning and initial local package execution plus `psign-tool portable ...` package helper commands. + +## Command mapping + +| dotnet/sign concept | psign status | psign command | +|---------------------|--------------|---------------| +| Expand files, file lists, `!` excludes, braces, ranges, and recursive globs | Implemented for dry-run planning | `psign-tool code --dry-run --plan-json --base-directory . --file-list files.txt` | +| Nested package graph / inside-out ordering | Implemented for dry-run planning across ZIP/OPC containers | `psign-tool code --dry-run --plan-json package.vsix` | +| Top-level NuGet/VSIX/App Installer local execution | Implemented for local cert/key plus explicit output | `psign-tool code --cert signer.der --key signer.pkcs8 --output signed.nupkg package.nupkg` | +| Authenticode PE/WinMD execution | Implemented for top-level and nested PE/WinMD with local cert/key | `psign-tool code --cert signer.der --key signer.pkcs8 --output signed.exe app.exe` | +| Package-native nested execution | Implemented for VSIX/ZIP -> NuGet/VSIX -> PE/WinMD inside-out signing without unsupported non-PE inner Authenticode payloads | `psign-tool code --cert signer.der --key signer.pkcs8 --output signed.vsix extension.vsix` | +| Continue after top-level errors | Implemented for `code` execution | `psign-tool code --continue-on-error --output signed-dir ...` | +| Independent top-level concurrency | Implemented for `code` execution | `psign-tool code --max-concurrency 4 --output signed-dir ...` | +| Skip already signed packages | Implemented for NuGet/SNuGet and VSIX package-native execution | `psign-tool code --skip-signed --output signed.nupkg package.nupkg` | +| Overwrite existing package signatures | Implemented for NuGet/SNuGet and VSIX package-native execution | `psign-tool code --overwrite --cert signer.der --key signer.pkcs8 --output resigned.nupkg signed-package.nupkg` | +| NuGet `.nupkg` / `.snupkg` signature marker | Implemented structural helpers | `psign-tool portable nupkg-signature-info package.nupkg` | +| NuGet package hash signature content | Implemented deterministic generation and verification | `nupkg-signature-content`, `nupkg-verify-signature-content` | +| NuGet local or external CMS signature blob | Implemented split-signing primitives over generated signature-content bytes, with optional RFC3161 timestamping | `nupkg-signature-pkcs7 --cert signer.der --key signer.pkcs8 --timestamp-url http://tsa --timestamp-digest sha256 --output signature.p7s`, `nupkg-signature-pkcs7-prehash --encoding raw --output prehash.bin`, `nupkg-signature-pkcs7-from-signature --cert signer.der --signature remote.sig --output signature.p7s` | +| NuGet `.signature.p7s` embed/overwrite | Implemented split-signing primitive; local cert/key signing can run in one command with optional RFC3161 timestamping | `nupkg-embed-signature --signature signature.p7s --output signed.nupkg`, `nupkg-sign --cert signer.der --key signer.pkcs8 --timestamp-url http://tsa --timestamp-digest sha256 --output signed.nupkg` | +| NuGet embedded signature verification | Implemented package hash + CMS/trust verification with explicit anchors | `nupkg-verify-signature signed.nupkg --trusted-ca signer.der --allow-loose-signing-cert` | +| VSIX OPC signature markers | Implemented structural helpers | `vsix-signature-info`, `vsix-embed-signature-xml` | +| VSIX XMLDSig package-part references | Implemented deterministic digest generation and verification | `vsix-signature-reference-xml`, `vsix-verify-signature-reference-xml` | +| VSIX local or external XMLDSig SignatureValue | Implemented deterministic local RSA/SHA-2 signing plus split external-signing over generated SignedInfo | `vsix-signature-xml --cert signer.der --key signer.pkcs8 --output signature.xml`, `vsix-signature-xml-prehash --encoding raw --output prehash.bin`, `vsix-signature-xml-from-signature --cert signer.der --signature remote.sig --output signature.xml`, `vsix-verify-signature-xml --cert signer.der --signature-xml signature.xml` | +| VSIX one-step local signing and embedded verification | Implemented deterministic local XMLDSig generation, OPC embed, embedded signature verification, and optional explicit-anchor signer trust checks | `vsix-sign --cert signer.der --key signer.pkcs8 --output signed.vsix`, `vsix-verify-signature signed.vsix --trusted-ca root.der` | +| App Installer descriptor inspection | Implemented | `appinstaller-info app.appinstaller --signature app.appinstaller.p7` | +| App Installer companion signature verification | Implemented explicit-anchor detached trust path | `appinstaller-verify-companion --signature app.appinstaller.p7 --anchor-dir anchors` | +| App Installer companion signature generation | Implemented local and external-signer RSA/SHA-2 detached PKCS#7 companion generation with optional RFC3161 timestamping | `appinstaller-sign-companion --cert signer.der --key signer.pkcs8 --timestamp-url http://tsa --timestamp-digest sha256 --output app.appinstaller.p7`, `appinstaller-sign-companion-prehash --encoding raw --output prehash.bin`, `appinstaller-sign-companion-from-signature --cert signer.der --signature remote.sig --output app.appinstaller.p7` | +| App Installer publisher metadata update | Implemented in portable helper and `code` companion signing | `appinstaller-set-publisher --publisher "CN=Example" --output updated.appinstaller`, `psign-tool code --publisher-name "CN=Example" --output updated.appinstaller.p7 app.appinstaller` | +| Business Central `.app` NAVX recognition | Implemented diagnostics, planner classification, and explicit execution gap diagnostic | `business-central-app-info package.app` | +| MSIX/AppX manifest Identity inspection/update | Implemented unsigned-package helper plus guarded `code` prepare execution and encrypted-package OS-only diagnostics | `msix-manifest-info`, `msix-set-publisher`, `psign-tool code --publisher-name "CN=Publisher" --output prepared.msix app.msix` | +| ClickOnce `.deploy` payload handling | Implemented copy-out primitive and guarded PE-like payload signing through `code` | `clickonce-deploy-info`, `clickonce-copy-deploy-payload`, `psign-tool code --cert signer.der --key signer.pkcs8 --output app.signed.exe.deploy app.exe.deploy` | +| ClickOnce manifest file hash graph | Implemented portable file size/digest update and verification helpers | `clickonce-update-manifest-hashes app.exe.manifest --base-directory publish --output updated.manifest`, `clickonce-manifest-hashes updated.manifest --base-directory publish` | +| ClickOnce manifest XMLDSig | Implemented deterministic portable structural local/external XMLDSig signing/verification; not full Mage parity | `clickonce-sign-manifest app.exe.manifest --cert signer.der --key signer.pkcs8 --output signed.manifest`, `clickonce-sign-manifest-prehash app.exe.manifest --encoding raw --output prehash.bin`, `clickonce-sign-manifest-from-signature app.exe.manifest --cert signer.der --signature remote.sig --output signed.manifest`, `clickonce-verify-manifest-signature signed.manifest --trusted-ca signer.der` | +| Azure.Identity-style auth selector UX | Partially implemented | `--azure-key-vault-credential-type`, `--artifact-signing-credential-type` | + +## Current gaps + +The remaining dotnet/sign feature gaps are execution and policy work: + +- `psign-tool code` execution supports local RSA cert/key PE/WinMD Authenticode signing, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare execution, encrypted MSIX/AppX OS-only diagnostics, PE-like ClickOnce `.deploy` payloads, App Installer descriptors, `--continue-on-error`, `--max-concurrency`, `--skip-signed`, `--overwrite`, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD inside-out signing. Unsupported non-PE nested Authenticode payloads still fail explicitly unless excluded by file-list filters. +- NuGet support does not yet wrap signature content in full NuGet author/repository signature metadata or enforce NuGet trust policy; local CMS signatures can carry RFC3161 timestamp tokens, and split external CMS assembly can consume a Key Vault/Trusted Signing-style RSA signature over `nupkg-signature-pkcs7-prehash`. +- VSIX support now produces and verifies deterministic local or external-signer RSA/SHA-2 XMLDSig `SignatureValue` bytes, writes the package-level OPC signature content types and relationships, and can optionally validate the XMLDSig signer certificate chain against explicit anchors, but still lacks timestamping and direct cloud-provider integration in the `code` orchestrator. +- ClickOnce/VSTO support includes classification, `.deploy` payload copy-out helpers, guarded local signing for PE-like `.deploy` payloads, portable manifest file hash update/verification, and deterministic portable structural local/external XMLDSig manifest signing/verification with embedded signer certificate; timestamping, full Mage-compatible XML canonicalization/policy, and full deployment graph signing remain. +- MSIX/AppX `code` execution prepares unsigned cleartext packages by signing nested entries, updating `AppxManifest.xml` Publisher from `--publisher-name`, and regenerating `AppxBlockMap.xml`; encrypted `.eappx`/`.emsix` packages are classified with explicit Windows AppxSip OS-delegation diagnostics; final package signing still uses the existing Windows SignerSignEx3/AppX path. +- App Installer local/external companion generation, RFC3161 timestamping, publisher update before companion signing, and explicit-anchor detached verification exist; full App Installer policy checks remain. + +## Migration workflow today + +Use `code --dry-run --plan-json` to validate that psign sees the same files and nested ordering that dotnet/sign would process: + +```sh +psign-tool code --dry-run --plan-json --base-directory . --file-list files.txt +``` + +Use guarded local execution for package-native inputs while broader Authenticode recursive execution is completed: + +```sh +psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.nupkg package.nupkg +psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.vsix extension.vsix +psign-tool code --base-directory . --overwrite --cert signer.der --key signer.pkcs8 --output resigned.nupkg signed-package.nupkg +psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.zip bundle.zip +psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.exe app.exe +psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --publisher-name "CN=Publisher" --output prepared.msix app.msix +psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output app.signed.exe.deploy app.exe.deploy +psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output app.appinstaller.p7 app.appinstaller +psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --publisher-name "CN=Publisher" --output updated.appinstaller.p7 app.appinstaller +``` + +Use the package helpers for split-signing experiments and CI assertions: + +```sh +psign-tool portable nupkg-signature-content package.nupkg --output signature-content.txt +psign-tool portable nupkg-signature-pkcs7-prehash package.nupkg --encoding raw --output prehash.bin +psign-tool portable nupkg-signature-pkcs7-from-signature package.nupkg --cert signer.der --signature remote.sig --output signature.p7s +psign-tool portable nupkg-sign package.nupkg --cert signer.der --key signer.pkcs8 --timestamp-url http://tsa --timestamp-digest sha256 --output signed.nupkg +psign-tool portable nupkg-verify-signature-content package.nupkg --content signature-content.txt +psign-tool portable vsix-signature-reference-xml extension.vsix --output signature-reference.xml +psign-tool portable vsix-verify-signature-reference-xml extension.vsix --signature-xml signature-reference.xml +psign-tool portable vsix-signature-xml extension.vsix --cert signer.der --key signer.pkcs8 --output signature.xml +psign-tool portable vsix-signature-xml-prehash extension.vsix --encoding raw --output prehash.bin +psign-tool portable vsix-signature-xml-from-signature extension.vsix --cert signer.der --signature remote.sig --output signature.xml +psign-tool portable vsix-verify-signature-xml extension.vsix --signature-xml signature.xml --cert signer.der +psign-tool portable vsix-sign extension.vsix --cert signer.der --key signer.pkcs8 --output signed.vsix +psign-tool portable msix-manifest-info package.msix +psign-tool portable msix-set-publisher package.msix --publisher "CN=Example" --output updated.msix +psign-tool portable clickonce-deploy-info app.exe.deploy +psign-tool portable clickonce-copy-deploy-payload app.exe.deploy --output app.exe +psign-tool portable clickonce-sign-manifest app.exe.manifest --cert signer.der --key signer.pkcs8 --output signed.manifest +psign-tool portable clickonce-sign-manifest-prehash app.exe.manifest --encoding raw --output prehash.bin +psign-tool portable clickonce-sign-manifest-from-signature app.exe.manifest --cert signer.der --signature remote.sig --output signed.manifest +psign-tool portable clickonce-verify-manifest-signature signed.manifest --trusted-ca signer.der +psign-tool portable appinstaller-sign-companion-prehash app.appinstaller --encoding raw --output prehash.bin +psign-tool portable appinstaller-sign-companion-from-signature app.appinstaller --cert signer.der --signature remote.sig --output app.appinstaller.p7 +``` + +Keep production recursive/nested package signing on dotnet/sign until the remaining execution gaps above are closed. diff --git a/docs/psign-cli-matrix.json b/docs/psign-cli-matrix.json index 5309916..916af34 100644 --- a/docs/psign-cli-matrix.json +++ b/docs/psign-cli-matrix.json @@ -151,6 +151,10 @@ {"native": "/l", "rust": "--dry-run (--l)", "tier": "P0", "status": "implemented", "notes": "Signs and validates the generated payload without replacing the input file."}, {"native": "/q", "rust": "--quiet (-q)", "tier": "P0", "status": "implemented", "notes": "Uses the shared global quiet flag."}, {"native": "/v", "rust": "--verbose (-v)", "tier": "P0", "status": "implemented", "notes": "Uses the shared global verbose flag for per-file status."} + ], + "code": [ + {"native": "(dotnet/sign-style)", "rust": "code --dry-run --plan-json --base-directory --file-list ", "tier": "P1", "status": "implemented", "notes": "Plans file-list/glob selection plus nested ZIP/OPC inside-out ordering without modifying inputs."}, + {"native": "(dotnet/sign-style)", "rust": "code --cert --key --output [--max-concurrency ] [--skip-signed|--overwrite] ", "tier": "P1", "status": "partial", "notes": "Guarded execution for local RSA cert/key PE/WinMD, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare with --publisher-name and AppxBlockMap regeneration, encrypted MSIX/AppX OS-only diagnostics, PE-like ClickOnce .deploy payloads, App Installer publisher updates plus companion signatures, --continue-on-error, --max-concurrency for independent top-level inputs, --skip-signed, --overwrite, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD nested inside-out signing. Unsupported non-PE nested Authenticode payloads fail explicitly unless excluded."} ] }, "tier_summary": { @@ -229,6 +233,16 @@ {"name": "verify-msi", "maps_to_native_concept": "`verify --rust-sip-msi-digest-check`"}, {"name": "verify-esd", "maps_to_native_concept": "`verify --rust-sip-esd-digest-check`"}, {"name": "verify-msix", "maps_to_native_concept": "`verify --rust-sip-msix-digest-check` (cleartext packages)"}, + {"name": "msix-manifest-info", "maps_to_native_concept": "Inspect cleartext MSIX/AppX AppxManifest.xml Identity metadata"}, + {"name": "msix-set-publisher", "maps_to_native_concept": "Update unsigned cleartext MSIX/AppX AppxManifest.xml Identity Publisher before final signing"}, + {"name": "clickonce-deploy-info", "maps_to_native_concept": "Inspect ClickOnce `.deploy` payload names for rename/sign/restore workflows"}, + {"name": "clickonce-copy-deploy-payload", "maps_to_native_concept": "Copy a ClickOnce `.deploy` payload to an explicit undeployed output path before signing"}, + {"name": "clickonce-manifest-hashes", "maps_to_native_concept": "Verify ClickOnce manifest file size/digest references before or after payload signing"}, + {"name": "clickonce-update-manifest-hashes", "maps_to_native_concept": "Update ClickOnce manifest file size/digest references after payload signing"}, + {"name": "clickonce-sign-manifest", "maps_to_native_concept": "Deterministic portable structural XMLDSig signing for ClickOnce manifests (not full Mage parity)"}, + {"name": "clickonce-sign-manifest-prehash", "maps_to_native_concept": "ClickOnce remote-signing primitive: compute the SignedInfo digest that an external RSA signer signs"}, + {"name": "clickonce-sign-manifest-from-signature", "maps_to_native_concept": "ClickOnce remote-signing primitive: assemble manifest XMLDSig from signer certificate plus external RSA signature bytes"}, + {"name": "clickonce-verify-manifest-signature", "maps_to_native_concept": "Verify deterministic portable ClickOnce manifest XMLDSig signature value, manifest digest, and optional explicit-anchor signer chain"}, {"name": "verify-catalog", "maps_to_native_concept": "`verify --rust-sip-catalog-digest-check`"}, {"name": "verify-catalog-member", "maps_to_native_concept": "Explicit file + catalog membership check by CTL member SpcIndirectData digest; no OS catalog database search"}, {"name": "verify-script", "maps_to_native_concept": "`verify --rust-sip-script-digest-check`"}, @@ -257,7 +271,31 @@ {"name": "azure-key-vault-sign-digest", "maps_to_native_concept": "KV keys/sign on digest file (feature azure-kv-sign-portable); AzureSignTool remote step analogue"}, {"name": "nupkg-signature-info", "maps_to_native_concept": "NuGet package-signature marker inspection for `.signature.p7s` (not SIP; groundwork for dotnet nuget sign-compatible portable signing)"}, {"name": "nupkg-digest", "maps_to_native_concept": "Unsigned NuGet package byte hash used by the package-signature properties document (SHA-256/384/512; rejects already signed packages)"}, + {"name": "nupkg-signature-content", "maps_to_native_concept": "NuGet v1 signature-content document (`Version` plus OID-hash property) generated from an unsigned package hash"}, + {"name": "nupkg-signature-pkcs7", "maps_to_native_concept": "NuGet split-signing primitive: create local RSA/SHA-2 CMS over generated signature-content bytes, optionally with RFC3161 timestamp token"}, + {"name": "nupkg-signature-pkcs7-prehash", "maps_to_native_concept": "NuGet remote-signing primitive: compute the CMS authenticated-attributes digest that an external RSA signer signs"}, + {"name": "nupkg-signature-pkcs7-from-signature", "maps_to_native_concept": "NuGet remote-signing primitive: assemble detached CMS from signer certificate plus external RSA signature bytes"}, + {"name": "nupkg-sign", "maps_to_native_concept": "NuGet local package signing primitive: create timestampable CMS over signature-content and embed it as root `.signature.p7s`"}, + {"name": "nupkg-verify-signature-content", "maps_to_native_concept": "NuGet signature-content package hash verification against an unsigned package"}, + {"name": "nupkg-verify-signature", "maps_to_native_concept": "NuGet embedded `.signature.p7s` verification against reconstructed package hash content plus explicit-anchor CMS trust"}, + {"name": "nupkg-embed-signature", "maps_to_native_concept": "NuGet split-signing primitive: add or overwrite root `.signature.p7s` as a stored ZIP entry"}, {"name": "vsix-signature-info", "maps_to_native_concept": "VSIX OPC XMLDSig marker inspection under package digital-signature service parts (not SIP; groundwork for portable VSIX XMLDSig signing)"}, + {"name": "vsix-signature-reference-xml", "maps_to_native_concept": "Deterministic VSIX XMLDSig Reference/DigestValue XML over package parts"}, + {"name": "vsix-signature-xml", "maps_to_native_concept": "Deterministic VSIX XMLDSig XML with local RSA/SHA-2 SignatureValue and embedded X.509 certificate"}, + {"name": "vsix-signature-xml-prehash", "maps_to_native_concept": "VSIX remote-signing primitive: compute the SignedInfo digest that an external RSA signer signs"}, + {"name": "vsix-signature-xml-from-signature", "maps_to_native_concept": "VSIX remote-signing primitive: assemble XMLDSig from signer certificate plus external RSA signature bytes"}, + {"name": "vsix-sign", "maps_to_native_concept": "VSIX local signing primitive: generate deterministic XMLDSig XML and embed the OPC signature parts"}, + {"name": "vsix-verify-signature-reference-xml", "maps_to_native_concept": "Verify VSIX package part digests against Reference/DigestValue XML"}, + {"name": "vsix-verify-signature-xml", "maps_to_native_concept": "Verify VSIX package part digests and local RSA/SHA-2 SignatureValue against an explicit signer certificate, with optional explicit-anchor signer chain validation"}, + {"name": "vsix-verify-signature", "maps_to_native_concept": "Verify embedded VSIX OPC XMLDSig package part digests and local RSA/SHA-2 SignatureValue against the embedded or explicit signer certificate, with optional explicit-anchor signer chain validation"}, + {"name": "vsix-embed-signature-xml", "maps_to_native_concept": "VSIX OPC split-signing primitive: add signature origin marker and XML signature part"}, + {"name": "appinstaller-info", "maps_to_native_concept": "App Installer descriptor and optional detached `.p7` companion metadata inspection"}, + {"name": "appinstaller-verify-companion", "maps_to_native_concept": "App Installer XML descriptor plus detached PKCS#7 companion signature verification with explicit anchors"}, + {"name": "appinstaller-sign-companion", "maps_to_native_concept": "App Installer XML descriptor detached PKCS#7 companion generation with local RSA/SHA-2 cert+key and optional RFC3161 timestamp token"}, + {"name": "appinstaller-sign-companion-prehash", "maps_to_native_concept": "App Installer remote-signing primitive: compute the CMS authenticated-attributes digest that an external RSA signer signs"}, + {"name": "appinstaller-sign-companion-from-signature", "maps_to_native_concept": "App Installer remote-signing primitive: assemble detached companion PKCS#7 from signer certificate plus external RSA signature bytes"}, + {"name": "appinstaller-set-publisher", "maps_to_native_concept": "Update App Installer MainPackage/MainBundle Publisher attributes for package-signing workflows"}, + {"name": "business-central-app-info", "maps_to_native_concept": "Dynamics Business Central `.app` NAVX header recognition diagnostics"}, {"name": "rfc3161-timestamp-req", "maps_to_native_concept": "RFC 3161 TimeStampReq DER from imprint preimage (TSA application/timestamp-query body)"}, {"name": "rfc3161-timestamp-resp-inspect", "maps_to_native_concept": "Parse TimeStampResp PKIStatus (label + raw int), timeStampToken length + 16-octet TLV hex prefix (CMS sniffing), statusString, failInfo TLV + PKIFailureInfo bit names, structural TSTInfo fields, and optional expected imprint/nonce diagnostics; portable trust performs the supported CryptVerifyTimeStampSignature-style checks when --require-valid-timestamp is used"}, {"name": "rfc3161-timestamp-http-post", "maps_to_native_concept": "HTTPS POST TimeStampReq to TSA (optional build feature timestamp-http; not SignerTimeStampEx3)"} diff --git a/docs/rust-sip-gaps.md b/docs/rust-sip-gaps.md index 9174742..2ca2769 100644 --- a/docs/rust-sip-gaps.md +++ b/docs/rust-sip-gaps.md @@ -34,7 +34,7 @@ Split digest (`/dg`, `/ds`, …), sealing (`/seal`, `/itos`, …), biometric/enc ## Portable package signing (not SIP) -VSIX and NuGet package signatures should not be modeled as Rust SIP gaps. VSIX uses OPC XML Digital Signature package parts/relationships, while NuGet package signing (`dotnet nuget sign` semantics) embeds one stored root ZIP entry named **`.signature.p7s`** containing CMS `SignedData` over a NuGet properties document. The dedicated **`psign-opc-sign`** crate owns these ZIP/OPC/NuGet primitives; current portable CLI coverage is inspection/hash groundwork (`nupkg-signature-info`, `nupkg-digest`, `vsix-signature-info`), not full XMLDSig/CMS package signing yet. +VSIX and NuGet package signatures should not be modeled as Rust SIP gaps. VSIX uses OPC XML Digital Signature package parts/relationships, while NuGet package signing (`dotnet nuget sign` semantics) embeds one stored root ZIP entry named **`.signature.p7s`** containing CMS `SignedData` over a NuGet properties document. The dedicated **`psign-opc-sign`** crate owns these ZIP/OPC/NuGet primitives; portable CLI coverage now includes marker inspection, package hash content, local NuGet CMS signing/verification, and deterministic local VSIX XMLDSig signing/verification. ## Tier 1b / 1c style gaps inside Rust SIP diff --git a/scripts/ci/build-package-signing-fixtures.ps1 b/scripts/ci/build-package-signing-fixtures.ps1 index 993f80f..6936665 100644 --- a/scripts/ci/build-package-signing-fixtures.ps1 +++ b/scripts/ci/build-package-signing-fixtures.ps1 @@ -91,7 +91,10 @@ function Add-ManifestEntry { } function New-UnsignedNuGetPackage { - param([Parameter(Mandatory)][string]$Path) + param( + [Parameter(Mandatory)][string]$Path, + [string]$PayloadDllPath = "" + ) $stage = Join-Path ([System.IO.Path]::GetTempPath()) ("psign-nupkg-fixture-" + [guid]::NewGuid()) try { New-Item -ItemType Directory -Force -Path (Join-Path $stage "lib\net8.0") | Out-Null @@ -101,6 +104,7 @@ function New-UnsignedNuGetPackage { + '@ @@ -116,6 +120,9 @@ function New-UnsignedNuGetPackage { '@ Write-Utf8NoBom -Path (Join-Path $stage "lib\net8.0\sample.txt") -Text "psign NuGet fixture`n" + if ($PayloadDllPath) { + Copy-Item -LiteralPath $PayloadDllPath -Destination (Join-Path $stage "lib\net8.0\tiny32.dll") -Force + } New-ZipFromDirectory -SourceDir $stage -Destination $Path } finally { @@ -124,16 +131,36 @@ function New-UnsignedNuGetPackage { } function New-UnsignedVsixPackage { - param([Parameter(Mandatory)][string]$Path) + param( + [Parameter(Mandatory)][string]$Path, + [string]$PackagePayloadPath = "", + [string]$PayloadDllPath = "" + ) $stage = Join-Path ([System.IO.Path]::GetTempPath()) ("psign-vsix-fixture-" + [guid]::NewGuid()) try { New-Item -ItemType Directory -Force -Path (Join-Path $stage "_rels") | Out-Null + $assetLines = [System.Collections.Generic.List[string]]::new() + if ($PayloadDllPath) { + $assetLines.Add(' ') + } + $packagePayloadName = "" + if ($PackagePayloadPath) { + $packagePayloadName = Split-Path -Leaf $PackagePayloadPath + $assetLines.Add(" ") + } + if ($assetLines.Count -eq 0) { + $assetsXml = " " + } else { + $assetsXml = " `n$($assetLines -join "`n")`n " + } Write-Utf8NoBom -Path (Join-Path $stage "[Content_Types].xml") -Text @' + + '@ Write-Utf8NoBom -Path (Join-Path $stage "_rels\.rels") -Text @' @@ -142,7 +169,7 @@ function New-UnsignedVsixPackage { '@ - Write-Utf8NoBom -Path (Join-Path $stage "extension.vsixmanifest") -Text @' + Write-Utf8NoBom -Path (Join-Path $stage "extension.vsixmanifest") -Text @" @@ -153,10 +180,18 @@ function New-UnsignedVsixPackage { - +$assetsXml -'@ +"@ Write-Utf8NoBom -Path (Join-Path $stage "payload.txt") -Text "psign VSIX fixture`n" + if ($PayloadDllPath) { + New-Item -ItemType Directory -Force -Path (Join-Path $stage "payload") | Out-Null + Copy-Item -LiteralPath $PayloadDllPath -Destination (Join-Path $stage "payload\tiny32.dll") -Force + } + if ($PackagePayloadPath) { + New-Item -ItemType Directory -Force -Path (Join-Path $stage "packages") | Out-Null + Copy-Item -LiteralPath $PackagePayloadPath -Destination (Join-Path $stage "packages\$packagePayloadName") -Force + } New-ZipFromDirectory -SourceDir $stage -Destination $Path } finally { @@ -204,22 +239,42 @@ $unsignedDir = Join-Path $OutputDir "unsigned" $signedDir = Join-Path $OutputDir "signed" New-Item -ItemType Directory -Force -Path $unsignedDir, $signedDir | Out-Null +$payloadDll = Join-Path $WorkspaceRoot "tests\fixtures\pe-authenticode-upstream\tiny32.efi" +if (-not (Test-Path -LiteralPath $payloadDll)) { + throw "PE payload fixture not found: $payloadDll" +} + $unsignedNupkg = Join-Path $unsignedDir "sample.nupkg" $unsignedSnupkg = Join-Path $unsignedDir "sample.snupkg" $unsignedVsix = Join-Path $unsignedDir "sample.vsix" +$unsignedNupkgWithPe = Join-Path $unsignedDir "with-pe.nupkg" +$unsignedNestedVsix = Join-Path $unsignedDir "nested.vsix" +$unsignedDeepNestedVsix = Join-Path $unsignedDir "deep-nested.vsix" $signedNupkg = Join-Path $signedDir "sample.signed.nupkg" $signedSnupkg = Join-Path $signedDir "sample.signed.snupkg" $signedVsix = Join-Path $signedDir "sample.signed.vsix" +$signedNupkgWithPe = Join-Path $signedDir "with-pe.signed.nupkg" +$signedNestedVsix = Join-Path $signedDir "nested.signed.vsix" +$signedDeepNestedVsix = Join-Path $signedDir "deep-nested.signed.vsix" New-UnsignedNuGetPackage -Path $unsignedNupkg New-UnsignedNuGetPackage -Path $unsignedSnupkg New-UnsignedVsixPackage -Path $unsignedVsix +New-UnsignedNuGetPackage -Path $unsignedNupkgWithPe -PayloadDllPath $payloadDll +New-UnsignedVsixPackage -Path $unsignedNestedVsix -PackagePayloadPath $unsignedNupkg -PayloadDllPath $payloadDll +New-UnsignedVsixPackage -Path $unsignedDeepNestedVsix -PackagePayloadPath $unsignedNupkgWithPe Copy-Item -LiteralPath $unsignedNupkg -Destination $signedNupkg -Force Copy-Item -LiteralPath $unsignedSnupkg -Destination $signedSnupkg -Force Copy-Item -LiteralPath $unsignedVsix -Destination $signedVsix -Force +Copy-Item -LiteralPath $unsignedNupkgWithPe -Destination $signedNupkgWithPe -Force +Copy-Item -LiteralPath $unsignedNestedVsix -Destination $signedNestedVsix -Force +Copy-Item -LiteralPath $unsignedDeepNestedVsix -Destination $signedDeepNestedVsix -Force Invoke-DotnetNuGetSign -PackagePath $signedNupkg -CertificatePath $PfxPath Invoke-DotnetNuGetSign -PackagePath $signedSnupkg -CertificatePath $PfxPath Invoke-VsixSign -PackagePath $signedVsix -CertificatePath $PfxPath +Invoke-DotnetNuGetSign -PackagePath $signedNupkgWithPe -CertificatePath $PfxPath +Invoke-VsixSign -PackagePath $signedNestedVsix -CertificatePath $PfxPath +Invoke-VsixSign -PackagePath $signedDeepNestedVsix -CertificatePath $PfxPath $entries = [System.Collections.Generic.List[object]]::new() Add-ManifestEntry -List $entries -Id "package-nupkg-unsigned" -Family "nuget" -State "unsigned" -Path $unsignedNupkg @@ -228,6 +283,12 @@ Add-ManifestEntry -List $entries -Id "package-snupkg-unsigned" -Family "nuget-sy Add-ManifestEntry -List $entries -Id "package-snupkg-signed" -Family "nuget-symbols" -State "signed" -Path $signedSnupkg -SourcePath $unsignedSnupkg -Tool "dotnet nuget sign" Add-ManifestEntry -List $entries -Id "package-vsix-unsigned" -Family "vsix" -State "unsigned" -Path $unsignedVsix Add-ManifestEntry -List $entries -Id "package-vsix-signed" -Family "vsix" -State "signed" -Path $signedVsix -SourcePath $unsignedVsix -Tool "System.IO.Packaging.PackageDigitalSignatureManager" +Add-ManifestEntry -List $entries -Id "package-nupkg-with-pe-unsigned" -Family "nuget" -State "unsigned" -Path $unsignedNupkgWithPe +Add-ManifestEntry -List $entries -Id "package-nupkg-with-pe-signed" -Family "nuget" -State "signed" -Path $signedNupkgWithPe -SourcePath $unsignedNupkgWithPe -Tool "dotnet nuget sign" +Add-ManifestEntry -List $entries -Id "package-vsix-nested-unsigned" -Family "vsix" -State "unsigned" -Path $unsignedNestedVsix +Add-ManifestEntry -List $entries -Id "package-vsix-nested-signed" -Family "vsix" -State "signed" -Path $signedNestedVsix -SourcePath $unsignedNestedVsix -Tool "System.IO.Packaging.PackageDigitalSignatureManager" +Add-ManifestEntry -List $entries -Id "package-vsix-deep-nested-unsigned" -Family "vsix" -State "unsigned" -Path $unsignedDeepNestedVsix +Add-ManifestEntry -List $entries -Id "package-vsix-deep-nested-signed" -Family "vsix" -State "signed" -Path $signedDeepNestedVsix -SourcePath $unsignedDeepNestedVsix -Tool "System.IO.Packaging.PackageDigitalSignatureManager" $manifest = [ordered]@{ generated_by = "scripts/ci/build-package-signing-fixtures.ps1" diff --git a/scripts/linux-portable-validation.sh b/scripts/linux-portable-validation.sh index cf57476..3b3c95a 100644 --- a/scripts/linux-portable-validation.sh +++ b/scripts/linux-portable-validation.sh @@ -15,7 +15,7 @@ cargo metadata --locked --format-version 1 >/dev/null echo "== clippy portable crates ==" cargo clippy -p psign-sip-digest -p psign-digest-cli -p psign-authenticode-trust \ -p psign-portable-core -p psign-portable-ffi \ - -p psign-codesigning-rest -p psign-azure-kv-rest \ + -p psign-codesigning-rest -p psign-azure-kv-rest -p psign-opc-sign \ --all-targets --locked -- -D warnings echo "== clippy digest-cli (artifact-signing-rest) ==" @@ -48,9 +48,15 @@ cargo test -p psign-codesigning-rest --lib --locked echo "== unit tests: azure-kv-rest ==" cargo test -p psign-azure-kv-rest --lib --locked +echo "== unit tests: opc package signing ==" +cargo test -p psign-opc-sign --lib --locked + echo "== integration: psign-tool portable (digest-cli) ==" cargo test -p psign --test cli_pe_digest --locked +echo "== integration: psign-tool code planner ==" +cargo test -p psign --test code_command --locked + echo "== integration: psign-tool portable (artifact-signing-rest) ==" cargo test -p psign --test cli_pe_digest --features artifact-signing-rest --locked diff --git a/src/cli.rs b/src/cli.rs index 2bc34ec..d322d12 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -45,6 +45,8 @@ pub enum Command { CertStore(CertStoreArgs), /// Portable-only diagnostics and helpers (no Win32 APIs). Portable(PortableArgs), + /// Plan or run dotnet/sign-style code-signing orchestration over files, globs, and nested containers. + Code(CodeArgs), /// Verify embedded Authenticode signature on a file. Verify(VerifyArgs), /// Sign a file using mssign32 (`SignerSignEx3`). @@ -78,6 +80,72 @@ pub struct CertStoreArgs { pub command: CertStoreCommand, } +#[derive(Args, Debug)] +pub struct CodeArgs { + /// Base directory used to resolve relative input patterns and file-list entries. + #[arg(long)] + pub base_directory: Option, + /// Text file containing include and `!` exclude patterns, one per line. + #[arg(long)] + pub file_list: Option, + /// Output file/directory for future signing runs. Dry-run records it but does not write signed content. + #[arg(long)] + pub output: Option, + /// Recurse into ZIP/OPC containers such as VSIX and NuGet packages. + #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] + pub recurse_containers: bool, + /// Maximum independent top-level signing concurrency used by the orchestrator. + #[arg(long)] + pub max_concurrency: Option, + /// Authenticode/package application display name. + #[arg(long)] + pub application_name: Option, + /// Package publisher name. + #[arg(long)] + pub publisher_name: Option, + /// Authenticode description. + #[arg(long)] + pub description: Option, + /// Authenticode description URL. + #[arg(long)] + pub description_url: Option, + /// File digest algorithm for future signing runs. + #[arg(long, value_enum, ignore_case = true, default_value_t = DigestAlgorithm::Sha256)] + pub file_digest: DigestAlgorithm, + /// RFC3161 timestamp URL for future signing runs. + #[arg(long)] + pub timestamp_url: Option, + /// RFC3161 timestamp digest algorithm for future signing runs. + #[arg(long, value_enum, ignore_case = true)] + pub timestamp_digest: Option, + /// Continue planning/signing remaining top-level inputs after an error. + #[arg(long)] + pub continue_on_error: bool, + /// Skip files already signed when future signing support is enabled. + #[arg(long)] + pub skip_signed: bool, + /// Replace existing package-native signatures instead of failing when NuGet/VSIX signatures are present. + #[arg(long, conflicts_with = "skip_signed")] + pub overwrite: bool, + /// Signer certificate as DER or PEM for initial local package signing execution. + #[arg(long, value_name = "PATH", requires = "key")] + pub cert: Option, + /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM for initial local package signing execution. + #[arg(long, value_name = "PATH", requires = "cert")] + pub key: Option, + /// Additional certificate to include in generated package PKCS#7 signatures. + #[arg(long = "chain-cert", value_name = "PATH", requires = "cert")] + pub chain_certs: Vec, + /// Build and print the signing graph without modifying files. + #[arg(long)] + pub dry_run: bool, + /// Emit the dry-run signing graph as JSON. + #[arg(long, requires = "dry_run")] + pub plan_json: bool, + /// Files or glob patterns to include. + pub inputs: Vec, +} + #[derive(Subcommand, Debug)] pub enum CertStoreCommand { /// Import a PEM or DER X.509 certificate into a store. @@ -255,6 +323,20 @@ pub enum DigestAlgorithm { CertHash, } +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +pub enum AzureCredentialType { + /// Infer from explicit token/client-secret/managed-identity options. + Default, + /// Use managed identity. For user-assigned identities, pass the existing client-id option. + ManagedIdentity, + /// Use an already acquired bearer access token. + AccessToken, + /// Use tenant/client-id/client-secret service-principal credentials. + ClientSecret, + /// Reserve the Azure.Identity workload identity shape; execution support is not wired yet. + WorkloadIdentity, +} + impl DigestAlgorithm { pub fn as_signtool_name(self) -> &'static str { match self { @@ -693,6 +775,9 @@ pub struct SignArgs { /// Managed identity / `DefaultAzureCredential`-style acquisition via IMDS (`-kvm`). #[arg(long = "azure-key-vault-managed-identity", visible_alias = "kvm")] pub azure_key_vault_managed_identity: bool, + /// Azure.Identity-style credential selector for Key Vault signing. + #[arg(long = "azure-key-vault-credential-type", value_enum)] + pub azure_key_vault_credential_type: Option, /// OAuth authority host prefix (`-au`), e.g. `https://login.microsoftonline.com`. #[arg(long = "azure-authority", visible_alias = "au")] pub azure_authority: Option, @@ -719,6 +804,9 @@ pub struct SignArgs { pub artifact_signing_access_token: Option, #[arg(long = "artifact-signing-managed-identity")] pub artifact_signing_managed_identity: bool, + /// Azure.Identity-style credential selector for Artifact Signing. + #[arg(long = "artifact-signing-credential-type", value_enum)] + pub artifact_signing_credential_type: Option, #[arg(long = "artifact-signing-tenant-id")] pub artifact_signing_tenant_id: Option, #[arg(long = "artifact-signing-client-id")] diff --git a/src/code.rs b/src/code.rs new file mode 100644 index 0000000..ad3ad7c --- /dev/null +++ b/src/code.rs @@ -0,0 +1,2288 @@ +use crate::CommandOutput; +use crate::cli::{CodeArgs, DigestAlgorithm}; +use anyhow::{Context, Result, anyhow}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +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::{pkcs7, rdp}; +use rsa::signature::{SignatureEncoding as _, Signer as _}; +use serde::Serialize; +use sha2::Digest as _; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::File; +use std::io::{Cursor, Read, Seek, SeekFrom, Write}; +use std::path::{Component, Path, PathBuf}; +use x509_cert::der::{ + Encode as _, + asn1::{ObjectIdentifier, OctetString}, +}; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum CodeFormat { + Pe, + Winmd, + Cab, + Msi, + Msp, + Mst, + Catalog, + Script, + Msix, + Appx, + MsixBundle, + AppxBundle, + AppxUpload, + MsixUpload, + EncryptedMsix, + Nuget, + Snupkg, + Vsix, + ClickOnceApplication, + Vsto, + Manifest, + Deploy, + AppInstaller, + BusinessCentralApp, + Zip, + Unknown, +} + +impl CodeFormat { + fn is_container(&self) -> bool { + matches!( + self, + Self::Nuget + | Self::Snupkg + | Self::Vsix + | Self::Msix + | Self::Appx + | Self::MsixBundle + | Self::AppxBundle + | Self::AppxUpload + | Self::MsixUpload + | Self::Zip + ) + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct CodePlan { + pub base_directory: String, + pub output: Option, + pub recurse_containers: bool, + pub max_concurrency: Option, + pub file_digest: String, + pub timestamp_digest: Option, + pub timestamp_url: Option, + pub continue_on_error: bool, + pub skip_signed: bool, + pub nodes: Vec, + pub edges: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct CodePlanNode { + pub id: usize, + pub path: String, + pub output_path: String, + pub format: CodeFormat, + pub depth: usize, + pub container: bool, + pub signer: &'static str, +} + +#[derive(Clone, Debug, Serialize)] +pub struct CodePlanEdge { + pub before: usize, + pub after: usize, +} + +#[derive(Clone, Debug)] +struct PatternRule { + include: bool, + pattern: String, +} + +#[derive(Default)] +struct PlanBuilder { + nodes: Vec, + edges: Vec, + node_ids: BTreeMap, + nested_excludes: Vec, + output: Option, + top_level_count: usize, +} + +pub fn code_command(args: &CodeArgs) -> Result { + let plan = build_code_plan(args)?; + if args.dry_run { + let stdout = if args.plan_json { + format!("{}\n", serde_json::to_string_pretty(&plan)?) + } else { + render_text_plan(&plan) + }; + Ok(CommandOutput::ok(stdout)) + } else { + execute_code_plan(args, &plan) + } +} + +pub fn build_code_plan(args: &CodeArgs) -> Result { + if args.max_concurrency == Some(0) { + return Err(anyhow!("--max-concurrency must be greater than zero")); + } + match (args.timestamp_url.as_ref(), args.timestamp_digest) { + (Some(_), None) => { + return Err(anyhow!( + "`psign-tool code` requires --timestamp-digest with --timestamp-url" + )); + } + (None, Some(_)) => { + return Err(anyhow!( + "`psign-tool code` requires --timestamp-url with --timestamp-digest" + )); + } + _ => {} + } + + let base = args + .base_directory + .clone() + .unwrap_or_else(|| PathBuf::from(".")); + let base = std::fs::canonicalize(&base) + .with_context(|| format!("resolve base directory {}", base.display()))?; + if !base.is_dir() { + return Err(anyhow!( + "base directory is not a directory: {}", + base.display() + )); + } + + let rules = selection_rules(&base, args)?; + let selected = select_inputs(&base, &rules)?; + let nested_excludes = rules + .iter() + .filter(|rule| !rule.include) + .map(|rule| rule.pattern.clone()) + .collect(); + let mut builder = PlanBuilder { + nested_excludes, + output: args.output.clone(), + top_level_count: selected.len(), + ..Default::default() + }; + for path in selected { + builder.add_path(&path, &base, 0, args.recurse_containers)?; + } + + Ok(CodePlan { + base_directory: display_path(&base), + output: args.output.as_ref().map(|p| display_path(p)), + recurse_containers: args.recurse_containers, + max_concurrency: args.max_concurrency, + file_digest: digest_name(args.file_digest).to_owned(), + timestamp_digest: args.timestamp_digest.map(digest_name).map(str::to_owned), + timestamp_url: args.timestamp_url.clone(), + continue_on_error: args.continue_on_error, + skip_signed: args.skip_signed, + nodes: builder.nodes, + edges: builder.edges, + }) +} + +fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result { + let Some(cert) = args.cert.as_deref() else { + return Err(anyhow!( + "`psign-tool code` signing execution currently requires --cert and --key for local package signing" + )); + }; + let Some(key) = args.key.as_deref() else { + return Err(anyhow!( + "`psign-tool code` signing execution currently requires --cert and --key for local package signing" + )); + }; + if args.output.is_none() { + return Err(anyhow!( + "`psign-tool code` signing execution currently requires --output to avoid in-place package mutation" + )); + } + + let digest = nuget_hash_algorithm(args.file_digest)?; + let signing_digest = signing_digest_algorithm(args.file_digest)?; + let base = PathBuf::from(&plan.base_directory); + let vsix_digest = vsix_hash_algorithm(args.file_digest)?; + let nested_excludes = nested_exclude_patterns(&base, args)?; + + let execute_node = |node: &CodePlanNode| -> Result { + let input = base.join(display_to_path(&node.path)); + match node.format { + CodeFormat::Pe | CodeFormat::Winmd => { + let output = output_path_for_node(node)?; + ensure_parent_dir(&output)?; + let input_bytes = + std::fs::read(&input).with_context(|| format!("read {}", input.display()))?; + if args.skip_signed && pe_has_signature(&input_bytes) { + std::fs::write(&output, input_bytes).with_context(|| { + format!("write skipped Authenticode payload {}", output.display()) + })?; + Ok(format!( + "skipped {} -> {} (already signed)", + node.path, + display_path(&output) + )) + } else { + let signed = + sign_pe_bytes(&input_bytes, &node.path, cert, key, signing_digest, false) + .with_context(|| { + format!("sign Authenticode payload {}", input.display()) + })?; + std::fs::write(&output, signed).with_context(|| { + format!("write signed Authenticode payload {}", output.display()) + })?; + Ok(format!( + "signed {} -> {} (Authenticode PE/WinMD)", + node.path, + display_path(&output) + )) + } + } + CodeFormat::Nuget | CodeFormat::Snupkg => { + let output = output_path_for_node(node)?; + ensure_parent_dir(&output)?; + let input_bytes = + std::fs::read(&input).with_context(|| format!("read {}", input.display()))?; + if args.skip_signed && package_has_signature(&input_bytes, &node.format)? { + std::fs::write(&output, input_bytes).with_context(|| { + format!("write skipped NuGet package {}", output.display()) + })?; + Ok(format!( + "skipped {} -> {} (already signed)", + node.path, + display_path(&output) + )) + } else { + let signed = sign_nuget_bytes( + &input_bytes, + &node.path, + digest, + signing_digest, + cert, + key, + args.chain_certs.clone(), + &nested_excludes, + args.skip_signed, + args.overwrite, + args.timestamp_url.as_deref(), + args.timestamp_digest, + ) + .with_context(|| { + format!("create NuGet package signature for {}", input.display()) + })?; + std::fs::write(&output, signed).with_context(|| { + format!("write signed NuGet package {}", output.display()) + })?; + Ok(format!( + "signed {} -> {} ({})", + node.path, + display_path(&output), + nuget::PACKAGE_SIGNATURE_FILE_NAME + )) + } + } + CodeFormat::AppInstaller => { + let output = appinstaller_companion_output_path(node)?; + ensure_parent_dir(&output)?; + let mut bytes = + std::fs::read(&input).with_context(|| format!("read {}", input.display()))?; + validate_appinstaller_descriptor(&bytes).with_context(|| { + format!("validate App Installer descriptor {}", input.display()) + })?; + let descriptor_output = if let Some(publisher) = args.publisher_name.as_deref() { + bytes = update_appinstaller_publisher_bytes(&bytes, publisher).with_context( + || format!("update App Installer publisher for {}", input.display()), + )?; + let descriptor_output = appinstaller_descriptor_output_path(node)?; + ensure_parent_dir(&descriptor_output)?; + std::fs::write(&descriptor_output, &bytes).with_context(|| { + format!( + "write updated App Installer descriptor {}", + descriptor_output.display() + ) + })?; + Some(descriptor_output) + } else { + None + }; + let pkcs7 = sign_pkcs7_id_data( + &bytes, + cert, + key, + args.chain_certs.clone(), + signing_digest, + args.timestamp_url.as_deref(), + args.timestamp_digest, + ) + .with_context(|| { + format!( + "create App Installer companion signature for {}", + input.display() + ) + })?; + std::fs::write(&output, pkcs7) + .with_context(|| format!("write {}", output.display()))?; + if let Some(descriptor_output) = descriptor_output { + Ok(format!( + "signed {} -> {} (updated descriptor {}; detached PKCS#7 companion)", + node.path, + display_path(&output), + display_path(&descriptor_output) + )) + } else { + Ok(format!( + "signed {} -> {} (detached PKCS#7 companion)", + node.path, + display_path(&output) + )) + } + } + CodeFormat::Vsix => { + let output = output_path_for_node(node)?; + ensure_parent_dir(&output)?; + let cert_bytes = + std::fs::read(cert).with_context(|| format!("read {}", cert.display()))?; + rdp::parse_certificate(&cert_bytes) + .with_context(|| format!("parse signer certificate {}", cert.display()))?; + let input_bytes = + std::fs::read(&input).with_context(|| format!("read {}", input.display()))?; + if args.skip_signed && package_has_signature(&input_bytes, &node.format)? { + std::fs::write(&output, input_bytes).with_context(|| { + format!("write skipped VSIX package {}", output.display()) + })?; + Ok(format!( + "skipped {} -> {} (already signed)", + node.path, + display_path(&output) + )) + } else { + let signed = sign_vsix_bytes( + &input_bytes, + &node.path, + digest, + signing_digest, + vsix_digest, + cert, + key, + args.chain_certs.clone(), + &nested_excludes, + args.skip_signed, + args.overwrite, + args.timestamp_url.as_deref(), + args.timestamp_digest, + ) + .with_context(|| format!("create VSIX signature for {}", input.display()))?; + std::fs::write(&output, signed).with_context(|| { + format!("write signed VSIX package {}", output.display()) + })?; + Ok(format!( + "signed {} -> {} ({})", + node.path, + display_path(&output), + vsix::DEFAULT_VSIX_SIGNATURE_PART + )) + } + } + CodeFormat::Zip => { + let output = output_path_for_node(node)?; + ensure_parent_dir(&output)?; + let input_bytes = + std::fs::read(&input).with_context(|| format!("read {}", input.display()))?; + let signed = sign_zip_container_bytes( + &input_bytes, + &node.path, + digest, + signing_digest, + vsix_digest, + cert, + key, + args.chain_certs.clone(), + &nested_excludes, + args.skip_signed, + args.overwrite, + args.timestamp_url.as_deref(), + args.timestamp_digest, + ) + .with_context(|| format!("sign nested package entries in {}", input.display()))?; + std::fs::write(&output, signed) + .with_context(|| format!("write signed ZIP container {}", output.display()))?; + Ok(format!( + "signed {} -> {} (nested package entries)", + node.path, + display_path(&output) + )) + } + CodeFormat::Msix + | CodeFormat::Appx + | CodeFormat::MsixBundle + | CodeFormat::AppxBundle + | CodeFormat::AppxUpload + | CodeFormat::MsixUpload => { + let output = output_path_for_node(node)?; + ensure_parent_dir(&output)?; + let input_bytes = + std::fs::read(&input).with_context(|| format!("read {}", input.display()))?; + let prepared = prepare_msix_family_bytes( + &input_bytes, + &node.path, + digest, + signing_digest, + vsix_digest, + cert, + key, + args.chain_certs.clone(), + &nested_excludes, + args.skip_signed, + args.overwrite, + args.publisher_name.as_deref(), + args.timestamp_url.as_deref(), + args.timestamp_digest, + ) + .with_context(|| format!("prepare MSIX/AppX package {}", input.display()))?; + std::fs::write(&output, prepared).with_context(|| { + format!("write prepared MSIX/AppX package {}", output.display()) + })?; + Ok(format!( + "prepared {} -> {} (unsigned MSIX/AppX; final AppX SIP signing pending)", + node.path, + display_path(&output) + )) + } + CodeFormat::EncryptedMsix => Err(anyhow!( + "`psign-tool code` recognized {} as an encrypted MSIX/AppX package; encrypted .eappx/.emsix packages require Windows AppxSip OS delegation and are not supported by the portable package prepare path", + node.path + )), + CodeFormat::Deploy => { + let output = output_path_for_node(node)?; + 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, + cert, + key, + signing_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()) + })?; + Ok(format!( + "signed {} -> {} (ClickOnce .deploy payload)", + node.path, + display_path(&output) + )) + } + CodeFormat::BusinessCentralApp => Err(anyhow!( + "`psign-tool code` recognized {} as a Business Central NAVX .app package, but Business Central package signing is not implemented yet", + node.path + )), + _ => Err(anyhow!( + "`psign-tool code` signing execution currently supports top-level PE/WinMD, NuGet/SNuGet, VSIX, ZIP, MSIX/AppX prepare, ClickOnce .deploy PE payloads, and App Installer descriptors only ({} is {:?})", + node.path, + node.format + )), + } + }; + + let mut lines = Vec::new(); + let mut exit_code = 0; + let top_nodes: Vec<&CodePlanNode> = plan.nodes.iter().filter(|node| node.depth == 0).collect(); + let max_concurrency = args.max_concurrency.unwrap_or(1).max(1); + if max_concurrency == 1 { + for node in top_nodes { + let result = execute_node(node); + match result { + Ok(line) => lines.push(line), + Err(err) if args.continue_on_error => { + exit_code = 1; + lines.push(format!("failed {}: {err:#}", node.path)); + } + Err(err) => return Err(err), + }; + } + } else { + for chunk in top_nodes.chunks(max_concurrency) { + let batch: Vec<_> = std::thread::scope(|scope| { + let handles: Vec<_> = chunk + .iter() + .map(|node| { + let node = *node; + let execute_node = &execute_node; + scope.spawn(move || { + let result = execute_node(node); + (node.id, node.path.clone(), result) + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().expect("code signing worker panicked")) + .collect() + }); + for (_id, path, result) in batch { + match result { + Ok(line) => lines.push(line), + Err(err) if args.continue_on_error => { + exit_code = 1; + lines.push(format!("failed {path}: {err:#}")); + } + Err(err) => return Err(err), + }; + } + } + } + Ok(CommandOutput::with_exit( + format!("{}\n", lines.join("\n")), + exit_code, + )) +} + +#[allow(clippy::too_many_arguments)] +fn sign_nuget_bytes( + input_bytes: &[u8], + label: &str, + digest: nuget::NuGetHashAlgorithm, + signing_digest: pkcs7::AuthenticodeSigningDigest, + cert: &Path, + key: &Path, + chain_certs: Vec, + nested_excludes: &[String], + skip_signed: bool, + overwrite: bool, + timestamp_url: Option<&str>, + timestamp_digest: Option, +) -> Result> { + if skip_signed && package_has_signature(input_bytes, &CodeFormat::Nuget)? { + return Ok(input_bytes.to_vec()); + } + let updated = sign_nested_package_entries( + input_bytes, + label, + digest, + signing_digest, + vsix::VsixHashAlgorithm::Sha256, + cert, + key, + chain_certs.clone(), + nested_excludes, + skip_signed, + overwrite, + timestamp_url, + timestamp_digest, + )?; + if !overwrite { + ensure_nuget_unsigned(&updated, label)?; + } + let unsigned = nuget::canonical_unsigned_package_bytes(Cursor::new(updated)) + .with_context(|| format!("canonicalize NuGet package before signing {label}"))?; + let content = nuget::signature_content_bytes(digest, &digest.hash(&unsigned)); + let pkcs7 = sign_pkcs7_id_data( + &content, + cert, + key, + chain_certs, + signing_digest, + timestamp_url, + timestamp_digest, + )?; + let mut out = Cursor::new(Vec::new()); + nuget::embed_signature(Cursor::new(unsigned), &mut out, &pkcs7, false) + .with_context(|| format!("embed NuGet signature into {label}"))?; + Ok(out.into_inner()) +} + +#[allow(clippy::too_many_arguments)] +fn sign_vsix_bytes( + input_bytes: &[u8], + label: &str, + digest: nuget::NuGetHashAlgorithm, + signing_digest: pkcs7::AuthenticodeSigningDigest, + vsix_digest: vsix::VsixHashAlgorithm, + cert: &Path, + key: &Path, + chain_certs: Vec, + nested_excludes: &[String], + skip_signed: bool, + overwrite: bool, + timestamp_url: Option<&str>, + timestamp_digest: Option, +) -> Result> { + if skip_signed && package_has_signature(input_bytes, &CodeFormat::Vsix)? { + return Ok(input_bytes.to_vec()); + } + let updated = sign_nested_package_entries( + input_bytes, + label, + digest, + signing_digest, + vsix_digest, + cert, + key, + chain_certs, + nested_excludes, + skip_signed, + overwrite, + timestamp_url, + timestamp_digest, + )?; + let cert_bytes = std::fs::read(cert).with_context(|| format!("read {}", cert.display()))?; + let key_bytes = std::fs::read(key).with_context(|| format!("read {}", key.display()))?; + let private_key = rdp::parse_rsa_private_key(&key_bytes) + .with_context(|| format!("parse RSA private key {}", key.display()))?; + let signed_info = vsix::signed_info_xml(Cursor::new(updated.clone()), vsix_digest) + .with_context(|| format!("create VSIX SignedInfo for {label}"))?; + let signature = sign_xml_signed_info(vsix_digest, private_key, &signed_info); + let xml = vsix::signature_xml_from_signed_info(&signed_info, &signature, Some(&cert_bytes)) + .into_bytes(); + let mut out = Cursor::new(Vec::new()); + vsix::embed_signature_xml(Cursor::new(updated), &mut out, &xml, overwrite) + .with_context(|| format!("embed VSIX signature XML into {label}"))?; + Ok(out.into_inner()) +} + +#[allow(clippy::too_many_arguments)] +fn sign_zip_container_bytes( + input_bytes: &[u8], + label: &str, + digest: nuget::NuGetHashAlgorithm, + signing_digest: pkcs7::AuthenticodeSigningDigest, + vsix_digest: vsix::VsixHashAlgorithm, + cert: &Path, + key: &Path, + chain_certs: Vec, + nested_excludes: &[String], + skip_signed: bool, + overwrite: bool, + timestamp_url: Option<&str>, + timestamp_digest: Option, +) -> Result> { + sign_nested_package_entries( + input_bytes, + label, + digest, + signing_digest, + vsix_digest, + cert, + key, + chain_certs, + nested_excludes, + skip_signed, + overwrite, + timestamp_url, + timestamp_digest, + ) +} + +#[allow(clippy::too_many_arguments)] +fn sign_nested_package_entries( + input_bytes: &[u8], + label: &str, + digest: nuget::NuGetHashAlgorithm, + signing_digest: pkcs7::AuthenticodeSigningDigest, + vsix_digest: vsix::VsixHashAlgorithm, + cert: &Path, + key: &Path, + chain_certs: Vec, + nested_excludes: &[String], + skip_signed: bool, + overwrite: bool, + timestamp_url: Option<&str>, + timestamp_digest: Option, +) -> Result> { + let mut archive = zip::ZipArchive::new(Cursor::new(input_bytes)) + .with_context(|| format!("open {label} as ZIP while signing nested package entries"))?; + let mut updates = Vec::new(); + for i in 0..archive.len() { + let mut file = archive + .by_index(i) + .with_context(|| format!("read ZIP entry in {label}"))?; + if file.is_dir() { + continue; + } + let name = normalize_zip_name(file.name())?; + let compression = file.compression(); + let format = detect_format(Path::new(&name), None); + let mut bytes = Vec::with_capacity(file.size() as usize); + file.read_to_end(&mut bytes)?; + let nested_label = format!("{label}!{name}"); + if nested_excludes.iter().any(|pattern| { + glob_match(pattern, &name) || glob_match(pattern, &nested_label.replace('!', "/")) + }) { + continue; + } + let signed = match format { + CodeFormat::Nuget | CodeFormat::Snupkg => Some(sign_nuget_bytes( + &bytes, + &nested_label, + digest, + signing_digest, + cert, + key, + chain_certs.clone(), + nested_excludes, + skip_signed, + overwrite, + timestamp_url, + timestamp_digest, + )?), + CodeFormat::Vsix => Some(sign_vsix_bytes( + &bytes, + &nested_label, + digest, + signing_digest, + vsix_digest, + cert, + key, + chain_certs.clone(), + nested_excludes, + skip_signed, + overwrite, + timestamp_url, + timestamp_digest, + )?), + CodeFormat::Deploy => Some(sign_clickonce_deploy_bytes( + &bytes, + &nested_label, + cert, + key, + signing_digest, + )?), + CodeFormat::Pe | CodeFormat::Winmd => Some(sign_pe_bytes( + &bytes, + &nested_label, + cert, + key, + signing_digest, + skip_signed, + )?), + CodeFormat::Msix + | CodeFormat::Appx + | CodeFormat::MsixBundle + | CodeFormat::AppxBundle + | CodeFormat::AppxUpload + | CodeFormat::MsixUpload => Some(prepare_msix_family_bytes( + &bytes, + &nested_label, + digest, + signing_digest, + vsix_digest, + cert, + key, + chain_certs.clone(), + nested_excludes, + skip_signed, + overwrite, + None, + timestamp_url, + timestamp_digest, + )?), + _ if is_unsupported_nested_signable(&format) => { + return Err(anyhow!( + "`psign-tool code` nested execution cannot sign {nested_label} yet ({format:?})" + )); + } + _ => None, + }; + if let Some(bytes) = signed { + updates.push(ZipEntryUpdate { + name, + bytes, + compression, + }); + } + } + drop(archive); + + if updates.is_empty() { + return Ok(input_bytes.to_vec()); + } + let mut out = Cursor::new(Vec::new()); + repack_zip_with_updates(Cursor::new(input_bytes), &mut out, updates) + .with_context(|| format!("repack {label} with signed nested package entries"))?; + Ok(out.into_inner()) +} + +fn ensure_nuget_unsigned(bytes: &[u8], label: &str) -> Result<()> { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)) + .with_context(|| format!("open NuGet package {label}"))?; + if archive.by_name(nuget::PACKAGE_SIGNATURE_FILE_NAME).is_ok() { + return Err(anyhow!( + "{label} already contains {}; nested re-sign overwrite is not wired yet", + nuget::PACKAGE_SIGNATURE_FILE_NAME + )); + } + Ok(()) +} + +fn sign_clickonce_deploy_bytes( + input_bytes: &[u8], + label: &str, + cert: &Path, + key: &Path, + signing_digest: pkcs7::AuthenticodeSigningDigest, +) -> Result> { + if signing_digest != pkcs7::AuthenticodeSigningDigest::Sha256 { + return Err(anyhow!( + "ClickOnce .deploy payload signing currently supports only SHA-256" + )); + } + let content_name = clickonce_deploy_content_name(label) + .ok_or_else(|| anyhow!("{label} is not a ClickOnce .deploy payload path"))?; + let ext = Path::new(&content_name) + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + if !is_pe_like_extension(&ext) { + return Err(anyhow!( + "ClickOnce .deploy payload {label} maps to unsupported content name {content_name}" + )); + } + let cert_bytes = std::fs::read(cert).with_context(|| format!("read {}", cert.display()))?; + let key_bytes = std::fs::read(key).with_context(|| format!("read {}", key.display()))?; + sign_pe_bytes_with_key(input_bytes, &cert_bytes, &key_bytes) + .with_context(|| format!("sign ClickOnce .deploy PE payload {label}")) +} + +fn clickonce_deploy_content_name(label: &str) -> Option { + label + .rsplit_once('!') + .map(|(_, name)| name) + .unwrap_or(label) + .strip_suffix(".deploy") + .map(str::to_owned) +} + +fn is_pe_like_extension(ext: &str) -> bool { + matches!( + ext, + "exe" | "dll" | "sys" | "ocx" | "efi" | "scr" | "cpl" | "mui" | "winmd" + ) +} + +fn sign_pe_bytes( + input_bytes: &[u8], + label: &str, + cert: &Path, + key: &Path, + signing_digest: pkcs7::AuthenticodeSigningDigest, + skip_signed: bool, +) -> Result> { + if skip_signed && pe_has_signature(input_bytes) { + return Ok(input_bytes.to_vec()); + } + if signing_digest != pkcs7::AuthenticodeSigningDigest::Sha256 { + return Err(anyhow!("PE/WinMD signing currently supports only SHA-256")); + } + let cert_bytes = std::fs::read(cert).with_context(|| format!("read {}", cert.display()))?; + let key_bytes = std::fs::read(key).with_context(|| format!("read {}", key.display()))?; + sign_pe_bytes_with_key(input_bytes, &cert_bytes, &key_bytes) + .with_context(|| format!("sign PE/WinMD payload {label}")) +} + +fn sign_pe_bytes_with_key( + input_bytes: &[u8], + cert_bytes: &[u8], + key_bytes: &[u8], +) -> Result> { + psign_sip_digest::pe_sign::sign_pe_image_rsa_sha256(input_bytes, cert_bytes, key_bytes) +} + +#[allow(clippy::too_many_arguments)] +fn prepare_msix_family_bytes( + input_bytes: &[u8], + label: &str, + digest: nuget::NuGetHashAlgorithm, + signing_digest: pkcs7::AuthenticodeSigningDigest, + vsix_digest: vsix::VsixHashAlgorithm, + cert: &Path, + key: &Path, + chain_certs: Vec, + nested_excludes: &[String], + skip_signed: bool, + overwrite: bool, + publisher: Option<&str>, + timestamp_url: Option<&str>, + timestamp_digest: Option, +) -> Result> { + ensure_unsigned_msix_family(input_bytes, label)?; + let mut updated = sign_nested_package_entries( + input_bytes, + label, + digest, + signing_digest, + vsix_digest, + cert, + key, + chain_certs, + nested_excludes, + skip_signed, + overwrite, + timestamp_url, + timestamp_digest, + )?; + if let Some(publisher) = publisher { + updated = update_msix_manifest_publisher_bytes(&updated, label, publisher)?; + } + if zip_contains_entry(&updated, "AppxBlockMap.xml")? { + updated = regenerate_msix_block_map_bytes(&updated, label)?; + } + Ok(updated) +} + +fn ensure_unsigned_msix_family(bytes: &[u8], label: &str) -> Result<()> { + if zip_contains_entry(bytes, "AppxSignature.p7x")? { + return Err(anyhow!( + "{label} already contains AppxSignature.p7x; update the unsigned package before final AppX signing" + )); + } + Ok(()) +} + +fn update_msix_manifest_publisher_bytes( + input_bytes: &[u8], + label: &str, + publisher: &str, +) -> Result> { + if publisher.is_empty() { + return Err(anyhow!("MSIX/AppX publisher cannot be empty")); + } + let escaped = xml_escape_attr(publisher); + let mut archive = zip::ZipArchive::new(Cursor::new(input_bytes)) + .with_context(|| format!("open MSIX/AppX package {label}"))?; + let mut updates = Vec::new(); + let mut updated_manifest = false; + for i in 0..archive.len() { + let mut file = archive + .by_index(i) + .with_context(|| format!("read MSIX/AppX entry in {label}"))?; + if file.is_dir() { + continue; + } + let name = normalize_zip_name(file.name())?; + if name == "AppxManifest.xml" { + let compression = file.compression(); + let mut text = String::new(); + file.read_to_string(&mut text) + .context("read AppxManifest.xml as UTF-8")?; + let updated = update_attr_for_tags(&text, "Identity", "Publisher", &escaped)?; + updates.push(ZipEntryUpdate { + name, + bytes: updated.into_bytes(), + compression, + }); + updated_manifest = true; + } + } + drop(archive); + if !updated_manifest { + return Err(anyhow!("{label} is missing AppxManifest.xml")); + } + let mut out = Cursor::new(Vec::new()); + repack_zip_with_updates(Cursor::new(input_bytes), &mut out, updates) + .with_context(|| format!("repack {label} with updated AppxManifest.xml"))?; + Ok(out.into_inner()) +} + +fn update_appinstaller_publisher_bytes(bytes: &[u8], publisher: &str) -> Result> { + if publisher.is_empty() { + return Err(anyhow!("App Installer publisher cannot be empty")); + } + let text = std::str::from_utf8(bytes).context("App Installer descriptor is not UTF-8")?; + validate_appinstaller_descriptor(bytes)?; + let escaped = xml_escape_attr(publisher); + let mut updated = text.to_owned(); + for tag in ["MainPackage", "MainBundle"] { + updated = update_attr_for_tags(&updated, tag, "Publisher", &escaped)?; + } + Ok(updated.into_bytes()) +} + +fn regenerate_msix_block_map_bytes(input_bytes: &[u8], label: &str) -> Result> { + let block_map = build_msix_block_map_xml(input_bytes)?; + let mut out = Cursor::new(Vec::new()); + repack_zip_with_updates( + Cursor::new(input_bytes), + &mut out, + vec![ZipEntryUpdate { + name: "AppxBlockMap.xml".to_owned(), + bytes: block_map, + compression: zip::CompressionMethod::Stored, + }], + ) + .with_context(|| format!("repack {label} with regenerated AppxBlockMap.xml"))?; + Ok(out.into_inner()) +} + +fn build_msix_block_map_xml(input_bytes: &[u8]) -> Result> { + let mut archive = + zip::ZipArchive::new(Cursor::new(input_bytes)).context("open MSIX/AppX ZIP")?; + let mut files = Vec::new(); + for i in 0..archive.len() { + let mut file = archive + .by_index(i) + .context("read MSIX/AppX entry for block map")?; + if file.is_dir() { + continue; + } + let name = normalize_zip_name(file.name())?; + if matches!( + name.as_str(), + "[Content_Types].xml" | "AppxBlockMap.xml" | "AppxSignature.p7x" + ) { + continue; + } + let mut bytes = Vec::with_capacity(file.size() as usize); + file.read_to_end(&mut bytes)?; + files.push((name, bytes)); + } + files.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut xml = String::new(); + xml.push_str(r#""#); + xml.push_str(r#""#); + for (name, bytes) in files { + xml.push_str(&format!( + r#""#, + xml_escape_attr(&name), + bytes.len() + )); + for chunk in bytes.chunks(64 * 1024) { + let hash = sha2::Sha256::digest(chunk); + xml.push_str(&format!( + r#""#, + BASE64_STANDARD.encode(hash) + )); + } + xml.push_str(""); + } + xml.push_str(""); + Ok(xml.into_bytes()) +} + +fn zip_contains_entry(bytes: &[u8], entry_name: &str) -> Result { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).context("open ZIP")?; + Ok(archive.by_name(entry_name).is_ok()) +} + +fn update_attr_for_tags(text: &str, tag: &str, attr: &str, escaped_value: &str) -> Result { + let mut out = String::with_capacity(text.len()); + let mut cursor = 0usize; + let needle = format!("<{tag}"); + while let Some(rel_start) = text[cursor..].find(&needle) { + let start = cursor + rel_start; + let end = text[start..] + .find('>') + .map(|offset| start + offset) + .ok_or_else(|| anyhow!("XML <{tag}> tag is not closed"))?; + out.push_str(&text[cursor..start]); + out.push_str(&replace_or_insert_xml_attr( + &text[start..=end], + attr, + escaped_value, + )?); + cursor = end + 1; + } + out.push_str(&text[cursor..]); + Ok(out) +} + +fn replace_or_insert_xml_attr(tag: &str, attr: &str, escaped_value: &str) -> Result { + let needle = format!("{attr}=\""); + if let Some(value_start) = tag.find(&needle).map(|idx| idx + needle.len()) { + let value_end = tag[value_start..] + .find('"') + .map(|offset| value_start + offset) + .ok_or_else(|| anyhow!("XML {attr} attribute is not closed"))?; + let mut out = String::with_capacity(tag.len() + escaped_value.len()); + out.push_str(&tag[..value_start]); + out.push_str(escaped_value); + out.push_str(&tag[value_end..]); + return Ok(out); + } + + let insert_at = tag + .rfind("/>") + .or_else(|| tag.rfind('>')) + .ok_or_else(|| anyhow!("XML tag is not closed"))?; + let mut out = String::with_capacity(tag.len() + attr.len() + escaped_value.len() + 4); + out.push_str(&tag[..insert_at]); + out.push(' '); + out.push_str(attr); + out.push_str("=\""); + out.push_str(escaped_value); + out.push('"'); + out.push_str(&tag[insert_at..]); + Ok(out) +} + +fn xml_escape_attr(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} + +fn pe_has_signature(bytes: &[u8]) -> bool { + psign_sip_digest::verify_pe::pe_pkcs7_signed_data_entry_count(bytes) + .is_ok_and(|count| count > 0) +} + +fn package_has_signature(bytes: &[u8], format: &CodeFormat) -> Result { + let mut archive = + zip::ZipArchive::new(Cursor::new(bytes)).context("open package to inspect signature")?; + for i in 0..archive.len() { + let file = archive.by_index(i)?; + if file.is_dir() { + continue; + } + let name = normalize_zip_name(file.name())?; + let signed = match format { + CodeFormat::Nuget | CodeFormat::Snupkg => name == nuget::PACKAGE_SIGNATURE_FILE_NAME, + CodeFormat::Vsix => { + name == opc::OPC_SIGNATURE_ORIGIN_PART + || name == vsix::DEFAULT_VSIX_SIGNATURE_PART + || name.starts_with(opc::OPC_SIGNATURES_PREFIX) + } + _ => false, + }; + if signed { + return Ok(true); + } + } + Ok(false) +} + +fn is_unsupported_nested_signable(format: &CodeFormat) -> bool { + matches!( + format, + CodeFormat::Cab + | CodeFormat::Msi + | CodeFormat::Msp + | CodeFormat::Mst + | CodeFormat::Catalog + | CodeFormat::Script + | CodeFormat::Msix + | CodeFormat::Appx + | CodeFormat::MsixBundle + | CodeFormat::AppxBundle + | CodeFormat::AppxUpload + | CodeFormat::MsixUpload + | CodeFormat::EncryptedMsix + | CodeFormat::ClickOnceApplication + | CodeFormat::Vsto + | CodeFormat::Manifest + | CodeFormat::AppInstaller + | CodeFormat::BusinessCentralApp + ) +} + +fn sign_pkcs7_id_data( + content: &[u8], + cert: &Path, + key: &Path, + chain_certs: Vec, + digest: pkcs7::AuthenticodeSigningDigest, + timestamp_url: Option<&str>, + timestamp_digest: Option, +) -> Result> { + let cert_bytes = std::fs::read(cert).with_context(|| format!("read {}", cert.display()))?; + let signer_cert = rdp::parse_certificate(&cert_bytes) + .with_context(|| format!("parse signer certificate {}", cert.display()))?; + let key_bytes = std::fs::read(key).with_context(|| format!("read {}", key.display()))?; + let private_key = rdp::parse_rsa_private_key(&key_bytes) + .with_context(|| format!("parse RSA private key {}", key.display()))?; + let mut chain = Vec::with_capacity(chain_certs.len()); + for chain_cert in chain_certs { + let bytes = + std::fs::read(&chain_cert).with_context(|| format!("read {}", chain_cert.display()))?; + chain.push( + rdp::parse_certificate(&bytes) + .with_context(|| format!("parse chain certificate {}", chain_cert.display()))?, + ); + } + let econtent_der = OctetString::new(content.to_vec()) + .map_err(|e| anyhow!("encode CMS id-data OCTET STRING: {e}"))? + .to_der() + .map_err(|e| anyhow!("encode CMS id-data DER: {e}"))?; + let id_data = ObjectIdentifier::new(pkcs7::PKCS7_ID_DATA_OID) + .map_err(|e| anyhow!("parse CMS id-data OID: {e}"))?; + let pkcs7 = pkcs7::create_pkcs7_signed_data_der_rsa( + id_data, + &econtent_der, + digest, + signer_cert, + chain, + private_key, + )?; + let mut detached = pkcs7::parse_pkcs7_signed_data_der(&pkcs7) + .context("parse generated CMS before detaching eContent")?; + detached.encap_content_info.econtent = None; + let pkcs7 = pkcs7::encode_pkcs7_content_info_signed_data_der(&detached)?; + timestamp_pkcs7_if_requested(&pkcs7, timestamp_url, timestamp_digest) +} + +fn timestamp_pkcs7_if_requested( + pkcs7_der: &[u8], + timestamp_url: Option<&str>, + timestamp_digest: Option, +) -> Result> { + match (timestamp_url, timestamp_digest) { + (Some(url), Some(digest)) => timestamp_pkcs7_der_rfc3161(pkcs7_der, 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(pkcs7_der.to_vec()), + } +} + +#[cfg(feature = "timestamp-http")] +fn timestamp_pkcs7_der_rfc3161( + pkcs7_der: &[u8], + timestamp_url: &str, + timestamp_digest: DigestAlgorithm, +) -> Result> { + let sd = pkcs7::parse_pkcs7_signed_data_der(pkcs7_der).context("parse PKCS#7 SignedData")?; + let signer = sd + .signer_infos + .0 + .as_slice() + .first() + .ok_or_else(|| anyhow!("PKCS#7 SignedData has no SignerInfo to timestamp"))?; + let imprint = timestamp_digest_bytes(timestamp_digest, signer.signature.as_bytes())?; + let response = post_rfc3161_timestamp_request(timestamp_url, timestamp_digest, &imprint)?; + let parsed = parse_time_stamp_resp_der(&response) + .ok_or_else(|| anyhow!("could not parse TimeStampResp DER from TSA response"))?; + if !parsed.pki_status.granted() { + return Err(anyhow!( + "TimeStampResp status is not granted (status={})", + parsed.pki_status.as_raw_integer() + )); + } + let token = parsed + .time_stamp_token + .ok_or_else(|| anyhow!("TimeStampResp has no timeStampToken"))?; + let stamped = pkcs7::signed_data_add_rfc3161_timestamp_token(&sd, 0, token) + .context("attach RFC3161 timestamp token")?; + pkcs7::encode_pkcs7_content_info_signed_data_der(&stamped) +} + +#[cfg(not(feature = "timestamp-http"))] +fn timestamp_pkcs7_der_rfc3161( + _pkcs7_der: &[u8], + _timestamp_url: &str, + _timestamp_digest: DigestAlgorithm, +) -> Result> { + Err(anyhow!( + "`psign-tool code` RFC3161 timestamping requires the timestamp-http feature" + )) +} + +#[cfg(feature = "timestamp-http")] +fn post_rfc3161_timestamp_request( + url: &str, + algorithm: DigestAlgorithm, + message_imprint: &[u8], +) -> Result> { + let plan = psign_sip_digest::timestamp::Rfc3161TimestampRequestPlan { + digest_alg_oid: timestamp_digest_oid(algorithm)?, + nonce: None, + cert_req: true, + }; + let der = build_timestamp_request_bytes(&plan, message_imprint).ok_or_else(|| { + anyhow!("unsupported digest OID / preimage length for RFC3161 TimeStampReq") + })?; + let client = reqwest::blocking::Client::builder() + .use_rustls_tls() + .timeout(std::time::Duration::from_secs(120)) + .build() + .context("build HTTP client")?; + let resp = client + .post(url.trim()) + .header("Content-Type", "application/timestamp-query") + .header( + "Accept", + "application/timestamp-reply, application/timestamp-response", + ) + .body(der) + .send() + .with_context(|| format!("POST TimeStampReq to {}", url.trim()))?; + let status = resp.status(); + let body = resp.bytes().context("read TSA response body")?; + if !status.is_success() { + return Err(anyhow!( + "TSA HTTP {} - first {} body bytes (hex): {}", + status, + body.len().min(256), + hex_lower(&body[..body.len().min(256)]) + )); + } + Ok(body.to_vec()) +} + +#[cfg(feature = "timestamp-http")] +fn timestamp_digest_oid(digest: DigestAlgorithm) -> Result<&'static str> { + match digest { + DigestAlgorithm::Sha1 => Ok("1.3.14.3.2.26"), + DigestAlgorithm::Sha256 => Ok("2.16.840.1.101.3.4.2.1"), + DigestAlgorithm::Sha384 => Ok("2.16.840.1.101.3.4.2.2"), + DigestAlgorithm::Sha512 => Ok("2.16.840.1.101.3.4.2.3"), + DigestAlgorithm::CertHash => Err(anyhow!( + "`psign-tool code` timestamping supports SHA-1, SHA-256, SHA-384, or SHA-512" + )), + } +} + +#[cfg(feature = "timestamp-http")] +fn timestamp_digest_bytes(digest: DigestAlgorithm, bytes: &[u8]) -> Result> { + match digest { + DigestAlgorithm::Sha1 => Ok(sha1::Sha1::digest(bytes).to_vec()), + DigestAlgorithm::Sha256 => Ok(sha2::Sha256::digest(bytes).to_vec()), + DigestAlgorithm::Sha384 => Ok(sha2::Sha384::digest(bytes).to_vec()), + DigestAlgorithm::Sha512 => Ok(sha2::Sha512::digest(bytes).to_vec()), + DigestAlgorithm::CertHash => Err(anyhow!( + "`psign-tool code` timestamping supports SHA-1, SHA-256, SHA-384, or SHA-512" + )), + } +} + +fn nuget_hash_algorithm(digest: DigestAlgorithm) -> Result { + match digest { + DigestAlgorithm::Sha256 => Ok(nuget::NuGetHashAlgorithm::Sha256), + DigestAlgorithm::Sha384 => Ok(nuget::NuGetHashAlgorithm::Sha384), + DigestAlgorithm::Sha512 => Ok(nuget::NuGetHashAlgorithm::Sha512), + DigestAlgorithm::Sha1 | DigestAlgorithm::CertHash => Err(anyhow!( + "`psign-tool code` package signing supports SHA-256, SHA-384, or SHA-512 file digests" + )), + } +} + +fn signing_digest_algorithm(digest: DigestAlgorithm) -> Result { + match digest { + DigestAlgorithm::Sha256 => Ok(pkcs7::AuthenticodeSigningDigest::Sha256), + DigestAlgorithm::Sha384 => Ok(pkcs7::AuthenticodeSigningDigest::Sha384), + DigestAlgorithm::Sha512 => Ok(pkcs7::AuthenticodeSigningDigest::Sha512), + DigestAlgorithm::Sha1 | DigestAlgorithm::CertHash => Err(anyhow!( + "`psign-tool code` package signing supports SHA-256, SHA-384, or SHA-512 file digests" + )), + } +} + +fn vsix_hash_algorithm(digest: DigestAlgorithm) -> Result { + match digest { + DigestAlgorithm::Sha256 => Ok(vsix::VsixHashAlgorithm::Sha256), + DigestAlgorithm::Sha384 => Ok(vsix::VsixHashAlgorithm::Sha384), + DigestAlgorithm::Sha512 => Ok(vsix::VsixHashAlgorithm::Sha512), + DigestAlgorithm::Sha1 | DigestAlgorithm::CertHash => Err(anyhow!( + "`psign-tool code` package signing supports SHA-256, SHA-384, or SHA-512 file digests" + )), + } +} + +fn sign_xml_signed_info( + algorithm: vsix::VsixHashAlgorithm, + private_key: rsa::RsaPrivateKey, + signed_info: &[u8], +) -> Vec { + match algorithm { + vsix::VsixHashAlgorithm::Sha256 => { + rsa::pkcs1v15::SigningKey::::new(private_key) + .sign(signed_info) + .to_vec() + } + vsix::VsixHashAlgorithm::Sha384 => { + rsa::pkcs1v15::SigningKey::::new(private_key) + .sign(signed_info) + .to_vec() + } + vsix::VsixHashAlgorithm::Sha512 => { + rsa::pkcs1v15::SigningKey::::new(private_key) + .sign(signed_info) + .to_vec() + } + } +} + +fn output_path_for_node(node: &CodePlanNode) -> Result { + if node.output_path.contains('!') { + return Err(anyhow!( + "`psign-tool code` signing execution cannot write nested output path {} yet", + node.output_path + )); + } + Ok(display_to_path(&node.output_path)) +} + +fn appinstaller_companion_output_path(node: &CodePlanNode) -> Result { + let mut output = output_path_for_node(node)?; + if output + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("p7")) + { + return Ok(output); + } + let Some(name) = output.file_name().and_then(|name| name.to_str()) else { + return Err(anyhow!( + "invalid App Installer output path {}", + output.display() + )); + }; + output.set_file_name(format!("{name}.p7")); + Ok(output) +} + +fn appinstaller_descriptor_output_path(node: &CodePlanNode) -> Result { + let output = output_path_for_node(node)?; + if output + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("p7")) + { + let stem = output + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or_else(|| anyhow!("invalid App Installer output path {}", output.display()))?; + let mut descriptor = output.clone(); + descriptor.set_file_name(stem); + return Ok(descriptor); + } + Ok(output) +} + +fn display_to_path(display: &str) -> PathBuf { + PathBuf::from(display.replace('/', std::path::MAIN_SEPARATOR_STR)) +} + +#[cfg(feature = "timestamp-http")] +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &b in bytes { + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0x0f) as usize] as char); + } + out +} + +fn ensure_parent_dir(path: &Path) -> Result<()> { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent) + .with_context(|| format!("create output directory {}", parent.display()))?; + } + Ok(()) +} + +fn validate_appinstaller_descriptor(bytes: &[u8]) -> Result<()> { + let text = std::str::from_utf8(bytes).context("App Installer descriptor is not UTF-8")?; + if !text.contains(" not found" + )); + } + if !text.contains(" Result> { + let mut rules = Vec::new(); + for input in &args.inputs { + rules.extend(pattern_rules(input)?); + } + if let Some(file_list) = &args.file_list { + let path = if file_list.is_absolute() { + file_list.clone() + } else { + base.join(file_list) + }; + let text = std::fs::read_to_string(&path) + .with_context(|| format!("read file list {}", path.display()))?; + for (line_no, raw) in text.lines().enumerate() { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + rules.extend( + pattern_rules(trimmed) + .with_context(|| format!("parse {} line {}", path.display(), line_no + 1))?, + ); + } + } + if rules.is_empty() { + return Err(anyhow!( + "code dry-run requires at least one input or --file-list entry" + )); + } + Ok(rules) +} + +fn nested_exclude_patterns(base: &Path, args: &CodeArgs) -> Result> { + Ok(selection_rules(base, args)? + .into_iter() + .filter(|rule| !rule.include) + .map(|rule| rule.pattern) + .collect()) +} + +fn select_inputs(base: &Path, rules: &[PatternRule]) -> Result> { + let mut selected = BTreeSet::new(); + for rule in rules { + let matches = resolve_rule(base, rule)?; + if rule.include { + selected.extend(matches); + } else { + for path in matches { + selected.remove(&path); + } + } + } + Ok(selected.into_iter().collect()) +} + +fn pattern_rules(input: &str) -> Result> { + let mut include = true; + let pattern = if let Some(rest) = input.strip_prefix("\\!") { + format!("!{}", unescape_pattern(rest)?) + } else if let Some(rest) = input.strip_prefix('!') { + include = false; + unescape_pattern(rest)? + } else { + unescape_pattern(input)? + }; + if pattern.is_empty() { + return Err(anyhow!("empty pattern")); + } + validate_relative_pattern(&pattern)?; + Ok(expand_braces(&pattern)? + .into_iter() + .map(|pattern| PatternRule { include, pattern }) + .collect()) +} + +fn resolve_rule(base: &Path, rule: &PatternRule) -> Result> { + if has_glob_meta(&rule.pattern) { + let mut out = Vec::new(); + for candidate in walk_files(base)? { + let rel = candidate + .strip_prefix(base) + .with_context(|| format!("strip base from {}", candidate.display()))?; + let normalized = normalize_match_path(rel); + if glob_match(&rule.pattern, &normalized) { + out.push(candidate); + } + } + Ok(out) + } else { + let path = base.join(pattern_to_native_path(&rule.pattern)); + Ok(if path.is_file() { + vec![path] + } else { + Vec::new() + }) + } +} + +fn walk_files(root: &Path) -> Result> { + let mut stack = vec![root.to_path_buf()]; + let mut out = Vec::new(); + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + if file_type.is_dir() { + stack.push(path); + } else if file_type.is_file() { + out.push(path); + } + } + } + out.sort(); + Ok(out) +} + +impl PlanBuilder { + fn add_path( + &mut self, + path: &Path, + base: &Path, + depth: usize, + recurse_containers: bool, + ) -> Result { + let display = path + .strip_prefix(base) + .map(normalize_match_path) + .unwrap_or_else(|_| display_path(path)); + let output_display = self.top_level_output_path(&display); + self.add_node_from_reader( + path, + display, + output_display, + depth, + recurse_containers, + None, + ) + } + + fn add_nested_zip_entry( + &mut self, + display: String, + output_display: String, + depth: usize, + recurse_containers: bool, + bytes: Vec, + ) -> Result { + let path = PathBuf::from(&display); + self.add_node_from_reader( + &path, + display, + output_display, + depth, + recurse_containers, + Some(bytes), + ) + } + + fn add_node_from_reader( + &mut self, + path: &Path, + display: String, + output_display: String, + depth: usize, + recurse_containers: bool, + bytes: Option>, + ) -> Result { + if let Some(id) = self.node_ids.get(&display) { + return Ok(*id); + } + let owned_prefix = if bytes.is_none() && is_extension(path, "app") && path.is_file() { + Some(read_prefix(path, 4)?) + } else { + None + }; + let format = detect_format(path, bytes.as_deref().or(owned_prefix.as_deref())); + let id = self.nodes.len(); + self.node_ids.insert(display.clone(), id); + self.nodes.push(CodePlanNode { + id, + path: display.clone(), + output_path: output_display.clone(), + signer: signer_for_format(&format), + container: format.is_container(), + format: format.clone(), + depth, + }); + + if recurse_containers && format.is_container() { + let nested = if let Some(bytes) = bytes { + self.inspect_zip_entries(Cursor::new(bytes), &display, &output_display, depth + 1)? + } else { + self.inspect_zip_entries( + File::open(path).with_context(|| format!("open {}", path.display()))?, + &display, + &output_display, + depth + 1, + )? + }; + for entry in nested { + let child = self.add_nested_zip_entry( + entry.display, + entry.output_display, + entry.depth, + recurse_containers, + entry.bytes, + )?; + self.edges.push(CodePlanEdge { + before: child, + after: id, + }); + } + } + Ok(id) + } + + fn top_level_output_path(&self, display: &str) -> String { + let Some(output) = &self.output else { + return display.to_owned(); + }; + let output_display = normalize_output_display(output); + if self.top_level_count == 1 && !is_output_directory_target(output) { + output_display + } else { + join_display_path(&output_display, display) + } + } + + fn inspect_zip_entries( + &self, + reader: R, + container: &str, + container_output: &str, + depth: usize, + ) -> Result> + where + R: Read + Seek, + { + let mut archive = zip::ZipArchive::new(reader) + .with_context(|| format!("open ZIP container {container}"))?; + let mut entries = Vec::new(); + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + if file.is_dir() { + continue; + } + let name = normalize_zip_name(file.name())?; + let display = format!("{container}!{name}"); + let output_display = format!("{container_output}!{name}"); + if self.nested_excludes.iter().any(|pattern| { + glob_match(pattern, &name) || glob_match(pattern, &display.replace('!', "/")) + }) { + continue; + } + let format = detect_format(Path::new(&name), None); + if format == CodeFormat::Unknown { + continue; + } + let mut bytes = Vec::with_capacity(file.size() as usize); + file.read_to_end(&mut bytes)?; + entries.push(NestedEntry { + display, + output_display, + depth, + bytes, + }); + } + entries.sort_by(|a, b| a.display.cmp(&b.display)); + Ok(entries) + } +} + +struct NestedEntry { + display: String, + output_display: String, + depth: usize, + bytes: Vec, +} + +#[allow(dead_code)] +pub(crate) struct ZipEntryUpdate { + pub name: String, + pub bytes: Vec, + pub compression: zip::CompressionMethod, +} + +#[allow(dead_code)] +pub(crate) fn repack_zip_with_updates( + reader: R, + writer: W, + updates: Vec, +) -> Result<()> +where + R: Read + Seek, + W: Write + Seek, +{ + let mut pending = BTreeMap::new(); + for update in updates { + let name = normalize_zip_name(&update.name)?; + if pending.insert(name.clone(), update).is_some() { + return Err(anyhow!("duplicate ZIP update entry: {name}")); + } + } + + let mut input = zip::ZipArchive::new(reader).context("open ZIP for repack")?; + let mut output = zip::ZipWriter::new(writer); + for i in 0..input.len() { + let mut file = input.by_index(i).context("read ZIP entry for repack")?; + let name = normalize_zip_name(file.name())?; + if let Some(update) = pending.remove(&name) { + output.start_file( + name, + zip::write::FileOptions::default().compression_method(update.compression), + )?; + output.write_all(&update.bytes)?; + } else { + let options = zip::write::FileOptions::default().compression_method(file.compression()); + if file.is_dir() { + output.add_directory(name, options)?; + } else { + output.start_file(name, options)?; + std::io::copy(&mut file, &mut output)?; + } + } + } + + for (name, update) in pending { + output.start_file( + name, + zip::write::FileOptions::default().compression_method(update.compression), + )?; + output.write_all(&update.bytes)?; + } + output.finish()?; + Ok(()) +} + +fn detect_format(path: &Path, bytes: Option<&[u8]>) -> CodeFormat { + if is_extension(path, "app") { + return if bytes.is_some_and(|b| b.starts_with(b"NAVX")) { + CodeFormat::BusinessCentralApp + } else { + CodeFormat::Unknown + }; + } + match path + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("exe" | "dll" | "sys" | "ocx" | "efi" | "scr" | "cpl" | "mui") => CodeFormat::Pe, + Some("winmd") => CodeFormat::Winmd, + Some("cab") => CodeFormat::Cab, + Some("msi") => CodeFormat::Msi, + Some("msp") => CodeFormat::Msp, + Some("mst") => CodeFormat::Mst, + Some("cat") => CodeFormat::Catalog, + Some( + "ps1" | "psd1" | "psm1" | "ps1xml" | "psc1" | "cdxml" | "mof" | "js" | "vbs" | "wsf" + | "jse" | "vbe" | "wsc", + ) => CodeFormat::Script, + Some("msix") => CodeFormat::Msix, + Some("appx") => CodeFormat::Appx, + Some("msixbundle") => CodeFormat::MsixBundle, + Some("appxbundle") => CodeFormat::AppxBundle, + Some("appxupload") => CodeFormat::AppxUpload, + Some("msixupload") => CodeFormat::MsixUpload, + Some("eappx" | "emsix" | "eappxbundle" | "emsixbundle") => CodeFormat::EncryptedMsix, + Some("nupkg") => CodeFormat::Nuget, + Some("snupkg") => CodeFormat::Snupkg, + Some("vsix") => CodeFormat::Vsix, + Some("application") => CodeFormat::ClickOnceApplication, + Some("vsto") => CodeFormat::Vsto, + Some("manifest") => CodeFormat::Manifest, + Some("deploy") => CodeFormat::Deploy, + Some("appinstaller") => CodeFormat::AppInstaller, + Some("zip") => CodeFormat::Zip, + _ => CodeFormat::Unknown, + } +} + +fn is_extension(path: &Path, expected: &str) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case(expected)) +} + +fn read_prefix(path: &Path, len: usize) -> Result> { + let mut file = File::open(path).with_context(|| format!("open {}", path.display()))?; + file.seek(SeekFrom::Start(0))?; + let mut buf = vec![0u8; len]; + let read = file.read(&mut buf)?; + buf.truncate(read); + Ok(buf) +} + +fn signer_for_format(format: &CodeFormat) -> &'static str { + match format { + CodeFormat::Nuget | CodeFormat::Snupkg => "nuget-package-native", + CodeFormat::Vsix => "vsix-opc-xmldsig", + CodeFormat::ClickOnceApplication + | CodeFormat::Vsto + | CodeFormat::Manifest + | CodeFormat::Deploy => "clickonce-manifest", + CodeFormat::EncryptedMsix => "msix-encrypted-os-only", + CodeFormat::AppInstaller => "appinstaller-detached-pkcs7", + CodeFormat::BusinessCentralApp => "business-central-app", + CodeFormat::Zip => "container-only", + CodeFormat::Unknown => "unsupported", + _ => "authenticode", + } +} + +fn digest_name(digest: DigestAlgorithm) -> &'static str { + match digest { + DigestAlgorithm::Sha1 => "sha1", + DigestAlgorithm::Sha256 => "sha256", + DigestAlgorithm::Sha384 => "sha384", + DigestAlgorithm::Sha512 => "sha512", + DigestAlgorithm::CertHash => "cert-hash", + } +} + +fn render_text_plan(plan: &CodePlan) -> String { + let mut out = String::new(); + out.push_str("psign code dry-run plan\n"); + out.push_str(&format!("base_directory={}\n", plan.base_directory)); + out.push_str(&format!("nodes={}\n", plan.nodes.len())); + for node in &plan.nodes { + out.push_str(&format!( + "#{:03} depth={} format={:?} signer={} path={} output={}\n", + node.id, node.depth, node.format, node.signer, node.path, node.output_path + )); + } + out +} + +fn validate_relative_pattern(pattern: &str) -> Result<()> { + let path = pattern_to_native_path(pattern); + if Path::new(&path).is_absolute() { + return Err(anyhow!("absolute patterns are not supported: {pattern}")); + } + for component in Path::new(&path).components() { + if matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) { + return Err(anyhow!( + "pattern may not traverse outside the base directory: {pattern}" + )); + } + } + Ok(()) +} + +fn has_glob_meta(pattern: &str) -> bool { + pattern.contains('*') || pattern.contains('?') +} + +fn pattern_to_native_path(pattern: &str) -> PathBuf { + pattern.split('/').collect() +} + +fn is_output_directory_target(path: &Path) -> bool { + let text = path.as_os_str().to_string_lossy(); + path.is_dir() || text.ends_with('/') || text.ends_with('\\') +} + +fn normalize_output_display(path: &Path) -> String { + path.display().to_string().replace('\\', "/") +} + +fn join_display_path(base: &str, rel: &str) -> String { + if base.is_empty() { + rel.to_owned() + } else if base.ends_with('/') { + format!("{base}{rel}") + } else { + format!("{base}/{rel}") + } +} + +fn normalize_match_path(path: &Path) -> String { + path.components() + .filter_map(|component| match component { + Component::Normal(s) => Some(s.to_string_lossy().replace('\\', "/")), + _ => None, + }) + .collect::>() + .join("/") +} + +fn display_path(path: &Path) -> String { + path.display().to_string() +} + +fn normalize_zip_name(name: &str) -> Result { + if name.is_empty() || name.starts_with('/') || name.contains('\\') { + return Err(anyhow!("unsafe ZIP entry path: {name}")); + } + if name.split('/').any(|part| part == "." || part == "..") { + return Err(anyhow!("unsafe ZIP entry path: {name}")); + } + Ok(name.to_owned()) +} + +fn unescape_pattern(input: &str) -> Result { + let mut out = String::new(); + let mut chars = input.chars(); + while let Some(ch) = chars.next() { + if ch == '\\' { + match chars.next() { + Some(next @ ('!' | '{' | '}' | '\\')) => out.push(next), + Some(next) => { + out.push('\\'); + out.push(next); + } + None => return Err(anyhow!("trailing escape")), + } + } else { + out.push(ch); + } + } + Ok(out) +} + +fn expand_braces(pattern: &str) -> Result> { + let Some((start, end)) = first_brace(pattern)? else { + return Ok(vec![pattern.to_owned()]); + }; + let before = &pattern[..start]; + let body = &pattern[start + 1..end]; + let after = &pattern[end + 1..]; + let choices = brace_choices(body)?; + let mut out = Vec::new(); + for choice in choices { + for expanded in expand_braces(&format!("{before}{choice}{after}"))? { + out.push(expanded); + } + } + Ok(out) +} + +fn first_brace(pattern: &str) -> Result> { + let mut start = None; + let mut depth = 0usize; + let mut escaped = false; + for (idx, ch) in pattern.char_indices() { + if escaped { + escaped = false; + continue; + } + match ch { + '\\' => escaped = true, + '{' => { + if depth == 0 { + start = Some(idx); + } + depth += 1; + } + '}' => { + if depth == 0 { + return Err(anyhow!("unmatched closing brace in pattern: {pattern}")); + } + depth -= 1; + if depth == 0 { + return Ok(Some((start.expect("brace start"), idx))); + } + } + _ => {} + } + } + if depth != 0 { + return Err(anyhow!("unmatched opening brace in pattern: {pattern}")); + } + Ok(None) +} + +fn brace_choices(body: &str) -> Result> { + if let Some((start, end)) = body.split_once("..") { + let first = start.parse::(); + let last = end.parse::(); + if let (Ok(first), Ok(last)) = (first, last) { + let width = start.len().max(end.len()); + let range: Box> = if first <= last { + Box::new(first..=last) + } else { + Box::new((last..=first).rev()) + }; + return Ok(range + .map(|n| { + if width > 1 { + format!("{n:0width$}") + } else { + n.to_string() + } + }) + .collect()); + } + } + + let mut choices = Vec::new(); + let mut current = String::new(); + let mut depth = 0usize; + for ch in body.chars() { + match ch { + '{' => { + depth += 1; + current.push(ch); + } + '}' => { + if depth == 0 { + return Err(anyhow!("unmatched closing brace in {body}")); + } + depth -= 1; + current.push(ch); + } + ',' if depth == 0 => { + choices.push(current); + current = String::new(); + } + _ => current.push(ch), + } + } + choices.push(current); + Ok(choices) +} + +fn glob_match(pattern: &str, text: &str) -> bool { + let p = pattern.as_bytes(); + let t = text.as_bytes(); + let mut memo = BTreeMap::new(); + glob_match_inner(p, t, 0, 0, &mut memo) +} + +fn glob_match_inner( + pattern: &[u8], + text: &[u8], + pi: usize, + ti: usize, + memo: &mut BTreeMap<(usize, usize), bool>, +) -> bool { + if let Some(value) = memo.get(&(pi, ti)) { + return *value; + } + let result = if pi == pattern.len() { + ti == text.len() + } else if pattern[pi] == b'*' { + if pi + 1 < pattern.len() && pattern[pi + 1] == b'*' { + ((pi + 2 < pattern.len() + && pattern[pi + 2] == b'/' + && glob_match_inner(pattern, text, pi + 3, ti, memo)) + || glob_match_inner(pattern, text, pi + 2, ti, memo)) + || (ti < text.len() && glob_match_inner(pattern, text, pi, ti + 1, memo)) + } else { + glob_match_inner(pattern, text, pi + 1, ti, memo) + || (ti < text.len() + && text[ti] != b'/' + && glob_match_inner(pattern, text, pi, ti + 1, memo)) + } + } else if pattern[pi] == b'?' { + ti < text.len() && text[ti] != b'/' && glob_match_inner(pattern, text, pi + 1, ti + 1, memo) + } else { + ti < text.len() + && pattern[pi].eq_ignore_ascii_case(&text[ti]) + && glob_match_inner(pattern, text, pi + 1, ti + 1, memo) + }; + memo.insert((pi, ti), result); + result +} + +#[cfg(test)] +mod tests { + use super::*; + use zip::write::FileOptions; + + #[test] + fn brace_expansion_supports_nested_lists_and_ranges() { + assert_eq!( + expand_braces("lib/{net{6,8}.0,tools}/file{01..02}.dll").unwrap(), + [ + "lib/net6.0/file01.dll", + "lib/net6.0/file02.dll", + "lib/net8.0/file01.dll", + "lib/net8.0/file02.dll", + "lib/tools/file01.dll", + "lib/tools/file02.dll", + ] + ); + } + + #[test] + fn globstar_crosses_directories_but_star_does_not() { + assert!(glob_match("**/*.dll", "lib/net8.0/a.dll")); + assert!(glob_match("**/*.dll", "a.dll")); + assert!(!glob_match("*.dll", "lib/net8.0/a.dll")); + assert!(glob_match("lib/net?.0/*.dll", "lib/net8.0/a.dll")); + } + + #[test] + fn rejects_traversal_patterns() { + assert!(pattern_rules("../secret.dll").is_err()); + assert!(pattern_rules("safe/../../secret.dll").is_err()); + } + + #[test] + fn repack_zip_replaces_and_appends_entries() { + let input = test_zip(&[ + ("lib/net8.0/a.dll", b"old".as_slice()), + ("content/readme.txt", b"text".as_slice()), + ]); + let mut output = Cursor::new(Vec::new()); + + repack_zip_with_updates( + Cursor::new(input), + &mut output, + vec![ + ZipEntryUpdate { + name: "lib/net8.0/a.dll".to_owned(), + bytes: b"new".to_vec(), + compression: zip::CompressionMethod::Deflated, + }, + ZipEntryUpdate { + name: ".signature.p7s".to_owned(), + bytes: b"cms".to_vec(), + compression: zip::CompressionMethod::Stored, + }, + ], + ) + .unwrap(); + + let mut archive = zip::ZipArchive::new(Cursor::new(output.into_inner())).unwrap(); + assert_eq!(read_zip_entry(&mut archive, "lib/net8.0/a.dll"), b"new"); + assert_eq!(read_zip_entry(&mut archive, ".signature.p7s"), b"cms"); + assert_eq!( + archive.by_name(".signature.p7s").unwrap().compression(), + zip::CompressionMethod::Stored + ); + } + + #[test] + fn repack_zip_rejects_unsafe_update_path() { + let input = test_zip(&[("file.txt", b"text".as_slice())]); + let err = repack_zip_with_updates( + Cursor::new(input), + Cursor::new(Vec::new()), + vec![ZipEntryUpdate { + name: "../evil.txt".to_owned(), + bytes: b"evil".to_vec(), + compression: zip::CompressionMethod::Deflated, + }], + ) + .unwrap_err(); + + assert!(err.to_string().contains("unsafe ZIP entry path")); + } + + fn test_zip(entries: &[(&str, &[u8])]) -> Vec { + let mut out = Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut out); + for (name, bytes) in entries { + writer + .start_file(*name, FileOptions::default()) + .expect("start zip entry"); + writer.write_all(bytes).expect("write zip entry"); + } + writer.finish().expect("finish zip"); + } + out.into_inner() + } + + fn read_zip_entry(archive: &mut zip::ZipArchive>>, name: &str) -> Vec { + let mut file = archive.by_name(name).expect("zip entry"); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).expect("read zip entry"); + bytes + } +} diff --git a/src/lib.rs b/src/lib.rs index 0f2fc02..4db385f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,10 +8,12 @@ pub mod cert_store; pub mod cli; +pub mod code; pub mod native_argv; pub mod portable_sign; pub mod rdp; pub mod response_argv; +pub mod signing_provider; #[cfg(windows)] pub mod win; @@ -329,6 +331,7 @@ fn execute_portable_inspect( fn execute_windows(cli: &crate::cli::Cli) -> anyhow::Result { use crate::cli::Command; match &cli.command { + Command::Code(args) => crate::code::code_command(args), Command::CertStore(args) => crate::cert_store::cert_store_command(args), Command::Portable(args) => run_portable_args(&args.args), Command::Verify(args) => crate::win::verify::verify_file(args, &cli.global), @@ -357,6 +360,7 @@ fn execute_windows(_cli: &crate::cli::Cli) -> anyhow::Result { fn execute_portable(cli: &crate::cli::Cli) -> anyhow::Result { use crate::cli::Command; match &cli.command { + Command::Code(args) => crate::code::code_command(args), Command::CertStore(args) => crate::cert_store::cert_store_command(args), Command::Portable(args) => run_portable_args(&args.args), Command::Verify(args) => execute_portable_verify(args), @@ -388,6 +392,9 @@ fn execute(cli: &crate::cli::Cli) -> anyhow::Result { if let crate::cli::Command::Portable(args) = &cli.command { return run_portable_args(&args.args); } + if let crate::cli::Command::Code(args) = &cli.command { + return crate::code::code_command(args); + } match effective_tool_mode(resolved_tool_mode(&cli.global)?) { crate::cli::ToolMode::Windows => execute_windows(cli), crate::cli::ToolMode::Portable => execute_portable(cli), diff --git a/src/portable_sign.rs b/src/portable_sign.rs index d75f33a..bff2f64 100644 --- a/src/portable_sign.rs +++ b/src/portable_sign.rs @@ -1,5 +1,5 @@ use crate::CommandOutput; -use crate::cli::{DigestAlgorithm, GlobalOpts, SignArgs}; +use crate::cli::{AzureCredentialType, DigestAlgorithm, GlobalOpts, SignArgs}; use anyhow::{Context, Result, anyhow}; use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -193,6 +193,10 @@ fn validate_supported_options(args: &SignArgs) -> Result<()> { "--azure-key-vault-managed-identity", args.azure_key_vault_managed_identity, )?; + reject_option( + "--azure-key-vault-credential-type", + args.azure_key_vault_credential_type.is_some(), + )?; reject_string_option("--azure-authority", &args.azure_authority)?; reject_artifact_signing_options(args)?; reject_path_option("--input-file-list", &args.sign_input_file_list)?; @@ -235,6 +239,10 @@ fn validate_azure_key_vault_supported_options(args: &SignArgs) -> Result<()> { &args.trusted_signing_dlib_root, )?; reject_path_option("--dmdf", &args.dmdf)?; + reject_workload_identity( + "--azure-key-vault-credential-type", + args.azure_key_vault_credential_type, + )?; if args.timestamp_url.is_some() && args.timestamp_digest.is_none() { return Err(anyhow!( "portable Azure Key Vault sign requires --td/--timestamp-digest with --tr/--timestamp-url" @@ -325,6 +333,10 @@ fn validate_artifact_signing_supported_options(args: &SignArgs) -> Result<()> { "use either --artifact-signing-metadata or --dmdf as Artifact Signing metadata, not both" )); } + reject_workload_identity( + "--artifact-signing-credential-type", + args.artifact_signing_credential_type, + )?; if args.timestamp_url.is_some() && args.timestamp_digest.is_none() { return Err(anyhow!( "portable Artifact Signing sign requires --td/--timestamp-digest with --tr/--timestamp-url" @@ -513,7 +525,7 @@ fn run_portable_sign_pe_azure_key_vault( "--azure-key-vault-accesstoken", &args.azure_key_vault_access_token, ); - if args.azure_key_vault_managed_identity { + if effective_azure_key_vault_managed_identity(args) { argv.push(OsString::from("--azure-key-vault-managed-identity")); } push_option( @@ -610,7 +622,7 @@ fn run_portable_sign_pe_artifact_signing( "--artifact-signing-access-token", &args.artifact_signing_access_token, ); - if args.artifact_signing_managed_identity { + if effective_artifact_signing_managed_identity(args) { argv.push(OsString::from("--artifact-signing-managed-identity")); } push_option( @@ -704,6 +716,7 @@ fn azure_key_vault_requested(args: &SignArgs) -> bool { || text_present(&args.azure_key_vault_tenant_id) || text_present(&args.azure_key_vault_access_token) || args.azure_key_vault_managed_identity + || args.azure_key_vault_credential_type.is_some() || text_present(&args.azure_authority) } @@ -719,6 +732,7 @@ fn artifact_signing_requested(args: &SignArgs) -> bool { || text_present(&args.artifact_signing_correlation_id) || text_present(&args.artifact_signing_access_token) || args.artifact_signing_managed_identity + || args.artifact_signing_credential_type.is_some() || text_present(&args.artifact_signing_tenant_id) || text_present(&args.artifact_signing_client_id) || text_present(&args.artifact_signing_client_secret) @@ -730,6 +744,31 @@ fn text_present(value: &Option) -> bool { value.as_deref().is_some_and(|s| !s.trim().is_empty()) } +fn effective_azure_key_vault_managed_identity(args: &SignArgs) -> bool { + args.azure_key_vault_managed_identity + || matches!( + args.azure_key_vault_credential_type, + Some(AzureCredentialType::ManagedIdentity) + ) +} + +fn effective_artifact_signing_managed_identity(args: &SignArgs) -> bool { + args.artifact_signing_managed_identity + || matches!( + args.artifact_signing_credential_type, + Some(AzureCredentialType::ManagedIdentity) + ) +} + +fn reject_workload_identity(name: &str, value: Option) -> Result<()> { + if matches!(value, Some(AzureCredentialType::WorkloadIdentity)) { + return Err(anyhow!( + "{name}=workload-identity is accepted by provider planning but is not wired for signing execution yet" + )); + } + Ok(()) +} + fn success_exit_code(args: &SignArgs) -> i32 { match args.exit_codes { Some(crate::cli::SignExitCodes::Azuresigntool) => 0, @@ -800,6 +839,10 @@ fn reject_artifact_signing_options(args: &SignArgs) -> Result<()> { "--artifact-signing-managed-identity", args.artifact_signing_managed_identity, )?; + reject_option( + "--artifact-signing-credential-type", + args.artifact_signing_credential_type.is_some(), + )?; reject_string_option( "--artifact-signing-tenant-id", &args.artifact_signing_tenant_id, diff --git a/src/signing_provider.rs b/src/signing_provider.rs new file mode 100644 index 0000000..dc515d5 --- /dev/null +++ b/src/signing_provider.rs @@ -0,0 +1,474 @@ +use crate::cli::{AzureCredentialType, DigestAlgorithm, SignArgs}; +use anyhow::{Result, anyhow}; +use serde::Serialize; +use std::path::PathBuf; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SigningProviderKind { + PortableStore, + Pfx, + WindowsStore, + AzureKeyVault, + ArtifactSigning, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct SigningProviderConfig { + pub kind: SigningProviderKind, + pub digest_algorithm: String, + pub timestamp_url: Option, + pub timestamp_digest_algorithm: Option, + pub identity: SigningProviderIdentity, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum SigningProviderIdentity { + PortableStore { + thumbprint_sha1: String, + machine_store: bool, + store_name: String, + cert_store_dir: Option, + }, + Pfx { + pfx: PathBuf, + }, + WindowsStore { + auto_select: bool, + subject_name: Option, + issuer_name: Option, + thumbprint_sha1: Option, + machine_store: bool, + store_name: String, + }, + AzureKeyVault { + vault_url: String, + certificate: String, + certificate_version: Option, + auth: AzureAuthConfig, + }, + ArtifactSigning { + metadata: Option, + endpoint: Option, + region: Option, + account_name: Option, + profile_name: Option, + auth: AzureAuthConfig, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(tag = "mode", rename_all = "kebab-case")] +pub enum AzureAuthConfig { + AccessToken { + authority: Option, + }, + ClientSecret { + tenant_id: Option, + client_id: Option, + authority: Option, + }, + ManagedIdentity { + client_id: Option, + authority: Option, + }, + WorkloadIdentity { + tenant_id: Option, + client_id: Option, + authority: Option, + }, + Ambient { + authority: Option, + }, +} + +pub trait SigningProvider { + fn kind(&self) -> SigningProviderKind; + fn certificate_chain_der(&self) -> Result>>; + fn sign_digest(&self, digest_algorithm: DigestAlgorithm, digest: &[u8]) -> Result>; +} + +impl SigningProviderConfig { + pub fn from_sign_args(args: &SignArgs) -> Result { + let azure_key_vault = azure_key_vault_requested(args); + let artifact_signing = artifact_signing_requested(args); + if azure_key_vault && artifact_signing { + return Err(anyhow!( + "signing provider options are ambiguous: choose either Azure Key Vault or Artifact Signing" + )); + } + + let timestamp_url = args + .timestamp_url + .clone() + .or_else(|| args.seal_timestamp_url.clone()) + .or_else(|| args.legacy_timestamp_url.clone()); + let timestamp_digest_algorithm = args.timestamp_digest.map(digest_name).map(str::to_owned); + + if azure_key_vault { + require_remote_digest(args.digest, "Azure Key Vault")?; + let vault_url = + required_text("--azure-key-vault-url", args.azure_key_vault_url.as_deref())?; + let certificate = required_text( + "--azure-key-vault-certificate", + args.azure_key_vault_certificate.as_deref(), + )?; + return Ok(Self { + kind: SigningProviderKind::AzureKeyVault, + digest_algorithm: digest_name(args.digest).to_owned(), + timestamp_url, + timestamp_digest_algorithm, + identity: SigningProviderIdentity::AzureKeyVault { + vault_url, + certificate, + certificate_version: trim_opt(&args.azure_key_vault_certificate_version), + auth: azure_auth( + args.azure_key_vault_credential_type, + args.azure_key_vault_access_token.as_deref(), + args.azure_key_vault_managed_identity, + args.azure_key_vault_tenant_id.as_deref(), + args.azure_key_vault_client_id.as_deref(), + args.azure_key_vault_client_secret.as_deref(), + args.azure_authority.as_deref(), + ), + }, + }); + } + + if artifact_signing { + require_remote_digest(args.digest, "Artifact Signing")?; + return Ok(Self { + kind: SigningProviderKind::ArtifactSigning, + digest_algorithm: digest_name(args.digest).to_owned(), + timestamp_url, + timestamp_digest_algorithm, + identity: SigningProviderIdentity::ArtifactSigning { + metadata: args + .artifact_signing_metadata + .clone() + .or_else(|| args.dmdf.clone()), + endpoint: trim_opt(&args.artifact_signing_endpoint), + region: trim_opt(&args.artifact_signing_region), + account_name: trim_opt(&args.artifact_signing_account_name), + profile_name: trim_opt(&args.artifact_signing_profile_name), + auth: azure_auth( + args.artifact_signing_credential_type, + args.artifact_signing_access_token.as_deref(), + args.artifact_signing_managed_identity, + args.artifact_signing_tenant_id.as_deref(), + args.artifact_signing_client_id.as_deref(), + args.artifact_signing_client_secret.as_deref(), + args.artifact_signing_authority.as_deref(), + ), + }, + }); + } + + if let Some(pfx) = &args.pfx { + if args.cert_sha1.is_some() + || args.auto_select + || text_present(&args.subject_name) + || text_present(&args.issuer_name) + { + return Err(anyhow!( + "signing provider options are ambiguous: do not combine --pfx with certificate store selection options" + )); + } + return Ok(Self { + kind: SigningProviderKind::Pfx, + digest_algorithm: digest_name(args.digest).to_owned(), + timestamp_url, + timestamp_digest_algorithm, + identity: SigningProviderIdentity::Pfx { pfx: pfx.clone() }, + }); + } + + if let Some(thumbprint_sha1) = trim_opt(&args.cert_sha1) { + return Ok(Self { + kind: SigningProviderKind::PortableStore, + digest_algorithm: digest_name(args.digest).to_owned(), + timestamp_url, + timestamp_digest_algorithm, + identity: SigningProviderIdentity::PortableStore { + thumbprint_sha1, + machine_store: args.machine_store, + store_name: args.store_name.clone(), + cert_store_dir: args.cert_store_dir.clone(), + }, + }); + } + + if args.auto_select || text_present(&args.subject_name) || text_present(&args.issuer_name) { + return Ok(Self { + kind: SigningProviderKind::WindowsStore, + digest_algorithm: digest_name(args.digest).to_owned(), + timestamp_url, + timestamp_digest_algorithm, + identity: SigningProviderIdentity::WindowsStore { + auto_select: args.auto_select, + subject_name: trim_opt(&args.subject_name), + issuer_name: trim_opt(&args.issuer_name), + thumbprint_sha1: trim_opt(&args.cert_sha1), + machine_store: args.machine_store, + store_name: args.store_name.clone(), + }, + }); + } + + Err(anyhow!( + "no signing provider selected; specify --sha1, --pfx, Windows store selection, Azure Key Vault, or Artifact Signing options" + )) + } +} + +fn azure_key_vault_requested(args: &SignArgs) -> bool { + text_present(&args.azure_key_vault_url) + || text_present(&args.azure_key_vault_certificate) + || text_present(&args.azure_key_vault_certificate_version) + || text_present(&args.azure_key_vault_client_id) + || text_present(&args.azure_key_vault_client_secret) + || text_present(&args.azure_key_vault_tenant_id) + || text_present(&args.azure_key_vault_access_token) + || args.azure_key_vault_managed_identity + || args.azure_key_vault_credential_type.is_some() + || text_present(&args.azure_authority) +} + +fn artifact_signing_requested(args: &SignArgs) -> bool { + args.artifact_signing_metadata.is_some() + || args.dmdf.is_some() + || args.trusted_signing_dlib_root.is_some() + || text_present(&args.artifact_signing_region) + || text_present(&args.artifact_signing_endpoint) + || text_present(&args.artifact_signing_account_name) + || text_present(&args.artifact_signing_profile_name) + || text_present(&args.artifact_signing_signature_algorithm) + || text_present(&args.artifact_signing_api_version) + || text_present(&args.artifact_signing_correlation_id) + || text_present(&args.artifact_signing_access_token) + || args.artifact_signing_managed_identity + || args.artifact_signing_credential_type.is_some() + || text_present(&args.artifact_signing_tenant_id) + || text_present(&args.artifact_signing_client_id) + || text_present(&args.artifact_signing_client_secret) + || text_present(&args.artifact_signing_authority) + || text_present(&args.artifact_signing_endpoint_base_url) +} + +fn azure_auth( + credential_type: Option, + access_token: Option<&str>, + managed_identity: bool, + tenant_id: Option<&str>, + client_id: Option<&str>, + client_secret: Option<&str>, + authority: Option<&str>, +) -> AzureAuthConfig { + let authority = trim_str(authority).map(str::to_owned); + match credential_type.unwrap_or(AzureCredentialType::Default) { + AzureCredentialType::AccessToken => AzureAuthConfig::AccessToken { authority }, + AzureCredentialType::ManagedIdentity => AzureAuthConfig::ManagedIdentity { + client_id: trim_str(client_id).map(str::to_owned), + authority, + }, + AzureCredentialType::ClientSecret => AzureAuthConfig::ClientSecret { + tenant_id: trim_str(tenant_id).map(str::to_owned), + client_id: trim_str(client_id).map(str::to_owned), + authority, + }, + AzureCredentialType::WorkloadIdentity => AzureAuthConfig::WorkloadIdentity { + tenant_id: trim_str(tenant_id).map(str::to_owned), + client_id: trim_str(client_id).map(str::to_owned), + authority, + }, + AzureCredentialType::Default => { + if trim_str(access_token).is_some() { + AzureAuthConfig::AccessToken { authority } + } else if managed_identity { + AzureAuthConfig::ManagedIdentity { + client_id: trim_str(client_id).map(str::to_owned), + authority, + } + } else if trim_str(client_secret).is_some() { + AzureAuthConfig::ClientSecret { + tenant_id: trim_str(tenant_id).map(str::to_owned), + client_id: trim_str(client_id).map(str::to_owned), + authority, + } + } else { + AzureAuthConfig::Ambient { authority } + } + } + } +} + +fn required_text(name: &str, value: Option<&str>) -> Result { + trim_str(value) + .map(str::to_owned) + .ok_or_else(|| anyhow!("{name} is required for the selected signing provider")) +} + +fn require_remote_digest(digest: DigestAlgorithm, provider: &str) -> Result<()> { + match digest { + DigestAlgorithm::Sha256 | DigestAlgorithm::Sha384 | DigestAlgorithm::Sha512 => Ok(()), + DigestAlgorithm::Sha1 | DigestAlgorithm::CertHash => Err(anyhow!( + "{provider} supports only SHA256, SHA384, or SHA512 file digests" + )), + } +} + +fn digest_name(digest: DigestAlgorithm) -> &'static str { + match digest { + DigestAlgorithm::Sha1 => "sha1", + DigestAlgorithm::Sha256 => "sha256", + DigestAlgorithm::Sha384 => "sha384", + DigestAlgorithm::Sha512 => "sha512", + DigestAlgorithm::CertHash => "cert-hash", + } +} + +fn text_present(value: &Option) -> bool { + trim_opt(value).is_some() +} + +fn trim_opt(value: &Option) -> Option { + trim_str(value.as_deref()).map(str::to_owned) +} + +fn trim_str(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|s| !s.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::{Cli, Command}; + use clap::Parser; + + #[test] + fn selects_portable_store_from_sha1() { + let args = sign_args(["psign-tool", "sign", "--sha1", "ABC", "file.exe"]); + let provider = SigningProviderConfig::from_sign_args(args).unwrap(); + assert_eq!(provider.kind, SigningProviderKind::PortableStore); + } + + #[test] + fn rejects_mixed_cloud_provider_options() { + let args = sign_args([ + "psign-tool", + "sign", + "--azure-key-vault-url", + "https://vault.example", + "--azure-key-vault-certificate", + "cert", + "--artifact-signing-profile-name", + "profile", + "file.exe", + ]); + let err = SigningProviderConfig::from_sign_args(args).unwrap_err(); + assert!(err.to_string().contains("ambiguous")); + } + + #[test] + fn artifact_signing_uses_client_secret_auth_shape() { + let args = sign_args([ + "psign-tool", + "sign", + "--artifact-signing-endpoint", + "https://westus.codesigning.azure.net", + "--artifact-signing-account-name", + "acct", + "--artifact-signing-profile-name", + "profile", + "--artifact-signing-tenant-id", + "tenant", + "--artifact-signing-client-id", + "client", + "--artifact-signing-client-secret", + "secret", + "file.exe", + ]); + let provider = SigningProviderConfig::from_sign_args(args).unwrap(); + assert_eq!(provider.kind, SigningProviderKind::ArtifactSigning); + match provider.identity { + SigningProviderIdentity::ArtifactSigning { + auth: AzureAuthConfig::ClientSecret { client_id, .. }, + .. + } => assert_eq!(client_id.as_deref(), Some("client")), + other => panic!("unexpected identity: {other:?}"), + } + } + + #[test] + fn azure_credential_type_managed_identity_maps_to_managed_identity_auth() { + let args = sign_args([ + "psign-tool", + "sign", + "--azure-key-vault-url", + "https://vault.example", + "--azure-key-vault-certificate", + "cert", + "--azure-key-vault-credential-type", + "managed-identity", + "--azure-key-vault-client-id", + "user-assigned-client", + "file.exe", + ]); + let provider = SigningProviderConfig::from_sign_args(args).unwrap(); + + match provider.identity { + SigningProviderIdentity::AzureKeyVault { + auth: AzureAuthConfig::ManagedIdentity { client_id, .. }, + .. + } => assert_eq!(client_id.as_deref(), Some("user-assigned-client")), + other => panic!("unexpected identity: {other:?}"), + } + } + + #[test] + fn azure_credential_type_workload_identity_is_represented_for_planning() { + let args = sign_args([ + "psign-tool", + "sign", + "--artifact-signing-endpoint", + "https://westus.codesigning.azure.net", + "--artifact-signing-account-name", + "acct", + "--artifact-signing-profile-name", + "profile", + "--artifact-signing-credential-type", + "workload-identity", + "--artifact-signing-tenant-id", + "tenant", + "--artifact-signing-client-id", + "client", + "file.exe", + ]); + let provider = SigningProviderConfig::from_sign_args(args).unwrap(); + + match provider.identity { + SigningProviderIdentity::ArtifactSigning { + auth: + AzureAuthConfig::WorkloadIdentity { + tenant_id, + client_id, + .. + }, + .. + } => { + assert_eq!(tenant_id.as_deref(), Some("tenant")); + assert_eq!(client_id.as_deref(), Some("client")); + } + other => panic!("unexpected identity: {other:?}"), + } + } + + fn sign_args(argv: [&str; N]) -> &'static crate::cli::SignArgs { + let cli = Cli::try_parse_from(argv).unwrap(); + let Command::Sign(args) = cli.command else { + panic!("expected sign args"); + }; + Box::leak(Box::new(args)) + } +} diff --git a/tests/cli_pe_digest.rs b/tests/cli_pe_digest.rs index 53c2d44..1c4e425 100644 --- a/tests/cli_pe_digest.rs +++ b/tests/cli_pe_digest.rs @@ -6,6 +6,7 @@ use base64::Engine as _; use predicates::prelude::*; use psign_authenticode_trust::pe_first_pkcs7_terminal_root; use psign_authenticode_trust::{inspect_authenticode_pkcs7_der, inspect_pe_authenticode}; +use psign_opc_sign::nuget; use psign_sip_digest::cab_digest; use psign_sip_digest::catalog_digest; use psign_sip_digest::msi_digest; @@ -21,6 +22,7 @@ use rsa::signature::SignatureEncoding; use rsa::signature::hazmat::PrehashSigner; use serde_json::Value; use sha2::{Digest as _, Sha256}; +use std::io::{Read as _, Write as _}; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::Duration; @@ -71,6 +73,8 @@ fn help_lists_core_subcommands() { "msi-signer-rs256-prehash", "verify-esd", "verify-msix", + "msix-manifest-info", + "msix-set-publisher", "verify-catalog", "verify-catalog-member", "catalog-signer-rs256-prehash", @@ -96,7 +100,41 @@ fn help_lists_core_subcommands() { "rdp", "nupkg-signature-info", "nupkg-digest", + "nupkg-signature-content", + "nupkg-signature-pkcs7", + "nupkg-signature-pkcs7-prehash", + "nupkg-signature-pkcs7-from-signature", + "nupkg-sign", + "nupkg-verify-signature-content", + "nupkg-verify-signature", + "nupkg-embed-signature", "vsix-signature-info", + "vsix-embed-signature-xml", + "vsix-signature-reference-xml", + "vsix-signature-xml", + "vsix-signature-xml-prehash", + "vsix-signature-xml-from-signature", + "vsix-sign", + "vsix-verify-signature-reference-xml", + "vsix-verify-signature-xml", + "vsix-verify-signature", + "appinstaller-info", + "appinstaller-verify-companion", + "appinstaller-sign-companion", + "appinstaller-sign-companion-prehash", + "appinstaller-sign-companion-from-signature", + "appinstaller-set-publisher", + "business-central-app-info", + "msix-manifest-info", + "msix-set-publisher", + "clickonce-deploy-info", + "clickonce-copy-deploy-payload", + "clickonce-manifest-hashes", + "clickonce-update-manifest-hashes", + "clickonce-sign-manifest", + "clickonce-sign-manifest-prehash", + "clickonce-sign-manifest-from-signature", + "clickonce-verify-manifest-signature", "rfc3161-timestamp-req", "rfc3161-timestamp-resp-inspect", ] { @@ -179,24 +217,1403 @@ fn nupkg_digest_rejects_signed_package_fixture() { .stderr(predicate::str::contains("already contains .signature.p7s")); } +#[test] +fn nupkg_signature_content_records_unsigned_package_hash() { + let package = package_fixture("unsigned/sample.nupkg"); + let expected_unsigned = + nuget::canonical_unsigned_package_bytes_path(&package).expect("canonical unsigned package"); + let expected_hash = hex_lower(&Sha256::digest(expected_unsigned)); + + let mut cmd = portable_cmd(); + cmd.arg("nupkg-signature-content") + .arg(&package) + .arg("--algorithm") + .arg("sha256"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Version:1")) + .stdout(predicate::str::contains("2.16.840.1.101.3.4.2.1-Hash:")); + + let dir = tempfile::tempdir().unwrap(); + let content = dir.path().join("signature-content.txt"); + let mut write = portable_cmd(); + write + .arg("nupkg-signature-content") + .arg(&package) + .arg("--output") + .arg(&content); + write.assert().success(); + + let mut verify = portable_cmd(); + verify + .arg("nupkg-verify-signature-content") + .arg(&package) + .arg("--content") + .arg(&content); + verify + .assert() + .success() + .stdout(predicate::str::contains("package_hash_algorithm=sha256")) + .stdout(predicate::str::contains(format!( + "package_hash={expected_hash}" + ))) + .stdout(predicate::str::contains("package_hash_match=yes")); +} + +#[test] +fn nupkg_signature_pkcs7_creates_verifiable_signature_blob() { + let package = package_fixture("unsigned/sample.nupkg"); + let dir = tempfile::tempdir().unwrap(); + let cert = dir.path().join("signer.der"); + let key = dir.path().join("signer.pkcs8"); + let content = dir.path().join("signature-content.txt"); + let signature = dir.path().join("signature.p7s"); + let signed = dir.path().join("signed.nupkg"); + write_test_rsa_cert_key(&cert, &key); + + let mut content_cmd = portable_cmd(); + content_cmd + .arg("nupkg-signature-content") + .arg(&package) + .arg("--output") + .arg(&content); + content_cmd.assert().success(); + + let mut sign = portable_cmd(); + sign.arg("nupkg-signature-pkcs7") + .arg(&package) + .arg("--cert") + .arg(&cert) + .arg("--key") + .arg(&key) + .arg("--output") + .arg(&signature); + sign.assert() + .success() + .stdout(predicate::str::contains("signature_len=")); + + let mut verify = portable_cmd(); + verify + .arg("trust-verify-detached") + .arg(&content) + .arg(&signature) + .arg("--trusted-ca") + .arg(&cert) + .arg("--allow-loose-signing-cert"); + verify + .assert() + .success() + .stdout(predicate::str::contains("trust-verify-detached: ok")); + + let mut embed = portable_cmd(); + embed + .arg("nupkg-embed-signature") + .arg(&package) + .arg("--signature") + .arg(&signature) + .arg("--output") + .arg(&signed); + embed.assert().success(); + + let mut info = portable_cmd(); + info.arg("nupkg-signature-info").arg(&signed); + info.assert() + .success() + .stdout(predicate::str::contains("signed=yes")) + .stdout(predicate::str::contains("signature_stored=yes")); +} + +#[test] +fn nupkg_signature_pkcs7_from_external_signature_creates_verifiable_blob() { + let package = package_fixture("unsigned/sample.nupkg"); + let dir = tempfile::tempdir().unwrap(); + let cert = dir.path().join("signer.der"); + let key = dir.path().join("signer.pkcs8"); + let content = dir.path().join("signature-content.txt"); + let prehash = dir.path().join("prehash.bin"); + let external_signature = dir.path().join("external.sig"); + let signature = dir.path().join("signature.p7s"); + let signed = dir.path().join("signed.nupkg"); + write_test_rsa_cert_key(&cert, &key); + + let mut content_cmd = portable_cmd(); + content_cmd + .arg("nupkg-signature-content") + .arg(&package) + .arg("--output") + .arg(&content); + content_cmd.assert().success(); + + let mut prehash_cmd = portable_cmd(); + prehash_cmd + .arg("nupkg-signature-pkcs7-prehash") + .arg(&package) + .arg("--encoding") + .arg("raw") + .arg("--output") + .arg(&prehash); + prehash_cmd.assert().success(); + + let key_bytes = std::fs::read(&key).expect("read test key"); + let private_key = rdp::parse_rsa_private_key(&key_bytes).expect("parse test key"); + let signing_key = SigningKey::::new(private_key); + let signed_attrs_digest = std::fs::read(&prehash).expect("read prehash"); + let raw_signature = signing_key + .sign_prehash(&signed_attrs_digest) + .expect("external RSA signature") + .to_bytes(); + std::fs::write(&external_signature, raw_signature).expect("write external signature"); + + let mut assemble = portable_cmd(); + assemble + .arg("nupkg-signature-pkcs7-from-signature") + .arg(&package) + .arg("--cert") + .arg(&cert) + .arg("--signature") + .arg(&external_signature) + .arg("--output") + .arg(&signature); + assemble + .assert() + .success() + .stdout(predicate::str::contains("signature_len=")); + + let mut verify = portable_cmd(); + verify + .arg("trust-verify-detached") + .arg(&content) + .arg(&signature) + .arg("--trusted-ca") + .arg(&cert) + .arg("--allow-loose-signing-cert"); + verify + .assert() + .success() + .stdout(predicate::str::contains("trust-verify-detached: ok")); + + let mut embed = portable_cmd(); + embed + .arg("nupkg-embed-signature") + .arg(&package) + .arg("--signature") + .arg(&signature) + .arg("--output") + .arg(&signed); + embed.assert().success(); + + let mut nupkg_verify = portable_cmd(); + nupkg_verify + .arg("nupkg-verify-signature") + .arg(&signed) + .arg("--trusted-ca") + .arg(&cert) + .arg("--allow-loose-signing-cert"); + nupkg_verify + .assert() + .success() + .stdout(predicate::str::contains("nupkg-verify-signature: ok")); +} + +#[test] +fn nupkg_sign_creates_signed_package_with_verifiable_embedded_signature() { + let package = package_fixture("unsigned/sample.nupkg"); + let dir = tempfile::tempdir().unwrap(); + let cert = dir.path().join("signer.der"); + let key = dir.path().join("signer.pkcs8"); + let content = dir.path().join("signature-content.txt"); + let signed = dir.path().join("signed.nupkg"); + let extracted = dir.path().join("signature.p7s"); + write_test_rsa_cert_key(&cert, &key); + + let mut content_cmd = portable_cmd(); + content_cmd + .arg("nupkg-signature-content") + .arg(&package) + .arg("--output") + .arg(&content); + content_cmd.assert().success(); + + let mut sign = portable_cmd(); + sign.arg("nupkg-sign") + .arg(&package) + .arg("--cert") + .arg(&cert) + .arg("--key") + .arg(&key) + .arg("--output") + .arg(&signed); + sign.assert().success().stdout(predicate::str::contains( + "embedded_signature=.signature.p7s", + )); + + let mut archive = zip::ZipArchive::new(std::fs::File::open(&signed).unwrap()).unwrap(); + let mut signature = Vec::new(); + archive + .by_name(".signature.p7s") + .unwrap() + .read_to_end(&mut signature) + .unwrap(); + std::fs::write(&extracted, signature).unwrap(); + + let mut verify = portable_cmd(); + verify + .arg("trust-verify-detached") + .arg(&content) + .arg(&extracted) + .arg("--trusted-ca") + .arg(&cert) + .arg("--allow-loose-signing-cert"); + verify + .assert() + .success() + .stdout(predicate::str::contains("trust-verify-detached: ok")); +} + +#[cfg(all(feature = "timestamp-server", feature = "timestamp-http"))] +#[test] +fn nupkg_sign_embeds_rfc3161_timestamp_attribute() { + let package = package_fixture("unsigned/sample.nupkg"); + let dir = tempfile::tempdir().unwrap(); + let cert = dir.path().join("signer.der"); + let key = dir.path().join("signer.pkcs8"); + let signed = dir.path().join("signed-timestamped.nupkg"); + let extracted = dir.path().join("signature.p7s"); + let tsa_root = dir.path().join("tsa-root.der"); + write_test_rsa_cert_key(&cert, &key); + let tsa_root_arg = tsa_root.to_str().unwrap(); + let gen_time = generalized_time_tomorrow_noon_utc(); + let (mut guard, timestamp_url) = + spawn_psign_server_with_gen_time(&gen_time, &["--cert-output", tsa_root_arg]); + + let mut sign = portable_cmd(); + sign.arg("nupkg-sign") + .arg(&package) + .arg("--cert") + .arg(&cert) + .arg("--key") + .arg(&key) + .arg("--timestamp-url") + .arg(×tamp_url) + .arg("--timestamp-digest") + .arg("sha256") + .arg("--output") + .arg(&signed); + sign.assert().success(); + let status = guard.0.wait().expect("timestamp server exit"); + assert!(status.success(), "timestamp server failed with {status}"); + + let mut archive = zip::ZipArchive::new(std::fs::File::open(&signed).unwrap()).unwrap(); + let mut signature = Vec::new(); + archive + .by_name(".signature.p7s") + .unwrap() + .read_to_end(&mut signature) + .unwrap(); + std::fs::write(&extracted, signature).unwrap(); + + let mut inspect = portable_cmd(); + inspect + .arg("inspect-authenticode") + .arg(&extracted) + .arg("--input") + .arg("pkcs7"); + inspect + .assert() + .success() + .stdout(predicate::str::contains( + "microsoft_nested_rfc3161_attribute", + )) + .stdout(predicate::str::contains("1.3.6.1.4.1.311.3.3.1")); + + let mut verify = portable_cmd(); + verify + .arg("nupkg-verify-signature") + .arg(&signed) + .arg("--trusted-ca") + .arg(&cert) + .arg("--trusted-ca") + .arg(&tsa_root) + .arg("--allow-loose-signing-cert") + .arg("--prefer-timestamp-signing-time") + .arg("--require-valid-timestamp"); + verify + .assert() + .success() + .stdout(predicate::str::contains("nupkg-verify-signature: ok")); +} + +#[test] +fn nupkg_verify_signature_validates_embedded_signature_and_package_hash() { + let package = package_fixture("unsigned/sample.nupkg"); + let dir = tempfile::tempdir().unwrap(); + let cert = dir.path().join("signer.der"); + let key = dir.path().join("signer.pkcs8"); + let signed = dir.path().join("signed.nupkg"); + write_test_rsa_cert_key(&cert, &key); + + let mut sign = portable_cmd(); + sign.arg("nupkg-sign") + .arg(&package) + .arg("--cert") + .arg(&cert) + .arg("--key") + .arg(&key) + .arg("--output") + .arg(&signed); + sign.assert().success(); + + let mut verify = portable_cmd(); + verify + .arg("nupkg-verify-signature") + .arg(&signed) + .arg("--trusted-ca") + .arg(&cert) + .arg("--allow-loose-signing-cert"); + verify + .assert() + .success() + .stdout(predicate::str::contains("nupkg-verify-signature: ok")) + .stdout(predicate::str::contains("signature_present=yes")) + .stdout(predicate::str::contains("package_hash_algorithm=sha256")) + .stdout(predicate::str::contains("package_hash_match=yes")) + .stdout(predicate::str::contains("signature_len=")); +} + +#[test] +fn nupkg_verify_signature_rejects_tampered_signed_package() { + let package = package_fixture("unsigned/sample.nupkg"); + let dir = tempfile::tempdir().unwrap(); + let cert = dir.path().join("signer.der"); + let key = dir.path().join("signer.pkcs8"); + let signed = dir.path().join("signed.nupkg"); + let tampered = dir.path().join("tampered.nupkg"); + write_test_rsa_cert_key(&cert, &key); + + let mut sign = portable_cmd(); + sign.arg("nupkg-sign") + .arg(&package) + .arg("--cert") + .arg(&cert) + .arg("--key") + .arg(&key) + .arg("--output") + .arg(&signed); + sign.assert().success(); + append_zip_entry(&signed, &tampered, "tampered.txt", b"changed"); + + let mut verify = portable_cmd(); + verify + .arg("nupkg-verify-signature") + .arg(&tampered) + .arg("--trusted-ca") + .arg(&cert) + .arg("--allow-loose-signing-cert"); + verify.assert().failure().stderr(predicate::str::contains( + "detached content digest does not match", + )); +} + +#[test] +fn nupkg_verify_signature_requires_embedded_signature() { + let package = package_fixture("unsigned/sample.nupkg"); + let dir = tempfile::tempdir().unwrap(); + let cert = dir.path().join("signer.der"); + let key = dir.path().join("signer.pkcs8"); + write_test_rsa_cert_key(&cert, &key); + + let mut verify = portable_cmd(); + verify + .arg("nupkg-verify-signature") + .arg(&package) + .arg("--trusted-ca") + .arg(&cert) + .arg("--allow-loose-signing-cert"); + verify.assert().failure().stderr(predicate::str::contains( + "package does not contain .signature.p7s", + )); +} + +#[test] +fn nupkg_verify_signature_content_rejects_tampered_package() { + let dir = tempfile::tempdir().unwrap(); + let original = package_fixture("unsigned/sample.nupkg"); + let content = dir.path().join("signature-content.txt"); + let tampered = package_fixture("unsigned/with-pe.nupkg"); + + let mut write = portable_cmd(); + write + .arg("nupkg-signature-content") + .arg(&original) + .arg("--output") + .arg(&content); + write.assert().success(); + + let mut verify = portable_cmd(); + verify + .arg("nupkg-verify-signature-content") + .arg(&tampered) + .arg("--content") + .arg(&content); + verify + .assert() + .failure() + .stderr(predicate::str::contains("hash mismatch")); +} + +#[test] +fn nupkg_embed_signature_creates_stored_root_signature_marker() { + let dir = tempfile::tempdir().unwrap(); + let input = package_fixture("unsigned/sample.nupkg"); + let sig = dir.path().join("signature.p7s"); + let output = dir.path().join("signed.nupkg"); + std::fs::write(&sig, b"cms-placeholder").unwrap(); + + let mut cmd = portable_cmd(); + cmd.arg("nupkg-embed-signature") + .arg(&input) + .arg("--signature") + .arg(&sig) + .arg("--output") + .arg(&output); + cmd.assert() + .success() + .stdout(predicate::str::contains( + "embedded_signature=.signature.p7s", + )) + .stdout(predicate::str::contains("signature_len=15")); + + let mut info = portable_cmd(); + info.arg("nupkg-signature-info").arg(&output); + info.assert() + .success() + .stdout(predicate::str::contains("signed=yes")) + .stdout(predicate::str::contains("signature_stored=yes")) + .stdout(predicate::str::contains("signature_len=15")); +} + +#[test] +fn nupkg_embed_signature_rejects_existing_signature_without_overwrite() { + let dir = tempfile::tempdir().unwrap(); + let sig = dir.path().join("signature.p7s"); + let output = dir.path().join("signed.nupkg"); + std::fs::write(&sig, b"cms-placeholder").unwrap(); + + let mut cmd = portable_cmd(); + cmd.arg("nupkg-embed-signature") + .arg(package_fixture("signed/sample.signed.nupkg")) + .arg("--signature") + .arg(&sig) + .arg("--output") + .arg(&output); + cmd.assert() + .failure() + .stderr(predicate::str::contains("already contains .signature.p7s")); +} + #[test] fn vsix_signature_info_detects_opc_signature_parts() { let package = package_fixture("signed/sample.signed.vsix"); let mut cmd = portable_cmd(); - cmd.arg("vsix-signature-info").arg(&package); + cmd.arg("vsix-signature-info").arg(&package); + cmd.assert() + .success() + .stdout(predicate::str::contains("opc_signature=yes")) + .stdout(predicate::str::contains( + "signature_origin=package/services/digital-signature/origin.psdsor", + )) + .stdout(predicate::str::contains("signature_parts=1")) + .stdout(predicate::str::contains( + "signature_part=package/services/digital-signature/xml-signature/", + )); +} + +#[test] +fn vsix_embed_signature_xml_creates_opc_signature_markers() { + let dir = tempfile::tempdir().unwrap(); + let input = package_fixture("unsigned/sample.vsix"); + let xml = dir.path().join("signature.xml"); + let output = dir.path().join("signed.vsix"); + std::fs::write(&xml, b"").unwrap(); + + let mut cmd = portable_cmd(); + cmd.arg("vsix-embed-signature-xml") + .arg(&input) + .arg("--signature-xml") + .arg(&xml) + .arg("--output") + .arg(&output); + cmd.assert() + .success() + .stdout(predicate::str::contains("embedded_signature_xml=")) + .stdout(predicate::str::contains("signature_xml_len=12")); + + let mut info = portable_cmd(); + info.arg("vsix-signature-info").arg(&output); + info.assert() + .success() + .stdout(predicate::str::contains("opc_signature=yes")) + .stdout(predicate::str::contains( + "signature_origin=package/services/digital-signature/origin.psdsor", + )) + .stdout(predicate::str::contains("signature_parts=1")); +} + +#[test] +fn vsix_signature_reference_xml_verifies_package_part_digests() { + let dir = tempfile::tempdir().unwrap(); + let input = package_fixture("unsigned/sample.vsix"); + let xml = dir.path().join("signature-reference.xml"); + + let mut write = portable_cmd(); + write + .arg("vsix-signature-reference-xml") + .arg(&input) + .arg("--output") + .arg(&xml); + write.assert().success(); + + let text = std::fs::read_to_string(&xml).unwrap(); + assert!(text.contains("")); + + let mut verify = portable_cmd(); + verify + .arg("vsix-verify-signature-reference-xml") + .arg(&input) + .arg("--signature-xml") + .arg(&xml); + verify + .assert() + .success() + .stdout(predicate::str::contains( + "reference_digest_algorithm=sha256", + )) + .stdout(predicate::str::contains("reference_digest_match=yes")); +} + +#[test] +fn vsix_signature_xml_signs_verifies_and_embeds() { + let dir = tempfile::tempdir().unwrap(); + let input = package_fixture("unsigned/sample.vsix"); + let cert = dir.path().join("signer.der"); + let key = dir.path().join("signer.pkcs8"); + let xml = dir.path().join("signature.xml"); + let output = dir.path().join("signed.vsix"); + write_test_rsa_cert_key(&cert, &key); + + let mut sign = portable_cmd(); + sign.arg("vsix-signature-xml") + .arg(&input) + .arg("--cert") + .arg(&cert) + .arg("--key") + .arg(&key) + .arg("--output") + .arg(&xml); + sign.assert().success(); + + let text = std::fs::read_to_string(&xml).unwrap(); + assert!(text.contains("")); + assert!(!text.contains("")); + assert!(text.contains("")); + + let mut verify = portable_cmd(); + verify + .arg("vsix-verify-signature-xml") + .arg(&input) + .arg("--signature-xml") + .arg(&xml) + .arg("--cert") + .arg(&cert) + .arg("--trusted-ca") + .arg(&cert); + verify + .assert() + .success() + .stdout(predicate::str::contains("reference_digest_match=yes")) + .stdout(predicate::str::contains("signature_value_match=yes")) + .stdout(predicate::str::contains("signer_trust_chain=yes")); + + let mut embed = portable_cmd(); + embed + .arg("vsix-embed-signature-xml") + .arg(&input) + .arg("--signature-xml") + .arg(&xml) + .arg("--output") + .arg(&output); + embed.assert().success(); + + let mut info = portable_cmd(); + info.arg("vsix-signature-info").arg(&output); + info.assert() + .success() + .stdout(predicate::str::contains("opc_signature=yes")) + .stdout(predicate::str::contains("signature_parts=1")); +} + +#[test] +fn vsix_signature_xml_from_external_signature_signs_verifies_and_embeds() { + let dir = tempfile::tempdir().unwrap(); + let input = package_fixture("unsigned/sample.vsix"); + let cert = dir.path().join("signer.der"); + let key = dir.path().join("signer.pkcs8"); + let prehash = dir.path().join("prehash.bin"); + let external_signature = dir.path().join("external.sig"); + let xml = dir.path().join("signature.xml"); + let output = dir.path().join("signed.vsix"); + write_test_rsa_cert_key(&cert, &key); + + let mut prehash_cmd = portable_cmd(); + prehash_cmd + .arg("vsix-signature-xml-prehash") + .arg(&input) + .arg("--encoding") + .arg("raw") + .arg("--output") + .arg(&prehash); + prehash_cmd.assert().success(); + + let key_bytes = std::fs::read(&key).expect("read test key"); + let private_key = rdp::parse_rsa_private_key(&key_bytes).expect("parse test key"); + let signing_key = SigningKey::::new(private_key); + let signed_info_digest = std::fs::read(&prehash).expect("read prehash"); + let raw_signature = signing_key + .sign_prehash(&signed_info_digest) + .expect("external RSA signature") + .to_bytes(); + std::fs::write(&external_signature, raw_signature).expect("write external signature"); + + let mut assemble = portable_cmd(); + assemble + .arg("vsix-signature-xml-from-signature") + .arg(&input) + .arg("--cert") + .arg(&cert) + .arg("--signature") + .arg(&external_signature) + .arg("--output") + .arg(&xml); + assemble.assert().success(); + + let mut verify = portable_cmd(); + verify + .arg("vsix-verify-signature-xml") + .arg(&input) + .arg("--signature-xml") + .arg(&xml) + .arg("--cert") + .arg(&cert) + .arg("--trusted-ca") + .arg(&cert); + verify + .assert() + .success() + .stdout(predicate::str::contains("reference_digest_match=yes")) + .stdout(predicate::str::contains("signature_value_match=yes")) + .stdout(predicate::str::contains("signer_trust_chain=yes")); + + let mut embed = portable_cmd(); + embed + .arg("vsix-embed-signature-xml") + .arg(&input) + .arg("--signature-xml") + .arg(&xml) + .arg("--output") + .arg(&output); + embed.assert().success(); + + let mut embedded_verify = portable_cmd(); + embedded_verify + .arg("vsix-verify-signature") + .arg(&output) + .arg("--trusted-ca") + .arg(&cert); + embedded_verify + .assert() + .success() + .stdout(predicate::str::contains("vsix-verify-signature: ok")) + .stdout(predicate::str::contains("signer_trust_chain=yes")); +} + +#[test] +fn vsix_sign_creates_verifiable_signed_package() { + let dir = tempfile::tempdir().unwrap(); + let input = package_fixture("unsigned/sample.vsix"); + let cert = dir.path().join("signer.der"); + let key = dir.path().join("signer.pkcs8"); + let output = dir.path().join("signed.vsix"); + let extracted_xml = dir.path().join("signature.xml"); + write_test_rsa_cert_key(&cert, &key); + + let mut sign = portable_cmd(); + sign.arg("vsix-sign") + .arg(&input) + .arg("--cert") + .arg(&cert) + .arg("--key") + .arg(&key) + .arg("--output") + .arg(&output); + sign.assert() + .success() + .stdout(predicate::str::contains("signature_xml_part=")); + + let mut archive = zip::ZipArchive::new(std::fs::File::open(&output).unwrap()).unwrap(); + let mut xml = Vec::new(); + archive + .by_name("package/services/digital-signature/xml-signature/psign-signature.psdsxs") + .unwrap() + .read_to_end(&mut xml) + .unwrap(); + std::fs::write(&extracted_xml, xml).unwrap(); + + let mut verify = portable_cmd(); + verify + .arg("vsix-verify-signature-xml") + .arg(&input) + .arg("--signature-xml") + .arg(&extracted_xml) + .arg("--cert") + .arg(&cert); + verify + .assert() + .success() + .stdout(predicate::str::contains("signature_value_match=yes")); + + let mut info = portable_cmd(); + info.arg("vsix-signature-info").arg(&output); + info.assert() + .success() + .stdout(predicate::str::contains("opc_signature=yes")); + + let mut embedded_verify = portable_cmd(); + embedded_verify + .arg("vsix-verify-signature") + .arg(&output) + .arg("--trusted-ca") + .arg(&cert); + embedded_verify + .assert() + .success() + .stdout(predicate::str::contains("vsix-verify-signature: ok")) + .stdout(predicate::str::contains("signature_xml_present=yes")) + .stdout(predicate::str::contains("reference_digest_match=yes")) + .stdout(predicate::str::contains("signature_value_match=yes")) + .stdout(predicate::str::contains("signer_trust_chain=yes")); +} + +#[test] +fn vsix_verify_signature_rejects_tampered_signed_package() { + let dir = tempfile::tempdir().unwrap(); + let input = package_fixture("unsigned/sample.vsix"); + let cert = dir.path().join("signer.der"); + let key = dir.path().join("signer.pkcs8"); + let output = dir.path().join("signed.vsix"); + let tampered = dir.path().join("tampered.vsix"); + write_test_rsa_cert_key(&cert, &key); + + let mut sign = portable_cmd(); + sign.arg("vsix-sign") + .arg(&input) + .arg("--cert") + .arg(&cert) + .arg("--key") + .arg(&key) + .arg("--output") + .arg(&output); + sign.assert().success(); + append_zip_entry(&output, &tampered, "tampered.txt", b"changed"); + + let mut verify = portable_cmd(); + verify.arg("vsix-verify-signature").arg(&tampered); + verify.assert().failure().stderr(predicate::str::contains( + "signature reference count mismatch", + )); +} + +#[test] +fn vsix_verify_signature_requires_embedded_signature_xml() { + let input = package_fixture("unsigned/sample.vsix"); + + let mut verify = portable_cmd(); + verify.arg("vsix-verify-signature").arg(&input); + verify.assert().failure().stderr(predicate::str::contains( + "VSIX package does not contain OPC signature XML", + )); +} + +#[test] +fn vsix_signature_reference_xml_rejects_different_package() { + let dir = tempfile::tempdir().unwrap(); + let input = package_fixture("unsigned/sample.vsix"); + let different = package_fixture("unsigned/nested.vsix"); + let xml = dir.path().join("signature-reference.xml"); + + let mut write = portable_cmd(); + write + .arg("vsix-signature-reference-xml") + .arg(&input) + .arg("--output") + .arg(&xml); + write.assert().success(); + + let mut verify = portable_cmd(); + verify + .arg("vsix-verify-signature-reference-xml") + .arg(&different) + .arg("--signature-xml") + .arg(&xml); + verify.assert().failure().stderr(predicate::str::contains( + "signature reference count mismatch", + )); +} + +#[test] +fn appinstaller_info_reports_descriptor_and_companion_signature() { + let descriptor = + repo_root().join("tests/fixtures/generated-unsigned/appinstaller/sample.appinstaller"); + let signature = + repo_root().join("tests/fixtures/generated-signed/appinstaller/sample.appinstaller.p7"); + + let mut cmd = portable_cmd(); + cmd.arg("appinstaller-info") + .arg(&descriptor) + .arg("--signature") + .arg(&signature); cmd.assert() .success() - .stdout(predicate::str::contains("opc_signature=yes")) + .stdout(predicate::str::contains("root=AppInstaller")) .stdout(predicate::str::contains( - "signature_origin=package/services/digital-signature/origin.psdsor", + "namespace=http://schemas.microsoft.com/appx/appinstaller/2018", )) - .stdout(predicate::str::contains("signature_parts=1")) + .stdout(predicate::str::contains("main_package=yes")) + .stdout(predicate::str::contains("main_bundle=no")) .stdout(predicate::str::contains( - "signature_part=package/services/digital-signature/xml-signature/", + "publisher=CN=Test Code Signing Certificate", + )) + .stdout(predicate::str::contains("companion_signature_len=")); +} + +#[test] +fn appinstaller_verify_companion_uses_detached_trust_path() { + let repo = repo_root(); + let descriptor = + repo.join("tests/fixtures/generated-unsigned/appinstaller/sample.appinstaller"); + let signature = + repo.join("tests/fixtures/generated-signed/appinstaller/sample.appinstaller.p7"); + + let mut cmd = portable_cmd(); + cmd.arg("appinstaller-verify-companion") + .arg(&descriptor) + .arg("--signature") + .arg(&signature) + .arg("--anchor-dir") + .arg(anchor_dir(&repo)); + cmd.assert().success().stdout(predicate::str::contains( + "appinstaller-verify-companion: ok", + )); +} + +#[test] +fn appinstaller_sign_companion_creates_verifiable_detached_pkcs7() { + let descriptor = + repo_root().join("tests/fixtures/generated-unsigned/appinstaller/sample.appinstaller"); + let dir = tempfile::tempdir().unwrap(); + let cert = dir.path().join("signer.der"); + let key = dir.path().join("signer.pkcs8"); + let signature = dir.path().join("sample.appinstaller.p7"); + write_test_rsa_cert_key(&cert, &key); + + let mut sign = portable_cmd(); + sign.arg("appinstaller-sign-companion") + .arg(&descriptor) + .arg("--cert") + .arg(&cert) + .arg("--key") + .arg(&key) + .arg("--output") + .arg(&signature); + sign.assert() + .success() + .stdout(predicate::str::contains("companion_signature_len=")); + + let mut verify = portable_cmd(); + verify + .arg("appinstaller-verify-companion") + .arg(&descriptor) + .arg("--signature") + .arg(&signature) + .arg("--trusted-ca") + .arg(&cert) + .arg("--allow-loose-signing-cert"); + verify.assert().success().stdout(predicate::str::contains( + "appinstaller-verify-companion: ok", + )); +} + +#[test] +fn appinstaller_sign_companion_from_external_signature_creates_verifiable_detached_pkcs7() { + let descriptor = + repo_root().join("tests/fixtures/generated-unsigned/appinstaller/sample.appinstaller"); + let dir = tempfile::tempdir().unwrap(); + let cert = dir.path().join("signer.der"); + let key = dir.path().join("signer.pkcs8"); + let prehash = dir.path().join("prehash.bin"); + let external_signature = dir.path().join("external.sig"); + let signature = dir.path().join("sample.appinstaller.p7"); + write_test_rsa_cert_key(&cert, &key); + + let mut prehash_cmd = portable_cmd(); + prehash_cmd + .arg("appinstaller-sign-companion-prehash") + .arg(&descriptor) + .arg("--encoding") + .arg("raw") + .arg("--output") + .arg(&prehash); + prehash_cmd.assert().success(); + + let key_bytes = std::fs::read(&key).expect("read test key"); + let private_key = rdp::parse_rsa_private_key(&key_bytes).expect("parse test key"); + let signing_key = SigningKey::::new(private_key); + let signed_attrs_digest = std::fs::read(&prehash).expect("read prehash"); + let raw_signature = signing_key + .sign_prehash(&signed_attrs_digest) + .expect("external RSA signature") + .to_bytes(); + std::fs::write(&external_signature, raw_signature).expect("write external signature"); + + let mut assemble = portable_cmd(); + assemble + .arg("appinstaller-sign-companion-from-signature") + .arg(&descriptor) + .arg("--cert") + .arg(&cert) + .arg("--signature") + .arg(&external_signature) + .arg("--output") + .arg(&signature); + assemble + .assert() + .success() + .stdout(predicate::str::contains("companion_signature_len=")); + + let mut verify = portable_cmd(); + verify + .arg("appinstaller-verify-companion") + .arg(&descriptor) + .arg("--signature") + .arg(&signature) + .arg("--trusted-ca") + .arg(&cert) + .arg("--allow-loose-signing-cert"); + verify.assert().success().stdout(predicate::str::contains( + "appinstaller-verify-companion: ok", + )); +} + +#[cfg(all(feature = "timestamp-server", feature = "timestamp-http"))] +#[test] +fn appinstaller_sign_companion_embeds_rfc3161_timestamp_attribute() { + let descriptor = + repo_root().join("tests/fixtures/generated-unsigned/appinstaller/sample.appinstaller"); + let dir = tempfile::tempdir().unwrap(); + let cert = dir.path().join("signer.der"); + let key = dir.path().join("signer.pkcs8"); + let signature = dir.path().join("sample.appinstaller.p7"); + let tsa_root = dir.path().join("tsa-root.der"); + write_test_rsa_cert_key(&cert, &key); + let tsa_root_arg = tsa_root.to_str().unwrap(); + let gen_time = generalized_time_tomorrow_noon_utc(); + let (mut guard, timestamp_url) = + spawn_psign_server_with_gen_time(&gen_time, &["--cert-output", tsa_root_arg]); + + let mut sign = portable_cmd(); + sign.arg("appinstaller-sign-companion") + .arg(&descriptor) + .arg("--cert") + .arg(&cert) + .arg("--key") + .arg(&key) + .arg("--timestamp-url") + .arg(×tamp_url) + .arg("--timestamp-digest") + .arg("sha256") + .arg("--output") + .arg(&signature); + sign.assert().success(); + let status = guard.0.wait().expect("timestamp server exit"); + assert!(status.success(), "timestamp server failed with {status}"); + + let mut inspect = portable_cmd(); + inspect + .arg("inspect-authenticode") + .arg(&signature) + .arg("--input") + .arg("pkcs7"); + inspect + .assert() + .success() + .stdout(predicate::str::contains( + "microsoft_nested_rfc3161_attribute", + )) + .stdout(predicate::str::contains("1.3.6.1.4.1.311.3.3.1")); + + let mut verify = portable_cmd(); + verify + .arg("appinstaller-verify-companion") + .arg(&descriptor) + .arg("--signature") + .arg(&signature) + .arg("--trusted-ca") + .arg(&cert) + .arg("--trusted-ca") + .arg(&tsa_root) + .arg("--allow-loose-signing-cert") + .arg("--prefer-timestamp-signing-time") + .arg("--require-valid-timestamp"); + verify.assert().success().stdout(predicate::str::contains( + "appinstaller-verify-companion: ok", + )); +} + +#[test] +fn appinstaller_set_publisher_updates_descriptor_metadata() { + let descriptor = + repo_root().join("tests/fixtures/generated-unsigned/appinstaller/sample.appinstaller"); + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("updated.appinstaller"); + let publisher = "CN=Updated Publisher, O=Example & Co"; + + let mut cmd = portable_cmd(); + cmd.arg("appinstaller-set-publisher") + .arg(&descriptor) + .arg("--publisher") + .arg(publisher) + .arg("--output") + .arg(&output); + cmd.assert() + .success() + .stdout(predicate::str::contains("publisher=CN=Updated Publisher")); + + let xml = std::fs::read_to_string(&output).unwrap(); + assert!(xml.contains("Publisher=\"CN=Updated Publisher, O=Example & Co\"")); + + let mut info = portable_cmd(); + info.arg("appinstaller-info").arg(&output); + info.assert().success().stdout(predicate::str::contains( + "publisher=CN=Updated Publisher, O=Example & Co", + )); +} + +#[test] +fn business_central_app_info_detects_navx_header() { + let dir = tempfile::tempdir().unwrap(); + let valid = dir.path().join("valid.app"); + let invalid = dir.path().join("invalid.app"); + std::fs::write(&valid, b"NAVX\x00fixture").unwrap(); + std::fs::write(&invalid, b"not-navx").unwrap(); + + let mut valid_cmd = portable_cmd(); + valid_cmd.arg("business-central-app-info").arg(&valid); + valid_cmd + .assert() + .success() + .stdout(predicate::str::contains("business_central_app=yes")) + .stdout(predicate::str::contains("header=NAVX")); + + let mut invalid_cmd = portable_cmd(); + invalid_cmd.arg("business-central-app-info").arg(&invalid); + invalid_cmd + .assert() + .success() + .stdout(predicate::str::contains("business_central_app=no")) + .stdout(predicate::str::contains("header=-")); +} + +#[test] +fn msix_manifest_info_and_set_publisher_update_identity() { + let input = repo_root().join("tests/fixtures/generated-unsigned/msix/sample.msix"); + + let mut info = portable_cmd(); + info.arg("msix-manifest-info").arg(&input); + info.assert() + .success() + .stdout(predicate::str::contains("package_name=Psign.ParityMinimal")) + .stdout(predicate::str::contains( + "publisher=CN=Test Code Signing Certificate", + )) + .stdout(predicate::str::contains("version=1.0.0.0")); + + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("updated.msix"); + let publisher = "CN=Updated MSIX Publisher, O=Example & Co"; + let mut update = portable_cmd(); + update + .arg("msix-set-publisher") + .arg(&input) + .arg("--publisher") + .arg(publisher) + .arg("--output") + .arg(&output); + update + .assert() + .success() + .stdout(predicate::str::contains("publisher=CN=Updated MSIX")); + + let mut updated_info = portable_cmd(); + updated_info.arg("msix-manifest-info").arg(&output); + updated_info + .assert() + .success() + .stdout(predicate::str::contains( + "publisher=CN=Updated MSIX Publisher, O=Example & Co", + )); +} + +#[test] +fn clickonce_deploy_info_and_copy_payload_use_content_name() { + let dir = tempfile::tempdir().unwrap(); + let deployed = dir.path().join("app.exe.deploy"); + let output = dir.path().join("app.exe"); + std::fs::write(&deployed, b"payload").unwrap(); + + let mut info = portable_cmd(); + info.arg("clickonce-deploy-info").arg(&deployed); + info.assert() + .success() + .stdout(predicate::str::contains("deployed=yes")) + .stdout(predicate::str::contains("content_name=app.exe")) + .stdout(predicate::str::contains("len=7")); + + let mut copy = portable_cmd(); + copy.arg("clickonce-copy-deploy-payload") + .arg(&deployed) + .arg("--output") + .arg(&output); + copy.assert() + .success() + .stdout(predicate::str::contains("content_name=app.exe")) + .stdout(predicate::str::contains("bytes=7")); + assert_eq!(std::fs::read(&output).unwrap(), b"payload"); +} + +#[test] +fn clickonce_manifest_hashes_can_update_and_verify_file_references() { + let dir = tempfile::tempdir().unwrap(); + let payload_dir = dir.path().join("bin"); + std::fs::create_dir(&payload_dir).unwrap(); + std::fs::write(payload_dir.join("app.dll"), b"ClickOnce payload").unwrap(); + let manifest = dir.path().join("app.exe.manifest"); + let updated = dir.path().join("app.updated.manifest"); + std::fs::write( + &manifest, + r#" + + + + + stale + + +"#, + ) + .unwrap(); + + let mut verify_stale = portable_cmd(); + verify_stale + .arg("clickonce-manifest-hashes") + .arg(&manifest) + .arg("--base-directory") + .arg(dir.path()); + verify_stale + .assert() + .failure() + .stderr(predicate::str::contains("hash verification failed")) + .stdout(predicate::str::contains("status=mismatch")); + + let mut update = portable_cmd(); + update + .arg("clickonce-update-manifest-hashes") + .arg(&manifest) + .arg("--base-directory") + .arg(dir.path()) + .arg("--algorithm") + .arg("sha256") + .arg("--output") + .arg(&updated); + update + .assert() + .success() + .stdout(predicate::str::contains("updated=1")) + .stdout(predicate::str::contains("algorithm=sha256")); + + let updated_text = std::fs::read_to_string(&updated).unwrap(); + assert!(updated_text.contains("size=\"17\"")); + assert!(updated_text.contains("http://www.w3.org/2001/04/xmlenc#sha256")); + assert!(!updated_text.contains(">stale<")); + + let mut verify_updated = portable_cmd(); + verify_updated + .arg("clickonce-manifest-hashes") + .arg(&updated) + .arg("--base-directory") + .arg(dir.path()); + verify_updated + .assert() + .success() + .stdout(predicate::str::contains("references=1")) + .stdout(predicate::str::contains("path=bin/app.dll")) + .stdout(predicate::str::contains("algorithm=sha256")) + .stdout(predicate::str::contains("status=valid")) + .stdout(predicate::str::contains("mismatches=0")); +} + +#[test] +fn clickonce_sign_manifest_creates_verifiable_portable_xmldsig() { + let dir = tempfile::tempdir().unwrap(); + let manifest = dir.path().join("app.exe.manifest"); + let signed = dir.path().join("app.signed.manifest"); + let tampered = dir.path().join("app.tampered.manifest"); + let cert = dir.path().join("signer.der"); + let key = dir.path().join("signer.pkcs8"); + write_test_rsa_cert_key(&cert, &key); + std::fs::write( + &manifest, + r#" + + + +"#, + ) + .unwrap(); + + let mut sign = portable_cmd(); + sign.arg("clickonce-sign-manifest") + .arg(&manifest) + .arg("--cert") + .arg(&cert) + .arg("--key") + .arg(&key) + .arg("--output") + .arg(&signed); + sign.assert() + .success() + .stdout(predicate::str::contains("signature_len=")); + + let signed_text = std::fs::read_to_string(&signed).unwrap(); + assert!(signed_text.contains("")); + assert!(signed_text.contains("")); + + let mut verify = portable_cmd(); + verify + .arg("clickonce-verify-manifest-signature") + .arg(&signed) + .arg("--trusted-ca") + .arg(&cert); + verify + .assert() + .success() + .stdout(predicate::str::contains( + "clickonce-verify-manifest-signature: ok", + )) + .stdout(predicate::str::contains("manifest_digest_match=yes")) + .stdout(predicate::str::contains("signature_value_match=yes")) + .stdout(predicate::str::contains("signer_trust_chain=yes")); + + std::fs::write( + &tampered, + signed_text.replace("ClickOnce.Sample", "ClickOnce.Tampered"), + ) + .unwrap(); + let mut verify_tampered = portable_cmd(); + verify_tampered + .arg("clickonce-verify-manifest-signature") + .arg(&tampered) + .arg("--trusted-ca") + .arg(&cert); + verify_tampered + .assert() + .failure() + .stderr(predicate::str::contains( + "SignedInfo does not match manifest digest", )); } +#[test] +fn clickonce_sign_manifest_from_external_signature_creates_verifiable_xmldsig() { + let dir = tempfile::tempdir().unwrap(); + let manifest = dir.path().join("app.exe.manifest"); + let prehash = dir.path().join("prehash.bin"); + let external_signature = dir.path().join("external.sig"); + let signed = dir.path().join("app.signed.manifest"); + let cert = dir.path().join("signer.der"); + let key = dir.path().join("signer.pkcs8"); + write_test_rsa_cert_key(&cert, &key); + std::fs::write( + &manifest, + r#" + + +"#, + ) + .unwrap(); + + let mut prehash_cmd = portable_cmd(); + prehash_cmd + .arg("clickonce-sign-manifest-prehash") + .arg(&manifest) + .arg("--encoding") + .arg("raw") + .arg("--output") + .arg(&prehash); + prehash_cmd.assert().success(); + + let key_bytes = std::fs::read(&key).expect("read test key"); + let private_key = rdp::parse_rsa_private_key(&key_bytes).expect("parse test key"); + let signing_key = SigningKey::::new(private_key); + let signed_info_digest = std::fs::read(&prehash).expect("read prehash"); + let raw_signature = signing_key + .sign_prehash(&signed_info_digest) + .expect("external RSA signature") + .to_bytes(); + std::fs::write(&external_signature, raw_signature).expect("write external signature"); + + let mut assemble = portable_cmd(); + assemble + .arg("clickonce-sign-manifest-from-signature") + .arg(&manifest) + .arg("--cert") + .arg(&cert) + .arg("--signature") + .arg(&external_signature) + .arg("--output") + .arg(&signed); + assemble + .assert() + .success() + .stdout(predicate::str::contains("signature_len=")); + + let mut verify = portable_cmd(); + verify + .arg("clickonce-verify-manifest-signature") + .arg(&signed) + .arg("--trusted-ca") + .arg(&cert); + verify + .assert() + .success() + .stdout(predicate::str::contains( + "clickonce-verify-manifest-signature: ok", + )) + .stdout(predicate::str::contains("manifest_digest_match=yes")) + .stdout(predicate::str::contains("signature_value_match=yes")) + .stdout(predicate::str::contains("signer_trust_chain=yes")); +} + fn package_fixture(rel: &str) -> PathBuf { let separator = std::path::MAIN_SEPARATOR.to_string(); repo_root() @@ -354,6 +1771,31 @@ fn write_test_rsa_cert_key(cert_path: &Path, key_path: &Path) { .expect("write key"); } +fn append_zip_entry(input: &Path, output: &Path, entry_name: &str, entry_bytes: &[u8]) { + let input_file = std::fs::File::open(input).expect("open input zip"); + let mut archive = zip::ZipArchive::new(input_file).expect("read input zip"); + let output_file = std::fs::File::create(output).expect("create output zip"); + let mut writer = zip::ZipWriter::new(output_file); + + for i in 0..archive.len() { + let mut entry = archive.by_index(i).expect("read zip entry"); + let name = entry.name().to_string(); + let options = zip::write::FileOptions::default().compression_method(entry.compression()); + if entry.is_dir() { + writer.add_directory(name, options).unwrap(); + } else { + writer.start_file(name, options).unwrap(); + std::io::copy(&mut entry, &mut writer).unwrap(); + } + } + + writer + .start_file(entry_name, zip::write::FileOptions::default()) + .unwrap(); + writer.write_all(entry_bytes).unwrap(); + writer.finish().unwrap(); +} + #[test] fn generated_signed_corpus_verifies_with_portable_cli() { let repo = repo_root(); @@ -1846,6 +3288,35 @@ fn portable_verify_negative_msix_encrypted_extension_cli() { ); } +#[test] +fn portable_msix_metadata_helpers_reject_encrypted_extensions() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("fake.eappx"); + let out = dir.path().join("out.eappx"); + std::fs::write(&p, b"not-a-real-package").unwrap(); + + let mut info = portable_cmd(); + info.arg("msix-manifest-info").arg(&p); + info.assert() + .failure() + .stderr(predicate::str::contains("encrypted MSIX/AppX packages")) + .stderr(predicate::str::contains("Windows AppxSip OS delegation")); + + let mut update = portable_cmd(); + update + .arg("msix-set-publisher") + .arg(&p) + .arg("--publisher") + .arg("CN=Example") + .arg("--output") + .arg(&out); + update + .assert() + .failure() + .stderr(predicate::str::contains("encrypted MSIX/AppX packages")) + .stderr(predicate::str::contains("Windows AppxSip OS delegation")); +} + #[test] fn portable_verify_negative_msix_non_zip_cli() { let dir = tempfile::tempdir().unwrap(); diff --git a/tests/code_command.rs b/tests/code_command.rs new file mode 100644 index 0000000..5b43feb --- /dev/null +++ b/tests/code_command.rs @@ -0,0 +1,1561 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use psign_opc_sign::nuget; +use rand::rngs::OsRng; +use rsa::RsaPrivateKey; +use rsa::pkcs1v15::SigningKey; +use rsa::pkcs8::EncodePrivateKey; +use rsa::signature::Keypair; +use serde_json::Value; +use sha2::Sha256; +use std::io::Read as _; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::time::Duration; +use x509_cert::builder::{Builder, CertificateBuilder, Profile}; +use x509_cert::der::Encode; +use x509_cert::name::Name; +use x509_cert::serial_number::SerialNumber; +use x509_cert::spki::SubjectPublicKeyInfoOwned; +use x509_cert::time::Validity; + +fn psign() -> Command { + Command::cargo_bin("psign-tool").unwrap() +} + +#[test] +fn help_lists_code_orchestrator_command() { + let mut cmd = psign(); + cmd.arg("--help"); + cmd.assert() + .success() + .stdout(predicate::str::contains("code")); +} + +#[test] +fn code_dry_run_file_list_applies_globs_braces_ranges_and_negation() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + write_file(&base.join("lib/net8.0/a1.dll"), b"pe"); + write_file(&base.join("lib/net8.0/a2.dll"), b"pe"); + write_file(&base.join("lib/net7.0/a1.dll"), b"pe"); + write_file(&base.join("tools/readme.txt"), b"text"); + let file_list = base.join("files.txt"); + std::fs::write(&file_list, "lib/net{7..8}.0/a?.dll\n!lib/net8.0/a2.dll\n").unwrap(); + + let mut cmd = psign(); + let assert = cmd + .args(["code", "--dry-run", "--plan-json", "--base-directory"]) + .arg(base) + .args(["--file-list"]) + .arg("files.txt") + .assert() + .success(); + + let json: Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); + let paths = plan_paths(&json); + assert_eq!(paths, ["lib/net7.0/a1.dll", "lib/net8.0/a1.dll"]); +} + +#[test] +fn code_dry_run_recurses_nested_vsix_and_nupkg_inside_out() { + let repo = repo_root(); + let input = "tests/fixtures/package-signing/unsigned/deep-nested.vsix"; + + let mut cmd = psign(); + let assert = cmd + .args(["code", "--dry-run", "--plan-json", "--base-directory"]) + .arg(&repo) + .arg(input) + .assert() + .success(); + + let json: Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); + let paths = plan_paths(&json); + assert!( + paths + .iter() + .any(|path| path == "tests/fixtures/package-signing/unsigned/deep-nested.vsix") + ); + assert!( + paths + .iter() + .any(|path| path.ends_with("!packages/with-pe.nupkg")) + ); + assert!( + paths + .iter() + .any(|path| path.ends_with("!packages/with-pe.nupkg!lib/net8.0/tiny32.dll")) + ); + + let edges = json["edges"].as_array().expect("edges"); + assert!( + !edges.is_empty(), + "inside-out graph should order nested entries before containers" + ); +} + +#[test] +fn code_dry_run_applies_exclude_filters_inside_containers() { + let repo = repo_root(); + let input = "tests/fixtures/package-signing/unsigned/deep-nested.vsix"; + let file_list = repo.join("target/psign-code-nested-exclude.txt"); + std::fs::write(&file_list, format!("{input}\n!**/*.dll\n")).unwrap(); + + let mut cmd = psign(); + let assert = cmd + .args(["code", "--dry-run", "--plan-json", "--base-directory"]) + .arg(&repo) + .args(["--file-list"]) + .arg(&file_list) + .assert() + .success(); + + let json: Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); + let paths = plan_paths(&json); + assert!( + paths + .iter() + .any(|path| path.ends_with("!packages/with-pe.nupkg")) + ); + assert!( + !paths + .iter() + .any(|path| path.ends_with("!packages/with-pe.nupkg!lib/net8.0/tiny32.dll")), + "nested DLL should be excluded by file-list rule" + ); +} + +#[test] +fn code_without_dry_run_fails_safely() { + let mut cmd = psign(); + cmd.args([ + "code", + "tests/fixtures/package-signing/unsigned/sample.nupkg", + ]) + .assert() + .failure() + .stderr(predicate::str::contains( + "currently requires --cert and --key", + )); +} + +#[test] +fn code_signs_top_level_nupkg_with_local_cert_key() { + let repo = repo_root(); + let temp = tempfile::tempdir().unwrap(); + let cert = temp.path().join("signer.der"); + let key = temp.path().join("signer.pkcs8"); + let output = temp.path().join("signed.nupkg"); + write_test_rsa_cert_key(&cert, &key); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(&repo) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("tests/fixtures/package-signing/unsigned/sample.nupkg"); + cmd.assert() + .success() + .stdout(predicate::str::contains( + "signed tests/fixtures/package-signing/unsigned/sample.nupkg", + )) + .stdout(predicate::str::contains(".signature.p7s")); + + let mut info = psign(); + info.args(["portable", "nupkg-signature-info"]) + .arg(&output) + .assert() + .success() + .stdout(predicate::str::contains("signed=yes")) + .stdout(predicate::str::contains("signature_stored=yes")); +} + +#[test] +fn code_signs_top_level_pe_with_local_cert_key() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("app.exe"); + let output = base.join("app.signed.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 cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("app.exe"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Authenticode PE/WinMD")); + + let mut verify = psign(); + verify + .args(["portable", "verify-pe"]) + .arg(&output) + .assert() + .success(); +} + +#[test] +fn code_skip_signed_copies_already_signed_pe() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("app.exe"); + let signed = base.join("app.initially-signed.exe"); + let output = base.join("app.skipped.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 first = psign(); + first + .args(["code", "--base-directory"]) + .arg(base) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&signed) + .arg("app.exe"); + first.assert().success(); + + let mut skip = psign(); + skip.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--skip-signed", + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("app.initially-signed.exe"); + skip.assert() + .success() + .stdout(predicate::str::contains("skipped")) + .stdout(predicate::str::contains("already signed")); + + assert_eq!( + std::fs::read(&signed).unwrap(), + std::fs::read(&output).unwrap() + ); +} + +#[test] +fn code_skip_signed_copies_already_signed_nupkg() { + let repo = repo_root(); + let temp = tempfile::tempdir().unwrap(); + let cert = temp.path().join("signer.der"); + let key = temp.path().join("signer.pkcs8"); + let output = temp.path().join("already-signed.nupkg"); + write_test_rsa_cert_key(&cert, &key); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(&repo) + .args([ + "--skip-signed", + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("tests/fixtures/package-signing/signed/sample.signed.nupkg"); + cmd.assert() + .success() + .stdout(predicate::str::contains("skipped")) + .stdout(predicate::str::contains("already signed")); + + let mut info = psign(); + info.args(["portable", "nupkg-signature-info"]) + .arg(&output) + .assert() + .success() + .stdout(predicate::str::contains("signed=yes")); +} + +#[test] +fn code_overwrite_resigns_already_signed_nupkg() { + let repo = repo_root(); + let temp = tempfile::tempdir().unwrap(); + let cert = temp.path().join("signer.der"); + let key = temp.path().join("signer.pkcs8"); + let output = temp.path().join("resigned.nupkg"); + write_test_rsa_cert_key(&cert, &key); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(&repo) + .args([ + "--overwrite", + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("tests/fixtures/package-signing/signed/sample.signed.nupkg"); + cmd.assert() + .success() + .stdout(predicate::str::contains( + "signed tests/fixtures/package-signing/signed/sample.signed.nupkg", + )) + .stdout(predicate::str::contains(".signature.p7s")); + + let mut verify = psign(); + verify + .args(["portable", "nupkg-verify-signature"]) + .arg(&output) + .args(["--trusted-ca"]) + .arg(&cert) + .args(["--allow-loose-signing-cert"]) + .assert() + .success() + .stdout(predicate::str::contains("nupkg-verify-signature: ok")); +} + +#[test] +fn code_signs_appinstaller_companion_with_local_cert_key() { + let repo = repo_root(); + let temp = tempfile::tempdir().unwrap(); + let cert = temp.path().join("signer.der"); + let key = temp.path().join("signer.pkcs8"); + let output = temp.path().join("sample.appinstaller.p7"); + write_test_rsa_cert_key(&cert, &key); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(&repo) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("tests/fixtures/generated-unsigned/appinstaller/sample.appinstaller"); + cmd.assert() + .success() + .stdout(predicate::str::contains("detached PKCS#7 companion")); + + let mut verify = psign(); + verify + .args([ + "portable", + "appinstaller-verify-companion", + "tests/fixtures/generated-unsigned/appinstaller/sample.appinstaller", + "--signature", + ]) + .arg(&output) + .args(["--trusted-ca"]) + .arg(&cert) + .args(["--allow-loose-signing-cert"]) + .assert() + .success() + .stdout(predicate::str::contains( + "appinstaller-verify-companion: ok", + )); +} + +#[test] +fn code_updates_appinstaller_publisher_before_signing_companion() { + let repo = repo_root(); + let temp = tempfile::tempdir().unwrap(); + let cert = temp.path().join("signer.der"); + let key = temp.path().join("signer.pkcs8"); + let descriptor = temp.path().join("updated.appinstaller"); + let signature = temp.path().join("updated.appinstaller.p7"); + let publisher = "CN=Updated App Installer Publisher, O=Example & Co"; + write_test_rsa_cert_key(&cert, &key); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(&repo) + .args([ + "--publisher-name", + publisher, + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&signature) + .arg("tests/fixtures/generated-unsigned/appinstaller/sample.appinstaller"); + cmd.assert() + .success() + .stdout(predicate::str::contains("updated descriptor")) + .stdout(predicate::str::contains("detached PKCS#7 companion")); + + let mut info = psign(); + info.args(["portable", "appinstaller-info"]) + .arg(&descriptor) + .assert() + .success() + .stdout(predicate::str::contains( + "publisher=CN=Updated App Installer Publisher, O=Example & Co", + )); + + let mut verify = psign(); + verify + .args(["portable", "appinstaller-verify-companion"]) + .arg(&descriptor) + .args(["--signature"]) + .arg(&signature) + .args(["--trusted-ca"]) + .arg(&cert) + .args(["--allow-loose-signing-cert"]) + .assert() + .success() + .stdout(predicate::str::contains( + "appinstaller-verify-companion: ok", + )); +} + +#[test] +fn code_signs_clickonce_deploy_pe_payload_with_local_cert_key() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("app.exe.deploy"); + let output = base.join("app.signed.exe.deploy"); + 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 cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("app.exe.deploy"); + cmd.assert() + .success() + .stdout(predicate::str::contains("ClickOnce .deploy payload")); + + let mut verify = psign(); + verify + .args(["portable", "verify-pe"]) + .arg(&output) + .assert() + .success(); +} + +#[test] +fn code_prepares_msix_with_nested_pe_and_publisher_update() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("sample.msix"); + let output = base.join("prepared.msix"); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + let nested_pe = base.join("app.signed.exe"); + write_test_rsa_cert_key(&cert, &key); + write_zip( + &input, + &[ + ( + "[Content_Types].xml", + br#""# + .as_slice(), + ), + ( + "AppxManifest.xml", + br#""# + .as_slice(), + ), + ( + "AppxBlockMap.xml", + br#""# + .as_slice(), + ), + ( + "app.exe", + &std::fs::read( + repo_root().join("tests/fixtures/pe-authenticode-upstream/tiny32.efi"), + ) + .unwrap(), + ), + ], + ); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--publisher-name", + "CN=Updated Publisher, O=Example & Co", + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("sample.msix"); + cmd.assert() + .success() + .stdout(predicate::str::contains("unsigned MSIX/AppX")); + + let mut info = psign(); + info.args(["portable", "msix-manifest-info"]) + .arg(&output) + .assert() + .success() + .stdout(predicate::str::contains( + "publisher=CN=Updated Publisher, O=Example & Co", + )); + + let mut archive = zip::ZipArchive::new(std::fs::File::open(&output).unwrap()).unwrap(); + let mut pe = Vec::new(); + archive + .by_name("app.exe") + .unwrap() + .read_to_end(&mut pe) + .unwrap(); + std::fs::write(&nested_pe, pe).unwrap(); + + let mut verify = psign(); + verify + .args(["portable", "verify-pe"]) + .arg(&nested_pe) + .assert() + .success(); + + let mut block_map = String::new(); + archive + .by_name("AppxBlockMap.xml") + .unwrap() + .read_to_string(&mut block_map) + .unwrap(); + assert!(block_map.contains("app.exe")); + assert!(block_map.contains("AppxManifest.xml")); +} + +#[test] +fn code_classifies_encrypted_msix_as_os_only_and_fails_explicitly() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + let output = base.join("prepared.emsix"); + write_test_rsa_cert_key(&cert, &key); + write_file(&base.join("encrypted.emsix"), b"encrypted-placeholder"); + + let mut dry = psign(); + let assert = dry + .args(["code", "--dry-run", "--plan-json", "--base-directory"]) + .arg(base) + .arg("encrypted.emsix") + .assert() + .success(); + let json: Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); + let node = json["nodes"].as_array().unwrap().first().unwrap(); + assert_eq!(node["format"].as_str().unwrap(), "encrypted-msix"); + assert_eq!(node["signer"].as_str().unwrap(), "msix-encrypted-os-only"); + + let mut sign = psign(); + sign.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("encrypted.emsix"); + sign.assert() + .failure() + .stderr(predicate::str::contains("encrypted MSIX/AppX package")) + .stderr(predicate::str::contains("Windows AppxSip OS delegation")); +} + +#[test] +fn code_signs_top_level_vsix_with_local_cert_key() { + let repo = repo_root(); + let temp = tempfile::tempdir().unwrap(); + let cert = temp.path().join("signer.der"); + let key = temp.path().join("signer.pkcs8"); + let output = temp.path().join("signed.vsix"); + let signature_xml = temp.path().join("signature.xml"); + write_test_rsa_cert_key(&cert, &key); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(&repo) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("tests/fixtures/package-signing/unsigned/sample.vsix"); + cmd.assert() + .success() + .stdout(predicate::str::contains("psign-signature.psdsxs")); + + let mut archive = zip::ZipArchive::new(std::fs::File::open(&output).unwrap()).unwrap(); + let mut xml = Vec::new(); + archive + .by_name("package/services/digital-signature/xml-signature/psign-signature.psdsxs") + .unwrap() + .read_to_end(&mut xml) + .unwrap(); + std::fs::write(&signature_xml, xml).unwrap(); + + let mut verify = psign(); + verify + .args([ + "portable", + "vsix-verify-signature-xml", + "tests/fixtures/package-signing/unsigned/sample.vsix", + "--signature-xml", + ]) + .arg(&signature_xml) + .args(["--cert"]) + .arg(&cert) + .assert() + .success() + .stdout(predicate::str::contains("signature_value_match=yes")); +} + +#[test] +fn code_overwrite_resigns_already_signed_vsix() { + let repo = repo_root(); + let temp = tempfile::tempdir().unwrap(); + let cert = temp.path().join("signer.der"); + let key = temp.path().join("signer.pkcs8"); + let output = temp.path().join("resigned.vsix"); + write_test_rsa_cert_key(&cert, &key); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(&repo) + .args([ + "--overwrite", + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("tests/fixtures/package-signing/signed/sample.signed.vsix"); + cmd.assert() + .success() + .stdout(predicate::str::contains( + "signed tests/fixtures/package-signing/signed/sample.signed.vsix", + )) + .stdout(predicate::str::contains("psign-signature.psdsxs")); + + let mut verify = psign(); + verify + .args(["portable", "vsix-verify-signature"]) + .arg(&output) + .assert() + .success() + .stdout(predicate::str::contains("vsix-verify-signature: ok")); +} + +#[test] +fn code_signs_nested_nupkg_before_outer_vsix() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("nested.vsix"); + let output = base.join("signed.vsix"); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + let nested_nupkg = base.join("nested-signed.nupkg"); + let signature_xml = base.join("signature.xml"); + write_test_rsa_cert_key(&cert, &key); + write_zip( + &input, + &[ + ("extension.vsixmanifest", b"".as_slice()), + ("[Content_Types].xml", b"".as_slice()), + ( + "packages/sample.nupkg", + &std::fs::read( + repo_root().join("tests/fixtures/package-signing/unsigned/sample.nupkg"), + ) + .unwrap(), + ), + ], + ); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("nested.vsix"); + cmd.assert() + .success() + .stdout(predicate::str::contains("signed nested.vsix")); + + let mut archive = zip::ZipArchive::new(std::fs::File::open(&output).unwrap()).unwrap(); + let mut nested = Vec::new(); + archive + .by_name("packages/sample.nupkg") + .unwrap() + .read_to_end(&mut nested) + .unwrap(); + std::fs::write(&nested_nupkg, nested).unwrap(); + let mut xml = Vec::new(); + archive + .by_name("package/services/digital-signature/xml-signature/psign-signature.psdsxs") + .unwrap() + .read_to_end(&mut xml) + .unwrap(); + std::fs::write(&signature_xml, xml).unwrap(); + drop(archive); + + let mut nested_info = psign(); + nested_info + .args(["portable", "nupkg-signature-info"]) + .arg(&nested_nupkg) + .assert() + .success() + .stdout(predicate::str::contains("signed=yes")); + + let mut verify_outer = psign(); + verify_outer + .args(["portable", "vsix-verify-signature-xml"]) + .arg(&output) + .args(["--signature-xml"]) + .arg(&signature_xml) + .args(["--cert"]) + .arg(&cert) + .assert() + .success() + .stdout(predicate::str::contains("signature_value_match=yes")); +} + +#[test] +fn code_signs_nested_nupkg_inside_generic_zip() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("bundle.zip"); + let output = base.join("signed.zip"); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + let nested_nupkg = base.join("nested-signed.nupkg"); + write_test_rsa_cert_key(&cert, &key); + write_zip( + &input, + &[ + ("readme.txt", b"unsigned container".as_slice()), + ( + "packages/sample.nupkg", + &std::fs::read( + repo_root().join("tests/fixtures/package-signing/unsigned/sample.nupkg"), + ) + .unwrap(), + ), + ], + ); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("bundle.zip"); + cmd.assert() + .success() + .stdout(predicate::str::contains("nested package entries")); + + let mut archive = zip::ZipArchive::new(std::fs::File::open(&output).unwrap()).unwrap(); + let mut nested = Vec::new(); + archive + .by_name("packages/sample.nupkg") + .unwrap() + .read_to_end(&mut nested) + .unwrap(); + std::fs::write(&nested_nupkg, nested).unwrap(); + + let mut nested_info = psign(); + nested_info + .args(["portable", "nupkg-signature-info"]) + .arg(&nested_nupkg) + .assert() + .success() + .stdout(predicate::str::contains("signed=yes")); +} + +#[test] +fn code_continue_on_error_signs_remaining_top_level_inputs() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("bundle.zip"); + let unsupported = base.join("unsupported.exe"); + let output_dir = base.join("signed"); + let signed_zip = output_dir.join("bundle.zip"); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + let nested_nupkg = base.join("nested-signed.nupkg"); + write_test_rsa_cert_key(&cert, &key); + write_file(&unsupported, b"MZunsupported"); + write_zip( + &input, + &[( + "packages/sample.nupkg", + &std::fs::read( + repo_root().join("tests/fixtures/package-signing/unsigned/sample.nupkg"), + ) + .unwrap(), + )], + ); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--continue-on-error", + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output_dir) + .args(["bundle.zip", "unsupported.exe"]); + cmd.assert() + .failure() + .stdout(predicate::str::contains("signed bundle.zip")) + .stdout(predicate::str::contains("failed unsupported.exe")); + + let mut archive = zip::ZipArchive::new(std::fs::File::open(&signed_zip).unwrap()).unwrap(); + let mut nested = Vec::new(); + archive + .by_name("packages/sample.nupkg") + .unwrap() + .read_to_end(&mut nested) + .unwrap(); + std::fs::write(&nested_nupkg, nested).unwrap(); + + let mut nested_info = psign(); + nested_info + .args(["portable", "nupkg-signature-info"]) + .arg(&nested_nupkg) + .assert() + .success() + .stdout(predicate::str::contains("signed=yes")); +} + +#[test] +fn code_max_concurrency_signs_independent_top_level_packages() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let output_dir = base.join("signed"); + 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/package-signing/unsigned/sample.nupkg"), + base.join("a.nupkg"), + ) + .unwrap(); + std::fs::copy( + repo_root().join("tests/fixtures/package-signing/unsigned/sample.snupkg"), + base.join("b.snupkg"), + ) + .unwrap(); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--max-concurrency", + "2", + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output_dir) + .args(["a.nupkg", "b.snupkg"]); + cmd.assert() + .success() + .stdout(predicate::str::contains("signed a.nupkg")) + .stdout(predicate::str::contains("signed b.snupkg")); + + for name in ["a.nupkg", "b.snupkg"] { + let mut info = psign(); + info.args(["portable", "nupkg-signature-info"]) + .arg(output_dir.join(name)) + .assert() + .success() + .stdout(predicate::str::contains("signed=yes")); + } +} + +#[cfg(all(feature = "timestamp-server", feature = "timestamp-http"))] +#[test] +fn code_signs_nupkg_with_rfc3161_timestamp() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let output = base.join("timestamped.nupkg"); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + let signature = base.join("signature.p7s"); + write_test_rsa_cert_key(&cert, &key); + std::fs::copy( + repo_root().join("tests/fixtures/package-signing/unsigned/sample.nupkg"), + base.join("sample.nupkg"), + ) + .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("sample.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"); + std::fs::write(&signature, signature_der).unwrap(); + let mut inspect = psign(); + inspect + .args(["portable", "inspect-authenticode"]) + .arg(&signature) + .args(["--input", "pkcs7"]); + inspect + .assert() + .success() + .stdout(predicate::str::contains( + "microsoft_nested_rfc3161_attribute", + )) + .stdout(predicate::str::contains("1.3.6.1.4.1.311.3.3.1")); +} + +#[test] +fn code_rejects_zero_max_concurrency() { + let mut cmd = psign(); + cmd.args(["code", "--dry-run", "--max-concurrency", "0"]) + .assert() + .failure() + .stderr(predicate::str::contains( + "--max-concurrency must be greater than zero", + )); +} + +#[test] +fn code_overwrite_resigns_signed_nupkg_inside_generic_zip() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("bundle.zip"); + let output = base.join("resigned.zip"); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + let nested_nupkg = base.join("nested-resigned.nupkg"); + write_test_rsa_cert_key(&cert, &key); + write_zip( + &input, + &[ + ("readme.txt", b"signed container".as_slice()), + ( + "packages/sample.nupkg", + &std::fs::read( + repo_root().join("tests/fixtures/package-signing/signed/sample.signed.nupkg"), + ) + .unwrap(), + ), + ], + ); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--overwrite", + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("bundle.zip"); + cmd.assert() + .success() + .stdout(predicate::str::contains("nested package entries")); + + let mut archive = zip::ZipArchive::new(std::fs::File::open(&output).unwrap()).unwrap(); + let mut nested = Vec::new(); + archive + .by_name("packages/sample.nupkg") + .unwrap() + .read_to_end(&mut nested) + .unwrap(); + std::fs::write(&nested_nupkg, nested).unwrap(); + + let mut verify = psign(); + verify + .args(["portable", "nupkg-verify-signature"]) + .arg(&nested_nupkg) + .args(["--trusted-ca"]) + .arg(&cert) + .args(["--allow-loose-signing-cert"]) + .assert() + .success() + .stdout(predicate::str::contains("nupkg-verify-signature: ok")); +} + +#[test] +fn code_overwrite_resigns_signed_vsix_inside_generic_zip() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("bundle.zip"); + let output = base.join("resigned.zip"); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + let nested_vsix = base.join("nested-resigned.vsix"); + write_test_rsa_cert_key(&cert, &key); + write_zip( + &input, + &[ + ("readme.txt", b"signed container".as_slice()), + ( + "extensions/sample.vsix", + &std::fs::read( + repo_root().join("tests/fixtures/package-signing/signed/sample.signed.vsix"), + ) + .unwrap(), + ), + ], + ); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--overwrite", + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("bundle.zip"); + cmd.assert() + .success() + .stdout(predicate::str::contains("nested package entries")); + + let mut archive = zip::ZipArchive::new(std::fs::File::open(&output).unwrap()).unwrap(); + let mut nested = Vec::new(); + archive + .by_name("extensions/sample.vsix") + .unwrap() + .read_to_end(&mut nested) + .unwrap(); + std::fs::write(&nested_vsix, nested).unwrap(); + + let mut verify = psign(); + verify + .args(["portable", "vsix-verify-signature"]) + .arg(&nested_vsix) + .assert() + .success() + .stdout(predicate::str::contains("vsix-verify-signature: ok")); +} + +#[test] +fn code_signs_nested_package_when_excluding_unsupported_inner_payload() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("deep-nested.vsix"); + let output = base.join("signed.vsix"); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + let file_list = base.join("files.txt"); + let nested_nupkg = base.join("with-pe-signed.nupkg"); + let signature_xml = base.join("signature.xml"); + write_test_rsa_cert_key(&cert, &key); + std::fs::copy( + repo_root().join("tests/fixtures/package-signing/unsigned/deep-nested.vsix"), + &input, + ) + .unwrap(); + std::fs::write(&file_list, "deep-nested.vsix\n!**/*.dll\n").unwrap(); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args(["--file-list", "files.txt"]) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output); + cmd.assert() + .success() + .stdout(predicate::str::contains("signed deep-nested.vsix")); + + let mut archive = zip::ZipArchive::new(std::fs::File::open(&output).unwrap()).unwrap(); + let mut nested = Vec::new(); + archive + .by_name("packages/with-pe.nupkg") + .unwrap() + .read_to_end(&mut nested) + .unwrap(); + std::fs::write(&nested_nupkg, nested).unwrap(); + let mut xml = Vec::new(); + archive + .by_name("package/services/digital-signature/xml-signature/psign-signature.psdsxs") + .unwrap() + .read_to_end(&mut xml) + .unwrap(); + std::fs::write(&signature_xml, xml).unwrap(); + drop(archive); + + let mut nested_info = psign(); + nested_info + .args(["portable", "nupkg-signature-info"]) + .arg(&nested_nupkg) + .assert() + .success() + .stdout(predicate::str::contains("signed=yes")); + + let mut verify_outer = psign(); + verify_outer + .args(["portable", "vsix-verify-signature-xml"]) + .arg(&output) + .args(["--signature-xml"]) + .arg(&signature_xml) + .args(["--cert"]) + .arg(&cert) + .assert() + .success() + .stdout(predicate::str::contains("signature_value_match=yes")); +} + +#[test] +fn code_signs_nested_pe_before_nupkg_before_vsix() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("deep-nested.vsix"); + let output = base.join("signed.vsix"); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + let nested_nupkg = base.join("with-pe-signed.nupkg"); + let nested_pe = base.join("tiny32.signed.dll"); + let signature_xml = base.join("signature.xml"); + write_test_rsa_cert_key(&cert, &key); + std::fs::copy( + repo_root().join("tests/fixtures/package-signing/unsigned/deep-nested.vsix"), + &input, + ) + .unwrap(); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("deep-nested.vsix"); + cmd.assert() + .success() + .stdout(predicate::str::contains("signed deep-nested.vsix")); + + let mut outer = zip::ZipArchive::new(std::fs::File::open(&output).unwrap()).unwrap(); + let mut nested = Vec::new(); + outer + .by_name("packages/with-pe.nupkg") + .unwrap() + .read_to_end(&mut nested) + .unwrap(); + std::fs::write(&nested_nupkg, nested).unwrap(); + let mut xml = Vec::new(); + outer + .by_name("package/services/digital-signature/xml-signature/psign-signature.psdsxs") + .unwrap() + .read_to_end(&mut xml) + .unwrap(); + std::fs::write(&signature_xml, xml).unwrap(); + drop(outer); + + let mut nupkg = zip::ZipArchive::new(std::fs::File::open(&nested_nupkg).unwrap()).unwrap(); + let mut pe = Vec::new(); + nupkg + .by_name("lib/net8.0/tiny32.dll") + .unwrap() + .read_to_end(&mut pe) + .unwrap(); + std::fs::write(&nested_pe, pe).unwrap(); + + let mut verify_pe = psign(); + verify_pe + .args(["portable", "verify-pe"]) + .arg(&nested_pe) + .assert() + .success(); + + let mut nested_info = psign(); + nested_info + .args(["portable", "nupkg-signature-info"]) + .arg(&nested_nupkg) + .assert() + .success() + .stdout(predicate::str::contains("signed=yes")); + + let mut verify_outer = psign(); + verify_outer + .args(["portable", "vsix-verify-signature-xml"]) + .arg(&output) + .args(["--signature-xml"]) + .arg(&signature_xml) + .args(["--cert"]) + .arg(&cert) + .assert() + .success() + .stdout(predicate::str::contains("signature_value_match=yes")); +} + +#[test] +fn code_dry_run_detects_only_navx_business_central_app_files() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + write_file(&base.join("valid.app"), b"NAVX\x00fixture"); + write_file(&base.join("not-business-central.app"), b"ZIP?"); + + let mut cmd = psign(); + let assert = cmd + .args(["code", "--dry-run", "--plan-json", "--base-directory"]) + .arg(base) + .args(["*.app"]) + .assert() + .success(); + + let json: Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); + let formats: Vec<_> = json["nodes"] + .as_array() + .expect("nodes") + .iter() + .map(|node| { + ( + node["path"].as_str().expect("path"), + node["format"].as_str().expect("format"), + ) + }) + .collect(); + assert!(formats.contains(&("valid.app", "business-central-app"))); + assert!(formats.contains(&("not-business-central.app", "unknown"))); +} + +#[test] +fn code_execution_reports_business_central_navx_gap() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + let output = base.join("signed.app"); + write_test_rsa_cert_key(&cert, &key); + write_file(&base.join("valid.app"), b"NAVX\x00fixture"); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("valid.app"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("Business Central NAVX .app")); +} + +#[test] +fn code_dry_run_classifies_clickonce_deploy_payloads() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + write_file(&base.join("app.application"), b"manifest"); + write_file(&base.join("app.exe.manifest"), b"manifest"); + write_file(&base.join("app.exe.deploy"), b"pe"); + + let mut cmd = psign(); + let assert = cmd + .args(["code", "--dry-run", "--plan-json", "--base-directory"]) + .arg(base) + .args(["**/*"]) + .assert() + .success(); + + let json: Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); + let formats: Vec<_> = json["nodes"] + .as_array() + .expect("nodes") + .iter() + .map(|node| { + ( + node["path"].as_str().expect("path"), + node["format"].as_str().expect("format"), + node["signer"].as_str().expect("signer"), + ) + }) + .collect(); + assert!(formats.contains(&( + "app.application", + "click-once-application", + "clickonce-manifest" + ))); + assert!(formats.contains(&("app.exe.manifest", "manifest", "clickonce-manifest"))); + assert!(formats.contains(&("app.exe.deploy", "deploy", "clickonce-manifest"))); +} + +#[test] +fn code_dry_run_plans_output_directory_layout() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let output = base.join("signed-output"); + write_file(&base.join("lib/a.dll"), b"pe"); + write_file(&base.join("tools/b.dll"), b"pe"); + + let mut cmd = psign(); + let assert = cmd + .args(["code", "--dry-run", "--plan-json", "--base-directory"]) + .arg(base) + .args(["--output"]) + .arg(&output) + .args(["**/*.dll"]) + .assert() + .success(); + + let json: Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); + let output_paths = plan_output_paths(&json); + let root = output.display().to_string().replace('\\', "/"); + assert_eq!( + output_paths, + [format!("{root}/lib/a.dll"), format!("{root}/tools/b.dll")] + ); +} + +#[test] +fn code_dry_run_plans_single_file_output_for_nested_container() { + let repo = repo_root(); + let input = "tests/fixtures/package-signing/unsigned/deep-nested.vsix"; + let output = repo.join("target/signed-extension.vsix"); + + let mut cmd = psign(); + let assert = cmd + .args(["code", "--dry-run", "--plan-json", "--base-directory"]) + .arg(&repo) + .args(["--output"]) + .arg(&output) + .arg(input) + .assert() + .success(); + + let json: Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); + let output_paths = plan_output_paths(&json); + let root = output.display().to_string().replace('\\', "/"); + assert!(output_paths.contains(&root)); + assert!(output_paths.iter().any(|path| { + path.ends_with("signed-extension.vsix!packages/with-pe.nupkg!lib/net8.0/tiny32.dll") + })); +} + +fn plan_paths(json: &Value) -> Vec { + let mut paths: Vec<_> = json["nodes"] + .as_array() + .expect("nodes") + .iter() + .map(|node| node["path"].as_str().expect("node path").to_owned()) + .collect(); + paths.sort(); + paths +} + +fn plan_output_paths(json: &Value) -> Vec { + let mut paths: Vec<_> = json["nodes"] + .as_array() + .expect("nodes") + .iter() + .map(|node| { + node["output_path"] + .as_str() + .expect("node output_path") + .to_owned() + }) + .collect(); + paths.sort(); + paths +} + +fn write_file(path: &Path, bytes: &[u8]) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +fn write_zip(path: &Path, entries: &[(&str, &[u8])]) { + let file = std::fs::File::create(path).unwrap(); + let mut writer = zip::ZipWriter::new(file); + let options = zip::write::FileOptions::default(); + for (name, bytes) in entries { + writer.start_file(*name, options).unwrap(); + use std::io::Write as _; + writer.write_all(bytes).unwrap(); + } + writer.finish().unwrap(); +} + +#[cfg(all(feature = "timestamp-server", feature = "timestamp-http"))] +struct PsignServerGuard(std::process::Child); + +#[cfg(all(feature = "timestamp-server", feature = "timestamp-http"))] +impl Drop for PsignServerGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +#[cfg(all(feature = "timestamp-server", feature = "timestamp-http"))] +fn spawn_timestamp_server() -> (PsignServerGuard, String) { + let mut server_cmd = std::process::Command::new(assert_cmd::cargo::cargo_bin("psign-server")); + server_cmd.args([ + "timestamp-server", + "--listen", + "127.0.0.1:0", + "--gen-time", + "20240102030405Z", + "--max-requests", + "1", + ]); + server_cmd.stdout(std::process::Stdio::piped()); + server_cmd.stderr(std::process::Stdio::piped()); + let mut guard = PsignServerGuard(server_cmd.spawn().expect("spawn psign-server")); + let stdout = guard.0.stdout.take().expect("server stdout"); + let mut reader = std::io::BufReader::new(stdout); + let mut line = String::new(); + std::io::BufRead::read_line(&mut reader, &mut line).expect("read listening line"); + let url = line + .trim() + .strip_prefix("psign-server timestamp-server listening on ") + .expect("listening URL") + .to_string(); + (guard, url) +} + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn write_test_rsa_cert_key(cert_path: &Path, key_path: &Path) { + let private_key = RsaPrivateKey::new(&mut OsRng, 2048).expect("rsa private key"); + let signing_key = SigningKey::::new(private_key.clone()); + let subject = Name::from_str("CN=psign code orchestrator test").expect("subject name"); + let spki = SubjectPublicKeyInfoOwned::from_key(signing_key.verifying_key()) + .expect("subject public key info"); + let builder = CertificateBuilder::new( + Profile::Root, + SerialNumber::from(84u32), + Validity::from_now(Duration::from_secs(86_400)).expect("validity"), + subject, + spki, + &signing_key, + ) + .expect("certificate builder"); + let cert = builder + .build::() + .expect("self-signed certificate"); + std::fs::write(cert_path, cert.to_der().expect("certificate DER")).expect("write cert"); + std::fs::write( + key_path, + private_key + .to_pkcs8_der() + .expect("PKCS#8 private key") + .as_bytes(), + ) + .expect("write key"); +} diff --git a/tests/fixture_vector_manifest.rs b/tests/fixture_vector_manifest.rs index bc2413a..13e5c02 100644 --- a/tests/fixture_vector_manifest.rs +++ b/tests/fixture_vector_manifest.rs @@ -1,5 +1,6 @@ use sha2::{Digest, Sha256}; use std::collections::HashSet; +use std::fs::File; use std::path::Path; #[test] @@ -286,8 +287,25 @@ fn package_signing_fixture_manifest_matches_files() { let entries = manifest["entries"] .as_array() .expect("package signing entries must be an array"); - assert_eq!(entries.len(), 6, "package signing fixture count"); + assert_eq!(entries.len(), 12, "package signing fixture count"); assert_hash_entries(entries); + assert_package_fixture_ids(entries); + assert_zip_contains( + "tests\\fixtures\\package-signing\\unsigned\\with-pe.nupkg", + "lib/net8.0/tiny32.dll", + ); + assert_zip_contains( + "tests\\fixtures\\package-signing\\unsigned\\nested.vsix", + "payload/tiny32.dll", + ); + assert_zip_contains( + "tests\\fixtures\\package-signing\\unsigned\\nested.vsix", + "packages/sample.nupkg", + ); + assert_zip_contains( + "tests\\fixtures\\package-signing\\unsigned\\deep-nested.vsix", + "packages/with-pe.nupkg", + ); let families: HashSet<_> = entries .iter() @@ -350,6 +368,31 @@ fn zip_authenticode_fixture_manifest_matches_files() { ); } +fn assert_package_fixture_ids(entries: &[serde_json::Value]) { + let actual: HashSet<_> = entries + .iter() + .map(|entry| entry["id"].as_str().expect("entry id").to_owned()) + .collect(); + assert_eq!( + actual, + HashSet::from([ + "package-nupkg-unsigned".to_owned(), + "package-nupkg-signed".to_owned(), + "package-snupkg-unsigned".to_owned(), + "package-snupkg-signed".to_owned(), + "package-vsix-unsigned".to_owned(), + "package-vsix-signed".to_owned(), + "package-nupkg-with-pe-unsigned".to_owned(), + "package-nupkg-with-pe-signed".to_owned(), + "package-vsix-nested-unsigned".to_owned(), + "package-vsix-nested-signed".to_owned(), + "package-vsix-deep-nested-unsigned".to_owned(), + "package-vsix-deep-nested-signed".to_owned(), + ]), + "package signing fixture IDs changed" + ); +} + fn manifest() -> serde_json::Value { serde_json::from_str(include_str!("fixtures/code-signing-vectors.json")) .expect("code-signing vector manifest JSON") @@ -383,6 +426,20 @@ fn repo_path(repo_root: &Path, rel: &str) -> std::path::PathBuf { repo_root.join(rel.replace('\\', &separator)) } +fn assert_zip_contains(rel: &str, expected_entry: &str) { + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")); + let path = repo_path(repo_root, rel); + let file = File::open(&path).unwrap_or_else(|e| panic!("open {}: {e}", path.display())); + let archive = + zip::ZipArchive::new(file).unwrap_or_else(|e| panic!("open ZIP {}: {e}", path.display())); + let found = archive.file_names().any(|name| name == expected_entry); + assert!( + found, + "{} should contain ZIP entry {expected_entry}", + path.display() + ); +} + fn matrix_group<'a>(manifest: &'a serde_json::Value, id: &str) -> &'a serde_json::Value { manifest["matrix_groups"] .as_array() diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 7b5ab92..9b04e0b 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -31,6 +31,10 @@ Package-signing fixtures live under `tests/fixtures/package-signing/`: - `unsigned/sample.vsix` and `signed/sample.signed.vsix` exercise OPC XMLDSig marker paths produced by `System.IO.Packaging.PackageDigitalSignatureManager`. +- `unsigned/with-pe.nupkg`, `unsigned/nested.vsix`, and + `unsigned/deep-nested.vsix` add nested signable payload coverage for the + dotnet/sign-style inside-out orchestration work. Signed counterparts are + reference outputs from the same tools above. - `package-signing-fixtures.json` records sizes and SHA-256 hashes. Regenerate them on Windows with the Devolutions test signing PFX: diff --git a/tests/fixtures/package-signing/package-signing-fixtures.json b/tests/fixtures/package-signing/package-signing-fixtures.json index 98d9ad6..3f60022 100644 --- a/tests/fixtures/package-signing/package-signing-fixtures.json +++ b/tests/fixtures/package-signing/package-signing-fixtures.json @@ -8,16 +8,16 @@ "family": "nuget", "state": "unsigned", "path": "tests\\fixtures\\package-signing\\unsigned\\sample.nupkg", - "size_bytes": 823, - "sha256": "70e2869457b252c9d27048cf37387880d44edf7d021446357c4cefd9f3567c9c" + "size_bytes": 833, + "sha256": "68752c16d37b134decd721c2bc12d857b451db851877d0055893d9f152293204" }, { "id": "package-nupkg-signed", "family": "nuget", "state": "signed", "path": "tests\\fixtures\\package-signing\\signed\\sample.signed.nupkg", - "size_bytes": 3354, - "sha256": "a6cc20cbb95314aadc396b14ebfa0acac869c94964a6367918e97ccab0e8e7a3", + "size_bytes": 3364, + "sha256": "d6c47dcba78f63c6e22b7230d0477979e24e65490b97a02dba0fd2d6e3794e71", "source_path": "tests\\fixtures\\package-signing\\unsigned\\sample.nupkg", "tool": "dotnet nuget sign" }, @@ -26,16 +26,16 @@ "family": "nuget-symbols", "state": "unsigned", "path": "tests\\fixtures\\package-signing\\unsigned\\sample.snupkg", - "size_bytes": 823, - "sha256": "70e2869457b252c9d27048cf37387880d44edf7d021446357c4cefd9f3567c9c" + "size_bytes": 833, + "sha256": "68752c16d37b134decd721c2bc12d857b451db851877d0055893d9f152293204" }, { "id": "package-snupkg-signed", "family": "nuget-symbols", "state": "signed", "path": "tests\\fixtures\\package-signing\\signed\\sample.signed.snupkg", - "size_bytes": 3354, - "sha256": "a6cc20cbb95314aadc396b14ebfa0acac869c94964a6367918e97ccab0e8e7a3", + "size_bytes": 3364, + "sha256": "d6c47dcba78f63c6e22b7230d0477979e24e65490b97a02dba0fd2d6e3794e71", "source_path": "tests\\fixtures\\package-signing\\unsigned\\sample.snupkg", "tool": "dotnet nuget sign" }, @@ -44,18 +44,72 @@ "family": "vsix", "state": "unsigned", "path": "tests\\fixtures\\package-signing\\unsigned\\sample.vsix", - "size_bytes": 1185, - "sha256": "19c5872702ed93f751b060280541452d2666e8d56654c68c2bde6a0fc2517f89" + "size_bytes": 1208, + "sha256": "a77fe652d2b5a82c74c7a3b962872a0b553607181f84f3fd88a89b3099f9b6ed" }, { "id": "package-vsix-signed", "family": "vsix", "state": "signed", "path": "tests\\fixtures\\package-signing\\signed\\sample.signed.vsix", - "size_bytes": 7080, - "sha256": "92ca6a002a6f37f60087fcb9b2de2cfa8979d229a97d968423802b72aa4cee65", + "size_bytes": 7107, + "sha256": "2ce5518fd9e9cd7e8785b67d20884be276e4e556e3f8327e40bf3db9109c1a01", "source_path": "tests\\fixtures\\package-signing\\unsigned\\sample.vsix", "tool": "System.IO.Packaging.PackageDigitalSignatureManager" + }, + { + "id": "package-nupkg-with-pe-unsigned", + "family": "nuget", + "state": "unsigned", + "path": "tests\\fixtures\\package-signing\\unsigned\\with-pe.nupkg", + "size_bytes": 1272, + "sha256": "7cec10be4cd8d10321c1937a2f7cafb3a991bae3f7b4b89c86984482b3ce7691" + }, + { + "id": "package-nupkg-with-pe-signed", + "family": "nuget", + "state": "signed", + "path": "tests\\fixtures\\package-signing\\signed\\with-pe.signed.nupkg", + "size_bytes": 3803, + "sha256": "0bae954abf8c0c915fab6971a69238690702a966ab188711998854312d452be0", + "source_path": "tests\\fixtures\\package-signing\\unsigned\\with-pe.nupkg", + "tool": "dotnet nuget sign" + }, + { + "id": "package-vsix-nested-unsigned", + "family": "vsix", + "state": "unsigned", + "path": "tests\\fixtures\\package-signing\\unsigned\\nested.vsix", + "size_bytes": 2487, + "sha256": "9ba06447e30a4ea1eb9e51a5f5c3c1e14d6b8ca6f706d7170ae477e1c67d2708" + }, + { + "id": "package-vsix-nested-signed", + "family": "vsix", + "state": "signed", + "path": "tests\\fixtures\\package-signing\\signed\\nested.signed.vsix", + "size_bytes": 8828, + "sha256": "0e8f1ea4925d97c8145823e0c088a859f2095e054baa5a3438f5788b8a6f20e7", + "source_path": "tests\\fixtures\\package-signing\\unsigned\\nested.vsix", + "tool": "System.IO.Packaging.PackageDigitalSignatureManager" + }, + { + "id": "package-vsix-deep-nested-unsigned", + "family": "vsix", + "state": "unsigned", + "path": "tests\\fixtures\\package-signing\\unsigned\\deep-nested.vsix", + "size_bytes": 2403, + "sha256": "94eca6546bb3fec63b3c6fd581cc3599d73164e8908e404830ef925ecb560602" + }, + { + "id": "package-vsix-deep-nested-signed", + "family": "vsix", + "state": "signed", + "path": "tests\\fixtures\\package-signing\\signed\\deep-nested.signed.vsix", + "size_bytes": 8520, + "sha256": "708fd26aef70b76b1d09301cd0730e850aa896087c3f47dc62fe38443e9fca7d", + "source_path": "tests\\fixtures\\package-signing\\unsigned\\deep-nested.vsix", + "tool": "System.IO.Packaging.PackageDigitalSignatureManager" } ] } diff --git a/tests/fixtures/package-signing/signed/deep-nested.signed.vsix b/tests/fixtures/package-signing/signed/deep-nested.signed.vsix new file mode 100644 index 0000000000000000000000000000000000000000..389cec9375c748b3c4ab70ea05d923fff0f03f4d GIT binary patch literal 8520 zcmeHNd2|$2)(-?(ByPA77$F2AGEGv|Yq~=xQq}vuud<}Nt9sw7sy87j=qMl<5YQls z%m5k$6%jB3Mnym%vIq&X2|^H*U1X6ZPIUs=KE{KN{`F0rQ#noISy@>bg|2Ba)A3*MXEd&92oU$XvDXQeM{ zxGVVq;W{BcU%0nn%9efyPfm(;ZAwgyo*%ej)t(RE)d(Dd+L)L4}al$zSp~r!7WR=E_rz>I&t9e$IDr4&w%^$77RY?+ui@zHx;Xz z$fI{fPVB3q&0I5Ix%KGd4fT5m=N_wD@!f$v%VmYz`)>Ju&e?AroRFku88oif_<3s= zo>vKhqeFG$hMy6gL$*Sjeic`5dtsziW!N~OblzZ3)6ClJ(F2}dzI49&{1nN|+CuW3 zo}VAP+;I2h=@m%f!}3qAj;tT3?SRoW{e$p>6gKx$cW>&BAgMqYL5qkaL3?bga+{%N z?~5;LrCXLfHmp#7v};z|=wB#=hly(c)~j1q)@AC>ARIxQe#DL8K*CFS3L{uih>ZB% zz0bN__aWuwhsrNkeazl)Y5n!WxBB+&`?tQieHY~R?b~%SbLaV=yY_i``TaW$2N*QD ze2V_UPx2|f^)HXVzIOTD{d?}m0LNIX-`==ZJLISD8xC{@xK9|>w9(GT{pXHs+%a{{ z^!TO2&bO)pfo-pxt*}(TJ0q01s_QfF)U~?F2VeTaf6DAyTeBrrF-P{}Yx4#Sx|$38 zyhk?K^X;AcKK<8>BQ=AVRc9uC8*1uxe&~YtYId*x`;*seA|IF+?x^WCOTH17SFFfg z^uV|8E`GAnc&*AmeZ}VRORMM3QOtSu;L@t9>UB@_>pwi-P+7M7di?9J-x;`Q9(~W{ zE89+}_gp|ebN5UAYj^i?Nz?q@oa<;q)5Bv=WosvlEVGYj8kC(qG3);9iC}iN;6uD( zjLbJiHcpB`zUY!MqR`g;Q+iOQpJnlvjJaGkU%WEB{3N~Cw#EGqezQ1tpr6>f>*&RX zHHp;+>+Hnd`H5GW@G(`d)nAzWR{by5xoh|AeZTwaiVer2C#Je4@0MR!QG1oYdeAOQ z`0#|KWVX680>fbNUuIX!jn^v@P{HXbSW<~X)Pt!+AhrhJfU3ll!?+?0m zj5ss!>b6(2y9~9BsyaPvcb>>+T*G1 zMHhD8^)q*^SYX(FV%YuJpTGIZ%%ybyLswVx7!zuacb|oRZ|c|ohlP!e<4%vBRarTs zva<5{r<*tKJpAS6x;p-^FTFAPwbOHn9kGdHaX6)ZV|%Zi+qp;IJ3~IYYu%Z;A9h}O zX26*F{p{|Enz_A-E`K>Net1dkvHC+_belF|*R%Ib8YX+NxG-Sbd78ewy5?xjo@L9b zmqp*FGkhPOYAl&CduHYK{bb(6sWqbU&p)f&1-(15wvY1B+#;kLbH0o|x&EWX*RzTS zaaK;qeg0S0rt60efA`&zSp7-ksmAYi+*Q86e8u3==SCixmNTqjea?>$vAy}F!*a&d z^?z{)JZ%Bxp1B?_=G@S*G7m|^ha2uWvEbFj6k&AYq4r*OiZXI)tKuyUjvxwjlG|LBVx{XOlH zM`n5G@^ub&o=O^x)Dn?NJ!4~700)XPe`ifs`PNZu!h882uG(N$fCcJ?J^vUra{9)f zv?qE@b?jeG?ihUG&V^46S@gi(3u`W&EB*e$FWBm_*M_+2lyu?)-HHeL22Kw9NIhn= zc15gq-+(CNIme;teWt9}JTrnfd&YpQZ>Al;>z!A@;T8QH7Z<%hc@M<6xP#km^zQy& zuF9!#P2Oqve4tRnkxUclIO*fj4Z|L-1JCQnyGHI-Eq^q-=#F()=Wc#&WOi)&Vf2G_ zUvE00`%1Zm9XUQC6gh&e1#m1D_d2Nri+bH& z0tpo2UUvu~$QTBMV^mY9D1xJSIMz{G3@@`KC@*L35w|C;1&j_dn~^!Qx|aWVG8o8< zW3kLqUYbuY0`v2*kTZ;WL+;Xil5iDr^B*tc>#+brP(5&uH-hI;Lqd3IzK0+pB>;ds zJy;OIi^35sM166EV?l(VUSn?HrlkxJI4m43tdlo9Qq;BeS1S1}uNaU5(GhBy!=2s&9@x`Ri@Fg6cq1}TZ>=gH90d_C7m zM^HKsVKE&ji|69x=K*DWpo9C$I^wySy>|XWYQ@gn*4xAL4=H$?VCUfk5<-zcI7H!* z!tf0GhMRHOY-8)23^gMG63g!t4~aygD8X#TpTZpkKpsFTj8b3>rclp}#!y+v6)xio zkWe_}bs_<8x;Zjb7~u({c~HPj#VX+mwgyBykw!g0Nu>jNk)SZ+2Pd5sD$KNiOgg*S z+pYTDj<{x7AnT@vg~4zP8yv%tLfnHe*e&qsF&7r2CIyyf(#uNo2YXR%OKGHW_%J6y z1y}pvZKrEz^KbMo;|sl1T}Av({U{9vx6`UcMl%9s?l9fT;K_Jqngyel(l~%AZBaeS zFr^uKJ*+T#+$ke&H9()2u*5TG&a7BV7rD;XR-;Y>u*kbopGe=@A$CSB0zRzJRNf+xJ;%v&0xm^K2bOrqp>;S6f4Tp2ZeH!1la+BO-Zt0NX!-4Tz->^rAYgT zVBCZT5;oB5N&qqvbrOD2S}KhgMG*~3+U-mM&f&PxbT}-=c^(qCON>r}X%7V=7OoVs zvm7a!5=rR29vO_7J+XvYEQ`bqLZ;s)O3IX^Q=yF~SrCtw1YDTP1s40jFzobklxn#> zC389@1gVIz0%?fub0j@bDFsI-NAnd}s%3gm4{9Wcl?l|PMk-wL1Ys1*%k-vlbDjW; z5ndNHc?gUT-0Ybd+X5F~Z-ClfRAH11ka$_xr83EqUYFI7bgI~CFlZO^I1URu&nf0QV!;F)472EZdn#d|L1w_|(@A-V$8B&)-3+`~ zBu#NuDw9D>$WeO)aj~^gnI^*H7yujKGLxXx=%)wNCQI6FK`|~6C1W1BQe_s4>7-39 z;u)9#Bbd}>K)mT;z9<=?QfG)LgLDwg2U=gLiPj#G3}iQuva&&*5FwB&J+xzR(;rRVN>twxLsm$n{gp$NTrgR^#LbS=hiWJe!BrSA{LZH zEbNd>qz*Vk`iMOk@Y$^T0A(92^L5r+B!(c30Mc=x%y)rXK|PD0|Ap;e5w`Hn#k~$HmdoOZT*2g8WHwkJC-FF>1qh!zX%qx- zwlyKf8M+Y8Wh%YKBvZu{^I(oJpa-HHy~sy60%8XXHjz3HiwDPiq@3;6#<@}r?pJGl z8lOEz(l{x#oAlx;Fq5Oa0a}W&3%@WvTTAw`ajf#C*LdT`Df>xDs#I@G261VDo zl0XDtTT-NorO_lvoeng(SaDJuSKx#e0S zt&0%|ff7QSi6Al}elV2e~CE4CpFBn2A0R%6nFq9&`HPUy{qko4%- zupltS;609OFIIhgDvgBiHFcmyVk-R@MgBhZJSkEON1I@NC?ktZ}BpI>+^rR zTLD@nQBwcxIpE|daF#iq=0;!*NoU{^rZ}Z>%f)bvDo>qEnk{DW`qBoTpZ7=Rz;6*4 z|A}(=|BFS0zF85)tRm#-ao9^G5(c zJM&Y8Eh7JdY``iOigXblR|*>t8eo(KEi|@VfRQ>(?+>!os)*cTmoPmBQvAET^uKIC zVr73)HUQdI{MN0jHb$|3)P@wJl33@))Q!TSwcY3qQl!yMMs9Fx`%ONnorp}=PW004 z(vANoXsKuwH*=F^HtH%=sS1TcqsLPc43^+{5pJ{#`M`}1{Fbd517vUtL3Sa7Zlp6x zSj8o5b`i4>WR-v*AGoo5YumnObiKi$G?TbDe3H6FTeF{lma?k-=T>U4XESm9Vdc{w z-$Bv5?^0*3nKQj7Zj?@AGqn>wx``yX=3u_ltep zj`}0HQ(yNT@(t(qv5XAr9$xsvKNX5!e;s=D>ai2656m1{fA|p>DyPH*u^%y>QZ=eT`R^o-ZG0en+xT zVlH1w&O;hb(OwKay7b%0T?a19l`1d99yo1#Jv!{Zi)#7@2hK&r)xY*sbuaOtpAShC zR92r9eW8B8+eYq$dox5QL^Gq_gnZsN?bp0nR428bi+?g%jIwGjQDmA_#}M2w^q@m>jPQ;`K+39 zUAybnWoc&_ozGI+1T3HsX?L>Pe&DT+QQHO{ZXS56L)G>&ZgrZ{CSzdt4reQE18;rQ z&^B;Hk3VwoaMQ0X(mNkLv`L?Id(!_?RnXat&YXDL57~O%mKhml7{AF{1Gh=xw`pDQ(O@yIPFqgHVT1yANbCVFPQK+W0312S*_2KB2>F8}}l literal 0 HcmV?d00001 diff --git a/tests/fixtures/package-signing/signed/nested.signed.vsix b/tests/fixtures/package-signing/signed/nested.signed.vsix new file mode 100644 index 0000000000000000000000000000000000000000..91d881ad1515b56b6d1190e32ab847ace4785856 GIT binary patch literal 8828 zcmeHMd3Y36wr_|80?`=}nFM?gf?=^qs(MW~b|O{1Rwvc_E<&ois`sv5s(KYrM44ez zL_nipL^gwaXh7y{GOuzjIHW z`#ZX-ocnsPSgbCr24dy#F$nAI#11Uh_Kqx8Po|Yf(F95P!em*3@})u;>2nhl9iOJ1 zbP)XGhO=Wwb)NI&oR@#Nw2OFo?ex;pvF<#)ch?H^^s@ezTjwTAub-}(C3@q`7spf= z$6P##oc!mvvqd#Z;duDuW3P0SJ{;(M0KRf)$eH6(ab4~GCScdUdOa7KYWrcp76UPG z_wF^rUO2>C`1F$c19LkY4qKj@_m8EE-rgtZvunfivnzK+qi1$+8?uuS*X3b*H|uiN z+ww4rAooMbU(8Q!zJI^)ok>%|S2mCS^MySdzrEi1`49ixv)cshJjLFy;MTDrrrBuj*5g*)Rp|FCP_9fUpL_66<>`z7#n%; zoj!A~j$SG`B)jzZt;Ne<`f2vo%4%`J4RZQV@blFt(%;+u@=d>gKBHW+EIqk$!?R=O z|ATe5TWw9`LH`b6e%;JPi8K836&LDny*MiUrS-IJ+JJLg=5!sM@d2^9z?qZ0xkpBA zxd7)7YikmB~8Qw#9=6m#R;6WVMX` ziNT(0+ZdV7;11=mSXn6%%oV^q1O-r7C=w*f$ao~+>2}WTeh{mvIa>2$?P|fss~c{V zE-WZ0c%-1PU|wNCLC1-_FE3u|*!`6i4}EF;n#)$JCmAkZP*3V+cxCjBwJRRz-Q^Gg zI7iw7j^k@}k6t*r_v?;;=t+}?{gms8!1-gFc2D{E<;2y0xfa$2gF9b6H^y53-ZV0K zP2aul^!4o%kG%9*;IzfPcGb4nnAef-->B=;?^+>nX+JX2d${YtPv=fMwyGbm_UyRB zP@hE%*w(AJr2LO=;`C8>$QQGS8ffzv}V@p zRjM&gSv-tD`fjBX1|=Aajb`O#(_CG(hsOHBM|=xWR!}4{L#fD6+dwDb}s6D z#D#l)vdvn%|G?7RHDfk@6FoJ>J#nA<^2$%HN!IlH${P9RGWp_K{lZhd>V6Py>HeT( z;pQ*Ho3?xv(%!7D8@-D>b#TYX5_?X@Id9RB;|o4s{i@^ml}Ew9Zd++x^~JTv zuFPCjV0njK_TlK^b0oYm^$R}D9IF`e(g9Ct*J~$_xOYE(w(qr_ujOq~lOavL^GSk*~fpDB5`wo3a3ti966Vl^$x$-)^R=8|q;pz_Gz6rDR10ETXf6h(h9Q^dt?U9*d$M$fcBAT9=8k+1}{!oR8xc3)YM?ZIz5p8Jk%*C zC#UXuS>^cBS)PZJV8bkP{+7){EQ1DZ)dK46W|e09wrzl3r8W&eVQUMJM;+oZJBlQc;Xe&XnEQQ)wbQ&NvIdGqxqD2;S?Zjn+yc{QI0%B9pWP}E=8k`>=G7W(<+=BI z_tjRPKOfb1iA2B1nek*p$*N8b8Ricv!O}hFG?Yw`e^+19Q0wmLUOhj(KmF5*^y>Nb zzS;(Py|{kDhWduV^_?~}l+>nYtoEJUvOqC@g3q@=fozKF%0D8+As;h#&=&W&dZqjQZDue|+Kn;t&41iHi&>(d^wne)zB%imkd+ zou@btt%&dLf4S@YXC7V9ydd5;@Kz#c&| z_Q}CM(XVD0j`N6~R6+kgTo@i$*SUt;_|_`4@Yo}YSJk^_95hMrP@k2`Z`Y7HCua70 zwK|&F`s28Q&C@^IvS;r+R?mTt7&mr&Xy*=3ZiA-2q%iVc!I5wHkTSN}Jn@yXKBtP_ z-;{8^{{Gl;|8rcAba>``!#=~(2XV-ipc_I}G7A1&S z_WNE@%qatliwM#c#(kuxqBu^wOGU*`R7wm)5TltMl-C!bikKlJRZ;Av>Bvw3pj=)e zgi&ST2thJW+~HUVqnX>72e_jt7X*a>;|nb^irdO8GNmKLeV{4~i*1G+1kdOZc>!N4igKL}KIBBjXoTZe0qhj1Eys+e0L3GW zq}2m~T$$SlhO09=ww4V-h|?M}222oJsDefOpu?n9rvgNk%V@z|7Hs)%&c@?b7BScs^UYAQ^OyA(;oI8l`C&BW#+DG{T6 zZf5b&gao+LGds2kE{VYiwcMa#JRXcwm0>q(Mp8bv&6skbf(#gPD8xdim95vIE-QpU za=nJ@X7drZC8pMB!W>fRj9AGy8q>xgnr-EnV=liu<`o-3VIq!2MPhkIoRkLGO4J{& zX2YbxC`)RPnBD54_^6d17ie&=+TlY*D!Tzkg*+u3NYDu%mI5Ii!GkPvIH0wv;}Lbn z?5a{r^=u!hm8WckHxbq<(_zRa$K8%9zXvRj#uN}u`w3W&W1J|D<^!RGLmTqixXyCF zLB|o4Q$P?m=wtzx$Z3kFWqy}JT1-YP;laQja+Qx z0o+hZp9S%bg(dP-gbAG_T?y$SSOPTPG7GIWB3a08BV`kSVi`tbm6GgLD_kp41d$nJ zQYqw1d!Qr&Lmou0rqjW+8vzlALaXqoU_PXaR?8p_>_03dG{$K^RIVr5-y{m9)dK-mHR>Xj0Ym%n!q!q#_KN***#n zI{aRlMq~w55x}TSB!xjZWKvN{mA+Aq7KZI|MK!7AvZ*8(PQ=Xumm3X;Lu`)M$_ZEu zC61iQec6UDWg83Q#q}!a!0j37AN%{wT{j(A3==9 zX_?(Dmd1T{Os=xJt7JrqLs|pO27qlQ)#Y|TDhg`wa@0phgz^|}@TnZW02_7qby1f* z9e0MEhH?|=CuKSfn!*Wf7_=KbsRTcu>soln~J7X2g{gx_? zPXe@$I87~0(I^|Dnm$BLc}rvR*FN{Dl%Tiyz-&U_CQkzH&Ko`<4U-I?NH-=;G}9-W zdohXOZ=Mr{LN+x!>QV;eLRc-AD#M{nxmt~vTSRh;nP7sVTLcS0hfrhxcR5jerS3|I zwb2QSM1tAxGP7L*GQP;~pcz63lieUL!%@QQuZdYD<+kLa!|CY@-yJ-cQVi-wfx9?P?r1|)%XIL@^TtNGQAkbsv!bRntAX(Rb@6XJEo%v{RzOKf|G zw)i(3!Ou}D|9`Q-WiGamz|#sdH|P;XNz5OlL^8^Na!FT8Oc4QLT7^1dI*1+kjj?D$ zZ2cMl@b>&$X_Ls`AsX<@WpaJQFH*rqj18EOkd-Y^ONqFiFa$yZ4H{8f9ZH_p7+08o ziIx5v4dO)QZxjuHmW-x(Yp#ni>?X6}CYT`BzA=3}F=}o%`9cgSc8787-`swOFKZQ{_CNp`xLqnmV6jer<95M-TyMv@@>&XSV!ctIB z%H^0i+@bvPp#nh}w-n?L1wjdLdw1GGPh{tMnR~|55)CQFrUGkp55ezk9o?>ej7+*RFkYYR%WvhwXV_dCl?950$fx z9j-=)U71}QUtG7{{q?$M9-X@AKIeDa=e_>sk{M&3J34Pd-beTKay_7#KVkpD;|)tL z*7UW!t30T*)GUeDVS7)rCzAt~9G=**@3KNw_2pQPGxpih!4F>1aF&04KBB0<)dkHR z>c#*5XtHE#{SWfbG)wQ>Bzo@KLiL^Szidd!xmh3H zRBs%(pd>I2a;`i5R!+vGT=|w~AvJ!e`_i^aXY407UbwkJ{nnQe|CfE%g>Q7zRdwjt zgZ1+Tdd76Ej=QJb?Patt6SoLh#~{+GXxw_>-73T_1Fts@yi2{fwTQb_=UPOpzpwe- ztx~tvvPw&tgGt8z{6tYF~3dBlCSHVz58F;`-r&! literal 0 HcmV?d00001 diff --git a/tests/fixtures/package-signing/signed/sample.signed.nupkg b/tests/fixtures/package-signing/signed/sample.signed.nupkg index f358c38fcde6f7cb43279d71e227543552655fc0..a9902ab8ceb83aed96fdb134a022a7d2338221d6 100644 GIT binary patch delta 792 zcmbOwwM2?Hz?+#xgn@y9gW+fD)`h%`LO?1;K3Y5eAy80&iGe|wVR8YFX#L!a2eS?v z@H9NEUidztMz(LW>@f+?svF?^bk5Y}_SU?2yWh-OblRbJm;Q5JnVZ%1il^22 zSe(7t+OP(@e#Yl<>y2iL*Gl!ZzxzD9A=FoJ=k;!my-}y@8lUVq$r2Gfr^wT9$H~-f zPyS4web8>}+s$7kSp#}vq^d0Bro6gWpIDSF@->jP;4rt#wO6^)`zrR9xfoWcD=xNu zT%kNOeyOIwdgDDTkJ@J~3j5~YX3;zEAN$Xk0|6&lT$%1HU2>{dIpAne3FG%~$0W+% zosM(h@n)R2iy<9_j5Tyyu@Rs3K?35ghXmSvB{fI$EX8&L*^$r?;%9KV1;;~mVw zFgcB>T07ArwAii0&)n6(xY)4L!@11KCo;@DD9FepG&04~(JkC7Aj%@lv%oh!a5E3{ z4>k@XLqjtYb5rBVJX}T!$M&Xuz3cO(Z^ zam}qSnfzv3wdL2`z)L5-Fx1Mvxyy1g(P~@Wo za9!%Teya7Gg#RJ&jVm7RHJly8cJo(cq|gM$uROjMR*x8yj9HgynfZ4dXnR$_wC0pi zv>{vWR<;eF-umy0DecNE4z0JlzPUlaRV1XUxZ#=V_WG)Gzd!uhd1Tt}y+Y^TeLqou z@Q#G*Eg>m`GbP{l=E==gxoyUAnr-gyh}grLmoKh~;fQ<0$aMPP)8C!k3K}As4R!B> z7kHSKS+3vkt@!AIeHMTJXV-bKe!AxBtn-vtBj%cfkCTEYw~5^CgWP3@c32%%;g!gn z>-$Ecxp&{1NmF^I3K|3jcr!AIfa3t1J|}bWxbdMT(8+N;#_}K`vA1; delta 779 zcmZ1?HA{*&z?+#xgn@y9gCW3e-9lbQJ|GqIyY`yob)euW#>vu*()Es6hYbW+FVrfO z3)~Wm&J$lMayxR#UPpa}CmWTu&hpGE49JfcDi_V*y8Hj<`@>(_XHB+rp6Fq5kCvudl1qEDK zyRiKF^`84EK96B%S@u{A=w(n?hyrm=W|DqhYKeuOf&OGWCM}LYV6bde=gFH~z*McB z?CqT>O~DC&jP8vaBLx-7d#_mnLQ2PD+iPoWV7>UP$}( zylhLSdB0^R`=lSa5ptjNT1)4uN0;Yhi!PYLuj%~fK*8iQ_eB3hXl)9-UoX1FXN62& zRzXo_mf#e&$vf}YHMjZ&mu{|aYB{;(jojSB?ZInxRxCWaaQ#Wv*$%3+QZ=HsUY&8} zUtP|+UF}Dof1j{b^5&7Z=bl;jCT`wYRI}h){k~<|9`?(M%Bubu^PiSERw}tdd*&U6 z|LIbfudU5oskOiB(mdmx7p($(Ld37AP2|nn$gTXjSb}@Av3 zFTcoOuXL`cvpVgwv~{a&VtU@b(7bQe%dP*t*%`H0r?qdl#HE*xi!Sfh3o6JjpSIWa z$JykXg%1S#(gVC1nMA diff --git a/tests/fixtures/package-signing/signed/sample.signed.snupkg b/tests/fixtures/package-signing/signed/sample.signed.snupkg index f358c38fcde6f7cb43279d71e227543552655fc0..a9902ab8ceb83aed96fdb134a022a7d2338221d6 100644 GIT binary patch delta 792 zcmbOwwM2?Hz?+#xgn@y9gW+fD)`h%`LO?1;K3Y5eAy80&iGe|wVR8YFX#L!a2eS?v z@H9NEUidztMz(LW>@f+?svF?^bk5Y}_SU?2yWh-OblRbJm;Q5JnVZ%1il^22 zSe(7t+OP(@e#Yl<>y2iL*Gl!ZzxzD9A=FoJ=k;!my-}y@8lUVq$r2Gfr^wT9$H~-f zPyS4web8>}+s$7kSp#}vq^d0Bro6gWpIDSF@->jP;4rt#wO6^)`zrR9xfoWcD=xNu zT%kNOeyOIwdgDDTkJ@J~3j5~YX3;zEAN$Xk0|6&lT$%1HU2>{dIpAne3FG%~$0W+% zosM(h@n)R2iy<9_j5Tyyu@Rs3K?35ghXmSvB{fI$EX8&L*^$r?;%9KV1;;~mVw zFgcB>T07ArwAii0&)n6(xY)4L!@11KCo;@DD9FepG&04~(JkC7Aj%@lv%oh!a5E3{ z4>k@XLqjtYb5rBVJX}T!$M&Xuz3cO(Z^ zam}qSnfzv3wdL2`z)L5-Fx1Mvxyy1g(P~@Wo za9!%Teya7Gg#RJ&jVm7RHJly8cJo(cq|gM$uROjMR*x8yj9HgynfZ4dXnR$_wC0pi zv>{vWR<;eF-umy0DecNE4z0JlzPUlaRV1XUxZ#=V_WG)Gzd!uhd1Tt}y+Y^TeLqou z@Q#G*Eg>m`GbP{l=E==gxoyUAnr-gyh}grLmoKh~;fQ<0$aMPP)8C!k3K}As4R!B> z7kHSKS+3vkt@!AIeHMTJXV-bKe!AxBtn-vtBj%cfkCTEYw~5^CgWP3@c32%%;g!gn z>-$Ecxp&{1NmF^I3K|3jcr!AIfa3t1J|}bWxbdMT(8+N;#_}K`vA1; delta 779 zcmZ1?HA{*&z?+#xgn@y9gCW3e-9lbQJ|GqIyY`yob)euW#>vu*()Es6hYbW+FVrfO z3)~Wm&J$lMayxR#UPpa}CmWTu&hpGE49JfcDi_V*y8Hj<`@>(_XHB+rp6Fq5kCvudl1qEDK zyRiKF^`84EK96B%S@u{A=w(n?hyrm=W|DqhYKeuOf&OGWCM}LYV6bde=gFH~z*McB z?CqT>O~DC&jP8vaBLx-7d#_mnLQ2PD+iPoWV7>UP$}( zylhLSdB0^R`=lSa5ptjNT1)4uN0;Yhi!PYLuj%~fK*8iQ_eB3hXl)9-UoX1FXN62& zRzXo_mf#e&$vf}YHMjZ&mu{|aYB{;(jojSB?ZInxRxCWaaQ#Wv*$%3+QZ=HsUY&8} zUtP|+UF}Dof1j{b^5&7Z=bl;jCT`wYRI}h){k~<|9`?(M%Bubu^PiSERw}tdd*&U6 z|LIbfudU5oskOiB(mdmx7p($(Ld37AP2|nn$gTXjSb}@Av3 zFTcoOuXL`cvpVgwv~{a&VtU@b(7bQe%dP*t*%`H0r?qdl#HE*xi!Sfh3o6JjpSIWa z$JykXg%1S#(gVC1nMA diff --git a/tests/fixtures/package-signing/signed/sample.signed.vsix b/tests/fixtures/package-signing/signed/sample.signed.vsix index 3c312479bd3d75d5ab71e8842b9c7d97ed1288dd..821b8edbff436dbacbb9cef0e4b2c1c93b5a0bf1 100644 GIT binary patch delta 1586 zcmb7^drVVz6vx|w2r5hw9~B460H>GO-pg%oi_uU@TS^~LTKdZ7Y4gNXz+X4I3bsf!Xu{c8=IhP?O4PAa|1(axOw-hqt6 zM}$dkawA7aq9pX(Ux1Cn8^UF_Gj}eWco04_u(@l0t3nzMS9{En z!zqKuekPAZzP#L^KiOB=e4-nju(c#bmx6@cSLP2qq zdRA`P#R;R}k=r5FROJ_OX+!E8&iVa}r6c+Yi*-L(n!-)Uqq@lj8Is|SYsb36+1GA9 zSYLDASM)|3u`d+4hetMd9qjJ>lb>$+`nNXflh#luLuP6Vd5I>OGBnx~WOsEVL8VZl zwurn*%O;1CXEv{h$)YYnj3~n7LPn4cv0#{kax4&nvW-UA%w!uNj%6X{Lw!MC#vC!a zIHL?Uu@Mx4*##hBk9HgWmv$`EK(c-;bm>+^dt7Jw>UK%qrqHk9i;I~ zh23n1rCOu_Buq?~o1=H*xk8X|0Luj;2$b3QBCm_j6chwyyj&RoC=d{WI4UThXZb;e z#s*n!E|r17)-fToKOihIRe)X%CdbOkwIWS9hX*nF9!+6@>sR6BgbV~kJ_&)8$<$tq zkPCv!Ad0JSFNBvn{D`2$S-`NGWg@?|c{qMm0#G0)d?450)(V35QYXt(3QC0rkyDoo zz$~22Bm|H#B&d`_6%v9hos!dhHknOb07)usqJYiq3>bw)Tu2G|`EHgD*4vfEO0y1w ziV7evQ5IC{tkFB<+2mK6SEt0&{+Adw{$=va^jnD#r~;up7|CP73t43E3~OR^)(_XN zJUVW4&i9kznWH|Fge*MRRrfsJzeQi~lwV%csub$fZ z$=1k6Z&&y4T$9imJIj7 zrXW+?@J^%2WOK=U&8b_55`#;oCWMKa`Pc-fb57?JADHQ!L)i+6WBbpYBo0Hk(~?*$Rx}G!9sE2lz~)9W)6UM1iR6mi;?Z-DnbT}u2tCP?k<;85A|){&wQ62_VS&J#l>I_qHTAEjw;W|_{d`S7bf!Irf{51bEAt=qAv z4HfU-#(BGA#q=u!Uf%xBA08fw@*Trsm+P$Hn&e)_w&+fJ>zNIe;df$A-n$wf{(ik- zaPAiIyTch@`j59YpLq}24X?}&J(oSU*WZMb93Qu*FYtjxTgUCYLN0X`LIl?<_4+V_hIg#l{Ot78i`Sl14WGH&@JSt7`(oFujjQipW4){li-kuj@-qiXh~$di<{@8z z&0w*((W=NI=3;p%@lAAksltL90|`g(28x6+w1Tw2b!Jy^Y?C z`C9vm3>`!W99Ahw!h8^hq=bOEJ}rIHLGQdrKMNQ1-wpg}ZR4PvuF)qY%1s}CSHkJKb0&?1iwHwLSmjc%7+3F|C+A>II3J#L+X z)=R4q8zC3sX0yaZyX;7HK!j=SR(-C@fa91tsBx7@sw_H^w%F@TNU0cCTHThA*5X4+ zqfbGr3-uyOPf`vKA-2@en%p2?iz(Hm1ZMVNHDCelgo~OK#RjFs0{aNMR%WyT^5Qy| zmk%2X3`DLJMEP1RrEXH!n7nR2jiXkzjy7o_K+z=ANCCHlP!vnV*6KuK*?Za{675rnIbk0=E`0Pw5a)do9KtV0QqDi#%K)u6ye$&^ig zwMC{8Iw^rhQD_&aq~=Ds&r(gem4KV74}|}Ueme#b7dQia!c|)+_4pO_cBvWDDiM`I zVDbe$dYPaxXtEm`%rd_d#~D0fNq8tx%w8-~G}4+ytlynnr$|dl)OSA>J zw2>@Q75Npl5|Fk{ykUkCGZ{KHF1$5qjQzh1JyG+R%X1&afsmdL=ZW}vLTH(k`7YTR zH}T)EqH8T95k7!SVMG?5I~#-^`5ObtHe&advKekGrM|#_yH=Gz5=o z`Z&XF4Jo$vc}t%?T~Izg{=DXBc4Lp6+;s6y|FLghtJ;vcW#n;2>5~ITTgHCxYBj`k z_g)BVn!vVp-dnfwaz09Yp5#4z!asO6E5ENiio5`okQ=gA=>~|l(1R<+?e}gd?{8%7 zyd2xzGrTi(R@ok^Fg*%L88`q{F1&OIbB^|>cY}`#Vk!aeRud07yl(d43XG#1?A-hQ zryVz!o)7IfmD%6?Bbu3)GyvU9OLo16O34vn#Q@rVODXoBc0$yzr+R8l{%M`$&H+^0H#zY zu~PA$VULt~YW>C}q(P;xHul~r+2{#cO?8dKV$Gh?jO+v{Oi9Wd=Cic;87Ob!=5HvJ BGob(g diff --git a/tests/fixtures/package-signing/signed/with-pe.signed.nupkg b/tests/fixtures/package-signing/signed/with-pe.signed.nupkg new file mode 100644 index 0000000000000000000000000000000000000000..2f80c799789c910d2df8311c9b2f4682a8bdb884 GIT binary patch literal 3803 zcmbuC2{=@38^`C&X2_s0rlQC;wsFP`DUpnQ9qTKyOlF3WWn%0pO`@@s_GE40%~C1J z+9DCzqD{mrr6i=!o0yXCczeI7tLyu&_xjFUXU@6r^F06SdG7Q4&V4^L8w64c27_T> zv&@QZb|ZgWyAN5$01UPoy3)eg{vkvf-ERlopJ~pH=0=1ui6If;9HyV0gH2!qDA6wz zCUhoVH_YaY)YfMewGt!3iAt^Oo(Q$;&&)Jl!EQAg@bNl9xsn`;x0#4hRq{9L8`>^r zn8%L(JR}ouR1`SYkL)}_VUEpOy4(UD95h-frPk|KCaM%Bxv#*W|(#B#{_9`(k$wnS@V?oHbDTY}qfYcAkfrD9vPRVk5kqrDQq& zITn4}3m#9NY@=Lx_vIa5?y{S6+g_~aMcU+OG%@L9OW>`MkDF;fgkY_whR+LV>Jo4m zOcJ_!QbR+y%n+`ROALn@?nR6a4$A4Oho&x?@FMQad*5Ah2g~J}@K&)s)95(hC4rx* zwai9spzn~Lf%J(x6W#N|k8%Q2Mn(5io4hycU35z@It0A&zO0ie6Tj+U^2AhDf~(!~ z>Zhrg8y>B@_r9uXLApC-l$@{{NqX#Ep6MXSp7A}MhEC&JET53 zqBsr9OrMMfuf7?5Bhr)nW2c8%EJHz>g6TLKeQnL6KNk zCeqLu#NMtQ!sO}`LG5sQFeivf zIS2DC(8_`q+s{H=c1R3ahsX#Dno2MB^=0__`qGV2Vxoy#PzVGf?+ei=Nh{l5ItmnI zyNb3nYq}8#_Z_sY+gvT}+ge+-9WAY03F__6&27${58`gk?G9Fto$0<94%2s%&e$IP zOy3a28bF{`%tOqL&}bDj1`Pm&q-v47tnI$O`%-QPpb?3xRd`vCdXaj8FXag7JPd_K zuSAamH;z0?S%F4#PBnbtt(g%K6%~#(Q{yZ1MMbq}K-tDBe}&t7s{&S`&cRgmcd?|> z7%*!@nGW9NE&~L?=7js3^w3H9z9)U@6_b}%uXV8f@L{JThO@I9aokg&T#XXMLVsJ7 z=`px70%gA0+ahH_CJV=^DTsL%GdmnpQ&7at6qprlDB?F33Dk;EjRNJ&nBz6<*DZzS zNqjcD(A=ttZ+;>crDk=m`Y3%6V)pOl41;}!i2h2QpN|uU!4^(2DD=byqUnEe%vm1} z@`R(%!sjzA(Hfn`V^2zB9cH7kxZlzA(xgVJv?9|%OT1!a!2e|(9td+%EMmImK+lqt{3bS z5kVt!ZP|VSoB%WbFq#8{V`eBKlFvhDfINgL$b)Ox|AP-o# zbR+-@hTs9>Fvu?g=K%oh%#@TOKF>>Y8#(*W%){|-D{dN3$-2W~%Mp=#CNS|HEtFo} zY}Q+kxU|yI+m*u=PL+x)s^V?zFIN}W{b@UQTg?dHJfc15ZzexUN$J_zs!s|bzIsYm zK9x2Zxt!ZkiC?LGedhE%WAQkSw8Tm=_pBmSU0Fg#hCr=DQhFdsAhuc^w#~waabfUb zO^MX9;PgRS#KpjYfz79qOow!GVo=u{P72<2|p*ZO}dT7}tTyiR&D# zsj|{+G?Vc>MR9QYW3I3fjsRf5Ie(A|ib3&|69<5KBoYA^N&qDnY-OYy($+1>4lnd8z|0WAvXRRAyprPN-fi(lN608CbC9b*2vKFL-sXm}T@oS|CY-pbz; zMcx@Nls{}Cs4=x14{`JylqQ<2F+^8QsGbtPRo`)y>X0__?9%(1<{n~71Le6d;{f5n z?@z{iF#5tg@wDur3+|m2;%nVrwzbrDdh^D-@J%Q3W<531249v{BPmv2wX3$?Fs0iw0V1EO~HH{jsywc}zlUk38J}TY8q()_xL_{88{go#X=PwbAoWkP# zChLwpjnmO!k68DcD?CeaNMn&lIkuNVlDftX6|>3hWEt;`>3D0=(4j4rTw}4io$y1(PiZ182|HRRz*AJ?(4C#wzv1)c_v&Kq(3fcd`5s3nEME;HyWd1X; z|AkUf#pC4>4`E9E9fb86L-v&>bMpgfvpHBryK_e`7%G_eDT31 z^*c@Fkx~4+a=#;!30Ly;v78-Nsr5N@!0kVx1WyE4EI=W-fEEUX^4UBb0KS2ujKqObi+Fv%!z|(Gmze$das7)U^-h)g zvEsL8qkOrH^Ye{SB)tc3(#~4#8D*rY-FvdZpSpAR;R|m|4tH%b=$U9yXYSK9m2ASs znGViesN7`*&yFr{Wg$O-PKQr6yk0>mE_pVnqCrb_X&M=%Uph*BPrs&}mGZAL_kQsm z*Hm*HL#2`e+az3IU<-~+ve|(na$$TCl4dTD2C}9K%ditOI3V^cT4Pg{X97;cWpso&yq(@n=tLp)- zK`;BesjviGf+P+xy68Xyp!Q;KBxxN|o`yY+iqf@MsX0=jjh2`3{3h0&z+FX*kM7v}aAW_-Af&GaiAj6V=_WCNZX zZP0}Rsi5|Gt$zgdFY@+c8~^~x17N4`*kyc)F;=}@L>({4JSa6dSRBcEIELUWdvpp2 z7IOPrE>Ej$?o`YTeS5BA`5yCmp^WT1>KhD>jX54zbD=D1pW@v(k;qK@+Nsh~>S;xf zd(Nt&?+bbHb&slAuIFvG!})7?(WHbor24TgtDpDt1dg?}DbleKH*7ilO)9#!nB#{^ z&E65znxmK39dW~aJ{5W?^os*OHTEk;wf5A3#DS|v3@meJvLBv2gZ?eF%dx{fvi)nK z;|fLmJ&P+xk(;jTne^6NWtd9wB|Jq79#nq>lYDaJ$8Fn(sO0yS)TWyXoMLLScgZSz zCC*faye)8`X=eZ@i+71poj5 literal 0 HcmV?d00001 diff --git a/tests/fixtures/package-signing/unsigned/deep-nested.vsix b/tests/fixtures/package-signing/unsigned/deep-nested.vsix new file mode 100644 index 0000000000000000000000000000000000000000..00bc2339ed993d69beb48926c8278a4e13f2b346 GIT binary patch literal 2403 zcmZ`*c{G%58y`k?5z(MX2x08KmQg5Vl4Re_V8+^*%#1CjqP|{)u}4Z%w$Ts?O?aob zNHSSsM#jEZ_N-Z^Z+!n$PTzB%>pthXpL6}rb6>yf{C=11c_7z8006)X*hJKLD2|7} zKyv^9(VPH)1p5>biAA6=fuSgHI3_UiG8`3%L}0Lpbkn4MO_5s*H@10*?!>g&My#uX zMp2Psr`nB>Gw_RUSACMQ6p4~WtyGQLaos@~%e?gu4N9aI`-M*jM42Oc8s`5>ZW!t^ z>hk;z$@AZRYdlKJNyF9x(&KX+girYFfdn6s`_a>vmQ$2ZolC&+G@5qx8}1Y25ygE* z04>pSX6crY@?x1N=S; z)Hap3IOKB@Wl5-L&s}4C2va?3nEbk(?p9w{=ImUWB zM4=HFPjKYrkXvb{Hg7aV%-6G`dD`4F^(#$y6~8<}1HmwZjHa)VAyLQEMpsIOp76hB zQP*|z)*Lv?#I?81-N%!q%ddMJhFrMsc@Va4#A<(eV|K1fbdFtKZ;6Su}qNoztD^$$zekD0hbfX|$!FUQQ`n8i1T zT$j>l&^Ml>O;V5KV(n<35?-03M*AAlX-(1Pn|$TKBv<0yYiJd!9bb8gG0J4X0Bp!X za?$=&=}%PjEI!hab5VLIufJt8^k+4`v-@4eoO+m#3jo+)$8Nu);l9Cee*{J&A`ly( zjz)k{ICQYTz#b+fW-cc_Bfk1~rlAuCK5QsBTr+ayYQ#7MmtlNH2 zTNMgTvRnNIO%kxnh}~(d-Y?BNfY9*qb_sTyXtY)MHpLj^)X;Z#uu=8&JsrF>-qMxa zkQTl^=39~*64H^gh<7Gcq@yCXE{PV*ZPOBmQ=5Y4oRE!_wlI92)z^CkGICpz8Y_KP zi2+0W{jVOUk5lAyau?!rNXC_-sTfmcPQv*PDNc5zAR{q85Cb5S$uG)qU`E~ z!**_PTFv{=)cX9q^LclN%X4!{Z``G%)sEQ{PWA4DfBg7Fwzxp6Z)3A##-?u-{;$7O z)Zke&c?YVrBWnB>4iJIyE;H~HijP@xkQ=2Gmc$wppss2rmP&1-g*H+_9`M7mrEt>*z zDpdv7#(EGTI(Sm?tLSlKwN$3RI{((xFtW>NL3XR-E|5dX)gyOa%`>db*9`F$D|8?{8xchjmTLt|Y}FfR$5B3W zXZcMzN=bhynwW^0_sk*?(g_5@=U4Ary2swVr_mrkQXhEUo6pB~g~fYg%%W@_bnQ6phz#Z>-vq1aOy?H~%izwyhAAd2Q54`bCHhhdsou-d8aizrd+!Rhwu@e7FJ;b$p zUaOi!nWXepRgtQ$RBCAlJ)fH}OwZ0FbPnK-#wSzEV{hNI?lG;1r;1u1mjuIM2;U69 ztIaPWK4yXCPS(ar-u|K6x-&91I!X?s&pOOajCKjZ24FSvo=YaNnjm_cIbUrU-i~EP zu16fjRGTGOc5~QRirs22O}FZ~=XupoCsvFbCHTvGS}xVY*{xz0^732G6qNCp>frBW zGnn;#c0#+Zww*>ppdPds4i-CB1x1603=EbyfM>4%C7;0yoQEXyFHWGb@tqiMPE8%3 z4@#f8A1Wzui6CC5kqODKvTuqU-B0H|8^^zhL;zJ_a2Q+!<_CMxBxonRM@lt+s8%$v zMdJipN_U0WI}8zm(E#u6h*Z;{|4HbL0aMzLlu?o$bb?$hM7d6AsVyx$AVn{&6P8Qr z!S2W|EVt)K#4}6(p7w)v8Y@FnlnNw(r*ZSAMzv*xr=|C~8!NfSO3;1I`M}EcuE{03 ztGIRLtJHvy;4Y^w4+$D0w*S^*&$_G&%Mb>U$z(>+aoT8Ulfi2`JJps8`~n@0Mk)GT z7B_Un2MTc-+!uE=XT1q-MJsn^9$mm6Cq106RdWz(uxv~S4Fwc5zc??$b{h`PgMj}( z6R>ak%k$?6VK2sB&-w{a$S&S54z@ROuU-929AhW`!MuJ2_>;o_1XyIxIDgjrUom#4 fqP;}_#8Bt?jdt7f`?z;M?qe?__Wz0U?!NsO?QSKI literal 0 HcmV?d00001 diff --git a/tests/fixtures/package-signing/unsigned/nested.vsix b/tests/fixtures/package-signing/unsigned/nested.vsix new file mode 100644 index 0000000000000000000000000000000000000000..2a0c15efbe3dceaed3aa0712a356752088fe4319 GIT binary patch literal 2487 zcmZ`*c{r47AAXr;7{+Pk4EnfKV>ctpANEo9*ModHtvWrHXN!Ag4 z2BQNTz!Fl97Q_&mbQ>*^@-1`UfOg#Pq4|tD6lE5y*1NN}O71 zBPQ0QsHlXKpae-F-LuoBNA17O-8NdEHqFqyH`6|1vPfSXM~@GG%+$o4(YRqau9(bk zBiJiSS-sf;)&uq~Iqyme zA9SCTYO9OP)NAweo8dGow-U9OP<&UTCFlhfinl)rsnEG)=h7(BqfrnO>$}nzdUK(( z{^PnpWW`M};VAq(RjDrT+lcaXsS@GTr`)!K>ko5BaY6j?V?lE543%{C{*QgB=_Ix;YF$$wu0KcR0VLERJ)Tov#)5MNla|LDUtP zON>ta<9}qU%Q_cYZv-vP723>;5}0TvL`%Xs{>h zQZ{O4Ts?a*q-gb}#fcY+0) zol-PQ^aT;09#B9?oL2J7&q3Za*~HYiM!4E?K`{3werBe=Dlh;6z*bHv`14+P4Ul^4q z>sRYGRPy0)_+hv>JVzW3=Z{u@yST)EAi3<|TbuW4N*GLx_3{EHM%X$zbfc#17fHc> zBFfzj>*Y9FV|i?0yz4zbO4G^KT*>A9?_TpG4V>8O#K6@L=kpof-mPhKbO+X>B=Y4o zs{_pG^_J+t_?KSO_M{qi^CfyZ`tv=el+2nqYN-buePKYTuQoerge{}an2i`9Z$cO4 za{kNiu2a0S!KOU6&*iY8cQ6fx7<##Q-l2gt1{9})9(%zGZ?HF zP7;#JM{E-Gx;Fyb+aF5jF^~6r;kHhi_blU|c}UQ*yCES#c3IsT8-%XSU)`obme=(4 z9JM!PK%fZVASgl=1Ty-IN_RzHbVY}l5DhN+6}g@!zv+(=;DtVSLZ`^}i(Zy-g>Tu^ zacFCydP(GykcXPP|yM0Y@C6x`gFHk~OggR&GsMA?_wZS90%JID( zDne`HgCvgjtn^yzT@at#IcLUdX)(}T8Vey74B^&AvB6rbV1y{5S7IXMjlk75 zLd{0l7701N_wnCnQr8!8zdqL)><8FsLyMrC_>`BxSwOpBd~c}sIM%E|;| zC$IS0rm@9_GQZcp@wElI(*}Tt|lCR{{6Azi%@Fy1}z}bMDW(eufZ32J+m8ijOUlLSRRU5-z>1aT1+{dNi z@a1!lB&99P=I8ya1Sx*)pgT@n1RKl^=4}QDJWVW%i-lm#v zm*vUeo@QyWqUu;&1s1rDi(t_1RCA8wG-nFE`E2<@P31q0SWCGYs_d-nFu>9p`oH6XTL;e z!QXpjVmkacq-Pi3q=`lJ7TlU3;M#QMF>!KCG0^V;GkU{nQhO&P!A`nz=R(Cg-A&wa z#V4=IL^~-saxxUk;Upx-p5rXm(iPfweXe6w8oPN4rz4fpOY00!qe$w%zpB1bc4k&c zxYbTOPLpFCt}fc26M%wPY^YATB{=3T-JX1KmM+V>KU1M>D^h)?Cd$_rVAee`m6EnJ z<>NmD{QrvrFVY>)?$?H&Ies2j+XVmclDA`c{S5qhQf&j*dB9!f)=q-m%zc|ch-de^ wTK!In-GsGG!2$p8@H=F-6Te%ZY~u|De&`xYQ!r%fCxrI~^TLzkDH#C%1+HQfrT_o{ literal 0 HcmV?d00001 diff --git a/tests/fixtures/package-signing/unsigned/sample.nupkg b/tests/fixtures/package-signing/unsigned/sample.nupkg index 839c72644d7a6f7d6b707ba04e2375b0ade255b0..bd46d4165e23a258fb59c5ba3c8f339b2d784fff 100644 GIT binary patch delta 348 zcmdnac94xXz?+#xgn@y9gW+fD)`h%`d_XEjK3Y5eAy80&X|gnAJ=zJ5I7h1kWk*^xJVVb=#9a(`O&F z+xm9%S4q}@o*1br3%Mz;?j;u0XN!CdWGy(%?Q-o^uJpc&y=5+j73zwMZ68-C&x~KH zDX`vn56h$WS&PEH`L|j0&ilvyGv+|RNfuY8J4=_G>QxRn8dSpg{o65#@^`1>9C*AL zr+?GhVbr)^{1(^Ty>=Bp7*WC?hMi^EV=vu*()Es6hYbW+FVrfO z3)~Wm&J$lMayxR#UPpa}CmWTu&hpGE49JfcDi_V*y8Hj<`@>(_XHB+rp6Fq5kCvudl1qEDK zyRiKF^`84EK96B%S@u{A=w(n?hyrm=W|DqhYKeuOf&OGWCLLyAuuRTnazhWEgG|N> cpb$gX4m1G@xS1v!GKo*tW)@%zVFHN(0Kw6X1poj5 diff --git a/tests/fixtures/package-signing/unsigned/sample.snupkg b/tests/fixtures/package-signing/unsigned/sample.snupkg index 839c72644d7a6f7d6b707ba04e2375b0ade255b0..bd46d4165e23a258fb59c5ba3c8f339b2d784fff 100644 GIT binary patch delta 348 zcmdnac94xXz?+#xgn@y9gW+fD)`h%`d_XEjK3Y5eAy80&X|gnAJ=zJ5I7h1kWk*^xJVVb=#9a(`O&F z+xm9%S4q}@o*1br3%Mz;?j;u0XN!CdWGy(%?Q-o^uJpc&y=5+j73zwMZ68-C&x~KH zDX`vn56h$WS&PEH`L|j0&ilvyGv+|RNfuY8J4=_G>QxRn8dSpg{o65#@^`1>9C*AL zr+?GhVbr)^{1(^Ty>=Bp7*WC?hMi^EV=vu*()Es6hYbW+FVrfO z3)~Wm&J$lMayxR#UPpa}CmWTu&hpGE49JfcDi_V*y8Hj<`@>(_XHB+rp6Fq5kCvudl1qEDK zyRiKF^`84EK96B%S@u{A=w(n?hyrm=W|DqhYKeuOf&OGWCLLyAuuRTnazhWEgG|N> cpb$gX4m1G@xS1v!GKo*tW)@%zVFHN(0Kw6X1poj5 diff --git a/tests/fixtures/package-signing/unsigned/sample.vsix b/tests/fixtures/package-signing/unsigned/sample.vsix index 415f3aecf1a742817c69386bf7ce499f5b3c3428..ffa3f102fc010be7554e81fa9a496b47034fdf39 100644 GIT binary patch delta 342 zcmZ3;xr388z?+#xgn@y9gW+fD){VU7jLblKayz3nFOZH&+bAA>7byF1@_9z-`iYKy zhYbW=zfY}VKOH^AVuJ&R>dQq1%z6oylaIWr$f;DD`0&dL-ql=({_X#6GV5Ci(<)J; z-yU=8)<~~yh>@}Jog0_3Chp5hd%0Wv=ULWxIXv<^uk`Y&M42(m=~5pD)dv#A8xED8 zne2Hj_6^Ikm6`K;yHy;5PF`L3`?2H7Z@g|kZWlsDXY%F;EXzCd$-5a-(Ews{VBEcL7y zS4J4!*pR+ZHFlZo`RV@B_h#Kb{wp6PASUl&dIAoJ#mtUi`Z=>1T7YP=n1E#xS)>_{ PO|E5;V_VG(3@HWxKWCTx delta 366 zcmdnNxsa1Lz?+#xgn@y9gCW3e-A3MWMrI&Axt-CP7f8oACvI;z29%ANe4bId{=&hm z!v;LZK2|S$pHL#(xmos@glE-@Z*0|!54AdH>Pma_-n-pzW;?a-?*9FC3;qQp8)%Du z>}0RVvo<)QH`nGo^PJMGdk+lWmH)l&uV!eYu!|J6e^zW*ZguCVHtZ@|t}r z$9Px%oRj-X?yfHFoEjm$_fA9Me&c2T7jOojcRZUO`iNIwgyl@3bo?GhgXa%qSNZ3J z+uij%>zm2l`r1uw{?nDm7>@lrx#m%}%QxZ9dxc)1Z|!$pYY*jnQTe*6y7t-+ZWJF+ zzRdIl?BiX`j$ry9vl*Jt9a&7AL1AI=UwhpiU|47|GB9uhX#`l$#J~_=l$ukluLmLn Syjj^mk}N>@9Y{}M2JryakBgK5 diff --git a/tests/fixtures/package-signing/unsigned/with-pe.nupkg b/tests/fixtures/package-signing/unsigned/with-pe.nupkg new file mode 100644 index 0000000000000000000000000000000000000000..109692760833bd0eaedd233b9eabde49760eade2 GIT binary patch literal 1272 zcmWIWW@Zs#U|`^2_?fyj#`nejEvJAy2Sx@4X&@a?oSB}d7m%2ootU2LmRV6!T9m4n zS6W<EFPEthkR@ep} zl^o9vnaO+~^mg-4m_H$X&Al#G+3U}RleH?1&P}LPIu=s$tIaT_@OD+*Bd6leT#Kw# zetX>I)LhcU{BF(6Oda0R*kBQvg7mrWx09A${V@HE$;Q`zUU$l_4+}oi$Q!*j;KQp5 zy|(Emv-ZFEY9D|S1oF|^@ehGs7hqyw5C+oG&iQ#Isd**wA(aKG#j$!7xjAz$9t3)w zr{Q7s!uJU^vVEInk4bn|-T1~<&G=ZObEYo0x8}Xu{btsp(+<76^q=#}+^nuwJgv^h z;_S`VhBesrGd_=7Z!}Z9R;sW4-RId2p}vAUuXl6ojXGV|_+-aPmWbdvMV@{;PNr^q z@@M+&gLYfrZvHCC8qgCXRb?SJ<<-5!qHK|`fvg3Gxm~Wk%9Y+%vA4{{utHsNvF+mu z<(ctIH3ili?_qh=K5J3fH~%(^-g*Dnf5sdLILYG5bZ6<3Q@zRoM}tZjzkfR>QU309 zoCA+H6s7 zoK(G%iV|&&b3W&TKWLZw>jrw9UZbI@>-Ch00bx*I%GQ`Qi~Zdkfhh}^*w76s$;_)X zHquMU$@$#7Dj^{yAt52rft8P^#gUbnnR)&nJ-asDIq9P12EXQn@|--O6Q-eY%3t5> zOsI$7nbW8B13kP#HMGtKpEwg-@R2|4#94pOtHFs090jQf9}-iNDsEcXvZmc*W>a&^ zbF*V(Q)A;`V`S85+sSn#Eh!>wPsoKfIqZ`1u!D>gk;(^8;8B(l^8PqLm(nNYv7`&J_ z7)gX2Nii@mIMHy*#*D3PLBjNe#H}CJNz42DeEe7#$Wc&uiFs=DFV&r_zp8+QqiSNM z*ur0`ovtres{WdpCYH8qY30qzpHC}yEnS&8^Ox&N%axr+SN_sm$$Ipc>deZiyE31h zT;|r+nVGrF>5N+q5cHP0z(UCnr7t=2>q>CJ}ICVb9aR@J9l8ayYs{=s5#n3NtWL xvE&nU1JDx^!T?cVX2Y7Skd45Wgb+r=p&EfmPyyboY#?PUKqvro=p|+l4*+pK$`=3t literal 0 HcmV?d00001 From e06788b23a2a89bc8f9ef4f0cf11578d4adce5a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 22 May 2026 10:26:19 -0400 Subject: [PATCH 02/14] Wire ClickOnce manifest code signing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 3 +- docs/gap-analysis-signing-platforms.md | 2 +- docs/linux-signing-pipelines.md | 5 +- docs/migration-dotnet-sign.md | 6 +- src/code.rs | 268 ++++++++++++++++++++++++- tests/code_command.rs | 136 +++++++++++++ 6 files changed, 412 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3c26522..928b702 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Canonical repository: . - `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). - `cert-store`: Portable file-backed certificate store under `~/.psign/cert-store` by default, with Windows-style store/thumbprint selection. -- `code`: dotnet/sign-style orchestration entry point. It supports `--dry-run` / `--plan-json` planning over inputs, file lists, globs, and nested ZIP/OPC containers, plus guarded local cert/key execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, MSIX/AppX unsigned-package prepare, encrypted MSIX/AppX OS-only diagnostics, PE-like ClickOnce `.deploy` payloads, App Installer publisher updates + companion signatures, `--continue-on-error`, `--skip-signed`, `--overwrite`, and inside-out VSIX/ZIP -> NuGet/VSIX -> PE signing. +- `code`: dotnet/sign-style orchestration entry point. It supports `--dry-run` / `--plan-json` planning over inputs, file lists, globs, and nested ZIP/OPC containers, plus guarded local cert/key execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, MSIX/AppX unsigned-package prepare, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer publisher updates + companion signatures, `--continue-on-error`, `--skip-signed`, `--overwrite`, and inside-out VSIX/ZIP -> NuGet/VSIX -> PE/ClickOnce-manifest signing. - `portable ...`: Cross-platform digest, verification, trust, signing, package, RFC3161, and remote-hash helpers that avoid Win32 APIs, including PE/WinMD signing through Azure Artifact Signing REST without Microsoft client DLLs. ## MSIX parity notes @@ -144,6 +144,7 @@ cargo build -p psign --bin psign-tool --locked # psign-tool code --base-directory . --overwrite --cert signer.der --key signer.pkcs8 --output resigned.nupkg signed-package.nupkg # psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.zip package-bundle.zip # psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --publisher-name "CN=Publisher" --output prepared.msix app.msix +# psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.manifest app.exe.manifest # psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output app.signed.exe.deploy app.exe.deploy # psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --publisher-name "CN=Publisher" --output updated.appinstaller.p7 app.appinstaller # Optional portable REST helpers (Linux/macOS): diff --git a/docs/gap-analysis-signing-platforms.md b/docs/gap-analysis-signing-platforms.md index ccbb0dc..114ec8d 100644 --- a/docs/gap-analysis-signing-platforms.md +++ b/docs/gap-analysis-signing-platforms.md @@ -58,7 +58,7 @@ This inventory starts from the in-tree supported formats, then expands to inbox | **App Installer descriptors** (`.appinstaller`) | Direct embedded signing is rejected by current SignTool; descriptor signing can be represented as unsigned XML plus a PKCS#7 companion artifact generated with SignTool `/p7`. | No full XML+companion signing/verification UX or native parity wrapper. | `appinstaller-info` inspects descriptor metadata and companion `.p7`; `appinstaller-set-publisher` updates MainPackage/MainBundle publisher metadata; `appinstaller-sign-companion` creates a local RSA/SHA-2 detached PKCS#7 companion with optional RFC3161 timestamping; `appinstaller-sign-companion-prehash` + `appinstaller-sign-companion-from-signature` assemble companion CMS from external RSA signatures; detached trust primitives verify the XML plus companion signature; `psign-tool code` can update publisher metadata and create top-level companion signatures with local cert/key and explicit output. | No App Installer-specific policy checks, nested orchestration, or direct cloud-provider integration in the `code` orchestrator. | | **NuGet packages** (`.nupkg`, `.snupkg`) | Not a `signtool`/WinTrust SIP target in this repo. | No `nuget sign`-compatible author/repository signing workflow in Windows mode. | `psign-opc-sign` groundwork: marker inspection, unsigned package digest, NuGet v1 signature-content generation/verification, local RSA/SHA-2 CMS generation for signature content with RFC3161 timestamp tokens, external-signer CMS assembly via `nupkg-signature-pkcs7-prehash` + `nupkg-signature-pkcs7-from-signature`, `.signature.p7s` embed/overwrite, one-step local `nupkg-sign`, embedded `.signature.p7s` package hash + explicit-anchor CMS verification, top-level local `psign-tool code` execution, package-native nested execution when embedded in VSIX/ZIP containers, nested PE/WinMD signing before package signatures, nested exclude filters, `--skip-signed`, and `--overwrite`. | No repository signatures, full NuGet policy verification, non-PE Authenticode nested payload signing, or direct cloud-provider integration in the `code` orchestrator. | | **VSIX packages** (`.vsix`) | Not a first-class Windows-mode signing surface here. | No VSIX package signing/verification workflow. | Signature marker inspection, signature XML embed primitive with OPC signature content-type and relationship metadata, deterministic XMLDSig Reference/DigestValue generation/verification for package parts, local RSA/SHA-2 XMLDSig `SignatureValue` generation/verification, external-signer XMLDSig assembly via `vsix-signature-xml-prehash` + `vsix-signature-xml-from-signature`, one-step local `vsix-sign`, embedded OPC XMLDSig verification with optional explicit-anchor signer chain validation, top-level local `psign-tool code` execution, package-native nested VSIX/ZIP -> NuGet/VSIX -> PE/WinMD execution, nested exclude filters, `--skip-signed`, and `--overwrite`. | No timestamping, non-PE Authenticode nested payload signing, or direct cloud-provider integration in the `code` orchestrator. | -| **ClickOnce / VSTO manifests** (`.manifest`, `.application`, `.vsto`, `.deploy` workflows) | Not implemented. | No `mage.exe`/manifest XMLDSig workflow, certificate embedding, or timestamping. | `psign-tool code --dry-run` classifies ClickOnce/VSTO workflow nodes; portable helpers inspect/copy `.deploy` payloads; guarded `psign-tool code` execution signs PE-like `.deploy` payloads with local cert/key; portable helpers update and verify manifest file size/digest references; `clickonce-sign-manifest` / `clickonce-sign-manifest-prehash` / `clickonce-sign-manifest-from-signature` / `clickonce-verify-manifest-signature` provide deterministic portable structural local/external XMLDSig signing with embedded signer certificate. | Full Mage-compatible canonicalization/policy, timestamping, full deployment graph orchestration, and ClickOnce/VSTO policy checks remain. | +| **ClickOnce / VSTO manifests** (`.manifest`, `.application`, `.vsto`, `.deploy` workflows) | Not implemented. | No `mage.exe`/manifest XMLDSig workflow, certificate embedding, or timestamping. | `psign-tool code --dry-run` classifies ClickOnce/VSTO workflow nodes; portable helpers inspect/copy `.deploy` payloads; guarded `psign-tool code` execution signs PE-like `.deploy` payloads and `.manifest` / `.application` / `.vsto` XMLDSig manifests with local cert/key; portable helpers update and verify manifest file size/digest references; `clickonce-sign-manifest` / `clickonce-sign-manifest-prehash` / `clickonce-sign-manifest-from-signature` / `clickonce-verify-manifest-signature` provide deterministic portable structural local/external XMLDSig signing with embedded signer certificate. | Full Mage-compatible canonicalization/policy, timestamping, full deployment graph orchestration, and ClickOnce/VSTO policy checks remain. | | **Business Central `.app`** | Format-specific behavior is not implemented. | No confirmed NAVX signing/verification workflow. | `business-central-app-info` detects NAVX headers, `psign-tool code --dry-run` classifies NAVX `.app` files, and signing execution now reports a Business Central-specific unsupported diagnostic instead of silently treating them as generic files. | Actual package signing and verification policy remain pending format confirmation. | | **File catalog authoring** | Can sign/verify an existing `.cat` at the Authenticode layer. | No catalog creation from arbitrary file sets or INF/driver package metadata. | `sign-catalog` authors generic CTL catalogs; catalog PKCS#7 consistency/trust and explicit `verify-catalog-member` cover committed MakeCat-style and psign-authored generic catalogs. | Driver/INF policy, OS catalog database search, and MakeCat byte-for-byte output remain out of scope. | | **WDAC / CI policy signing** | Detached PKCS#7/catalog primitives only. | No policy-specific signing/validation workflow or deployment policy checks. | Detached PKCS#7/catalog primitives only. | No policy-specific workflow, Code Integrity semantics, or Windows deployment policy checks. | diff --git a/docs/linux-signing-pipelines.md b/docs/linux-signing-pipelines.md index 6769f6d..13cb59c 100644 --- a/docs/linux-signing-pipelines.md +++ b/docs/linux-signing-pipelines.md @@ -89,7 +89,7 @@ This path builds Authenticode CMS locally, sends the CMS authenticated-attribute ## 1.4 Package-native helper workflows -`dotnet/sign`-style package orchestration is being added through `psign-tool code` and package-native helpers. The command can plan nested graphs and has guarded local cert/key execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare, encrypted MSIX/AppX OS-only diagnostics, PE-like ClickOnce `.deploy` payloads, App Installer inputs, `--continue-on-error`, `--skip-signed`, `--overwrite`, and package-native VSIX/ZIP/MSIX -> NuGet -> PE nesting: +`dotnet/sign`-style package orchestration is being added through `psign-tool code` and package-native helpers. The command can plan nested graphs and has guarded local cert/key execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer inputs, `--continue-on-error`, `--skip-signed`, `--overwrite`, and package-native VSIX/ZIP/MSIX -> NuGet -> PE/ClickOnce-manifest nesting: ```bash psign-tool code --dry-run --plan-json --base-directory . --file-list files.txt @@ -99,6 +99,7 @@ psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output psign-tool code --base-directory . --overwrite --cert signer.der --key signer.pkcs8 --output resigned.nupkg signed-package.nupkg psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.zip bundle.zip psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --publisher-name "CN=Publisher" --output prepared.msix app.msix +psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.manifest app.exe.manifest psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output app.signed.exe.deploy app.exe.deploy psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output app.appinstaller.p7 app.appinstaller psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --publisher-name "CN=Publisher" --output updated.appinstaller.p7 app.appinstaller @@ -141,7 +142,7 @@ psign-tool portable clickonce-sign-manifest-from-signature updated.manifest --ce psign-tool portable clickonce-verify-manifest-signature signed.manifest --trusted-ca signer.der ``` -These commands do not yet replace `dotnet/sign` for production recursive package signing. They cover deterministic package hashing/reference generation, local PE/WinMD Authenticode signing, local and external-signer NuGet/App Installer CMS signing, NuGet external-signer CMS assembly via `nupkg-signature-pkcs7-prehash` + `nupkg-signature-pkcs7-from-signature`, local and external-signer VSIX XMLDSig signing with optional explicit-anchor signer chain verification, unsigned MSIX/AppX publisher/block-map prepare, encrypted MSIX/AppX OS-only diagnostics, App Installer publisher update before companion signing, marker embedding, package-native nested VSIX/ZIP/MSIX -> NuGet -> PE signing, PE-like ClickOnce `.deploy` payload signing, ClickOnce manifest file hash update/verification plus local/external deterministic portable structural XMLDSig signing, nested exclude filters, and metadata inspection/update while final MSIX signing and full manifest/policy checks are being completed. +These commands do not yet replace `dotnet/sign` for production recursive package signing. They cover deterministic package hashing/reference generation, local PE/WinMD Authenticode signing, local and external-signer NuGet/App Installer CMS signing, NuGet external-signer CMS assembly via `nupkg-signature-pkcs7-prehash` + `nupkg-signature-pkcs7-from-signature`, local and external-signer VSIX XMLDSig signing with optional explicit-anchor signer chain verification, unsigned MSIX/AppX publisher/block-map prepare, encrypted MSIX/AppX OS-only diagnostics, App Installer publisher update before companion signing, marker embedding, package-native nested VSIX/ZIP/MSIX -> NuGet -> PE/ClickOnce-manifest signing, PE-like ClickOnce `.deploy` payload signing, ClickOnce manifest file hash update/verification plus local/external deterministic portable structural XMLDSig signing, nested exclude filters, and metadata inspection/update while final MSIX signing and full manifest/policy checks are being completed. ## 1.5 RFC 3161 TSA query/reply (DER only; no embed) diff --git a/docs/migration-dotnet-sign.md b/docs/migration-dotnet-sign.md index 55d239e..14e90d6 100644 --- a/docs/migration-dotnet-sign.md +++ b/docs/migration-dotnet-sign.md @@ -34,17 +34,17 @@ | MSIX/AppX manifest Identity inspection/update | Implemented unsigned-package helper plus guarded `code` prepare execution and encrypted-package OS-only diagnostics | `msix-manifest-info`, `msix-set-publisher`, `psign-tool code --publisher-name "CN=Publisher" --output prepared.msix app.msix` | | ClickOnce `.deploy` payload handling | Implemented copy-out primitive and guarded PE-like payload signing through `code` | `clickonce-deploy-info`, `clickonce-copy-deploy-payload`, `psign-tool code --cert signer.der --key signer.pkcs8 --output app.signed.exe.deploy app.exe.deploy` | | ClickOnce manifest file hash graph | Implemented portable file size/digest update and verification helpers | `clickonce-update-manifest-hashes app.exe.manifest --base-directory publish --output updated.manifest`, `clickonce-manifest-hashes updated.manifest --base-directory publish` | -| ClickOnce manifest XMLDSig | Implemented deterministic portable structural local/external XMLDSig signing/verification; not full Mage parity | `clickonce-sign-manifest app.exe.manifest --cert signer.der --key signer.pkcs8 --output signed.manifest`, `clickonce-sign-manifest-prehash app.exe.manifest --encoding raw --output prehash.bin`, `clickonce-sign-manifest-from-signature app.exe.manifest --cert signer.der --signature remote.sig --output signed.manifest`, `clickonce-verify-manifest-signature signed.manifest --trusted-ca signer.der` | +| ClickOnce manifest XMLDSig | Implemented deterministic portable structural local/external XMLDSig signing/verification and routed through guarded `psign-tool code` local cert/key execution for `.manifest`, `.application`, and `.vsto`; not full Mage parity | `psign-tool code --cert signer.der --key signer.pkcs8 --output signed.manifest app.exe.manifest`, `clickonce-sign-manifest app.exe.manifest --cert signer.der --key signer.pkcs8 --output signed.manifest`, `clickonce-sign-manifest-prehash app.exe.manifest --encoding raw --output prehash.bin`, `clickonce-sign-manifest-from-signature app.exe.manifest --cert signer.der --signature remote.sig --output signed.manifest`, `clickonce-verify-manifest-signature signed.manifest --trusted-ca signer.der` | | Azure.Identity-style auth selector UX | Partially implemented | `--azure-key-vault-credential-type`, `--artifact-signing-credential-type` | ## Current gaps The remaining dotnet/sign feature gaps are execution and policy work: -- `psign-tool code` execution supports local RSA cert/key PE/WinMD Authenticode signing, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare execution, encrypted MSIX/AppX OS-only diagnostics, PE-like ClickOnce `.deploy` payloads, App Installer descriptors, `--continue-on-error`, `--max-concurrency`, `--skip-signed`, `--overwrite`, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD inside-out signing. Unsupported non-PE nested Authenticode payloads still fail explicitly unless excluded by file-list filters. +- `psign-tool code` execution supports local RSA cert/key PE/WinMD Authenticode signing, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare execution, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer descriptors, `--continue-on-error`, `--max-concurrency`, `--skip-signed`, `--overwrite`, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD/ClickOnce-manifest inside-out signing. Unsupported non-PE nested Authenticode payloads still fail explicitly unless excluded by file-list filters. - NuGet support does not yet wrap signature content in full NuGet author/repository signature metadata or enforce NuGet trust policy; local CMS signatures can carry RFC3161 timestamp tokens, and split external CMS assembly can consume a Key Vault/Trusted Signing-style RSA signature over `nupkg-signature-pkcs7-prehash`. - VSIX support now produces and verifies deterministic local or external-signer RSA/SHA-2 XMLDSig `SignatureValue` bytes, writes the package-level OPC signature content types and relationships, and can optionally validate the XMLDSig signer certificate chain against explicit anchors, but still lacks timestamping and direct cloud-provider integration in the `code` orchestrator. -- ClickOnce/VSTO support includes classification, `.deploy` payload copy-out helpers, guarded local signing for PE-like `.deploy` payloads, portable manifest file hash update/verification, and deterministic portable structural local/external XMLDSig manifest signing/verification with embedded signer certificate; timestamping, full Mage-compatible XML canonicalization/policy, and full deployment graph signing remain. +- ClickOnce/VSTO support includes classification, `.deploy` payload copy-out helpers, guarded local signing for PE-like `.deploy` payloads, portable manifest file hash update/verification, deterministic portable structural local/external XMLDSig manifest signing/verification with embedded signer certificate, and guarded `psign-tool code` routing for top-level or nested manifest XMLDSig signing; timestamping, full Mage-compatible XML canonicalization/policy, and full deployment graph signing remain. - MSIX/AppX `code` execution prepares unsigned cleartext packages by signing nested entries, updating `AppxManifest.xml` Publisher from `--publisher-name`, and regenerating `AppxBlockMap.xml`; encrypted `.eappx`/`.emsix` packages are classified with explicit Windows AppxSip OS-delegation diagnostics; final package signing still uses the existing Windows SignerSignEx3/AppX path. - App Installer local/external companion generation, RFC3161 timestamping, publisher update before companion signing, and explicit-anchor detached verification exist; full App Installer policy checks remain. diff --git a/src/code.rs b/src/code.rs index ad3ad7c..9e7fb75 100644 --- a/src/code.rs +++ b/src/code.rs @@ -461,6 +461,41 @@ fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result "`psign-tool code` recognized {} as an encrypted MSIX/AppX package; encrypted .eappx/.emsix packages require Windows AppxSip OS delegation and are not supported by the portable package prepare path", node.path )), + CodeFormat::ClickOnceApplication | CodeFormat::Vsto | CodeFormat::Manifest => { + let output = output_path_for_node(node)?; + ensure_parent_dir(&output)?; + let input_bytes = + std::fs::read(&input).with_context(|| format!("read {}", input.display()))?; + if args.skip_signed && clickonce_manifest_has_signature(&input_bytes) { + std::fs::write(&output, input_bytes).with_context(|| { + format!("write skipped ClickOnce manifest {}", output.display()) + })?; + Ok(format!( + "skipped {} -> {} (already signed)", + node.path, + display_path(&output) + )) + } else { + let signed = sign_clickonce_manifest_bytes( + &input_bytes, + &node.path, + cert, + key, + vsix_digest, + args.timestamp_url.as_deref(), + args.timestamp_digest, + ) + .with_context(|| format!("sign ClickOnce manifest {}", input.display()))?; + std::fs::write(&output, signed).with_context(|| { + format!("write signed ClickOnce manifest {}", output.display()) + })?; + Ok(format!( + "signed {} -> {} (ClickOnce manifest XMLDSig)", + node.path, + display_path(&output) + )) + } + } CodeFormat::Deploy => { let output = output_path_for_node(node)?; ensure_parent_dir(&output)?; @@ -488,7 +523,7 @@ fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result node.path )), _ => Err(anyhow!( - "`psign-tool code` signing execution currently supports top-level PE/WinMD, NuGet/SNuGet, VSIX, ZIP, MSIX/AppX prepare, ClickOnce .deploy PE payloads, and App Installer descriptors only ({} is {:?})", + "`psign-tool code` signing execution currently supports top-level PE/WinMD, NuGet/SNuGet, VSIX, ZIP, MSIX/AppX prepare, ClickOnce manifests, ClickOnce .deploy PE payloads, and App Installer descriptors only ({} is {:?})", node.path, node.format )), @@ -651,6 +686,117 @@ fn sign_vsix_bytes( Ok(out.into_inner()) } +fn sign_clickonce_manifest_bytes( + input_bytes: &[u8], + label: &str, + cert: &Path, + key: &Path, + digest: vsix::VsixHashAlgorithm, + timestamp_url: Option<&str>, + timestamp_digest: Option, +) -> Result> { + if timestamp_url.is_some() || timestamp_digest.is_some() { + return Err(anyhow!( + "ClickOnce manifest XMLDSig timestamping is not implemented in `psign-tool code` yet" + )); + } + let text = std::str::from_utf8(input_bytes) + .with_context(|| format!("read ClickOnce manifest {label} as UTF-8 XML"))?; + let unsigned = unsigned_clickonce_manifest_text(text)?; + let cert_bytes = std::fs::read(cert).with_context(|| format!("read {}", cert.display()))?; + let key_bytes = std::fs::read(key).with_context(|| format!("read {}", key.display()))?; + let private_key = rdp::parse_rsa_private_key(&key_bytes) + .with_context(|| format!("parse RSA private key {}", key.display()))?; + let signed_info = clickonce_manifest_signed_info_xml(&unsigned, digest); + let signature = sign_xml_signed_info(digest, private_key, &signed_info); + let signature_xml = clickonce_manifest_signature_xml(&signed_info, &signature, &cert_bytes); + Ok(insert_clickonce_signature_xml(&unsigned, &signature_xml)?.into_bytes()) +} + +fn clickonce_manifest_has_signature(bytes: &[u8]) -> bool { + std::str::from_utf8(bytes).is_ok_and(|text| { + find_xml_element_span_by_local_name(text, "Signature", 0).is_ok_and(|span| span.is_some()) + }) +} + +fn unsigned_clickonce_manifest_text(text: &str) -> Result { + let Some((start, end)) = find_xml_element_span_by_local_name(text, "Signature", 0)? else { + return Ok(text.to_owned()); + }; + let mut out = String::with_capacity(text.len() - (end - start)); + out.push_str(&text[..start]); + out.push_str(&text[end..]); + Ok(out) +} + +fn clickonce_manifest_signed_info_xml( + unsigned_manifest_text: &str, + digest: vsix::VsixHashAlgorithm, +) -> Vec { + let manifest_digest = + clickonce_signature_digest_bytes(digest, unsigned_manifest_text.as_bytes()); + let digest_b64 = BASE64_STANDARD.encode(manifest_digest); + format!( + r#"{digest_b64}"#, + clickonce_signature_algorithm_uri(digest), + clickonce_signature_digest_uri(digest), + ) + .into_bytes() +} + +fn clickonce_manifest_signature_xml( + signed_info: &[u8], + signature: &[u8], + cert_der: &[u8], +) -> String { + let signed_info = String::from_utf8_lossy(signed_info); + format!( + r#"{signed_info}{}{}"#, + BASE64_STANDARD.encode(signature), + BASE64_STANDARD.encode(cert_der) + ) +} + +fn insert_clickonce_signature_xml( + unsigned_manifest_text: &str, + signature_xml: &str, +) -> Result { + let root = find_xml_root_start_tag(unsigned_manifest_text)?; + let close = format!("", root.name); + let close_start = unsigned_manifest_text + .rfind(&close) + .ok_or_else(|| anyhow!("ClickOnce manifest root tag is not closed", root.name))?; + let mut out = String::with_capacity(unsigned_manifest_text.len() + signature_xml.len()); + out.push_str(&unsigned_manifest_text[..close_start]); + out.push_str(signature_xml); + out.push_str(&unsigned_manifest_text[close_start..]); + Ok(out) +} + +fn clickonce_signature_algorithm_uri(digest: vsix::VsixHashAlgorithm) -> &'static str { + match digest { + vsix::VsixHashAlgorithm::Sha256 => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + vsix::VsixHashAlgorithm::Sha384 => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384", + vsix::VsixHashAlgorithm::Sha512 => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512", + } +} + +fn clickonce_signature_digest_uri(digest: vsix::VsixHashAlgorithm) -> &'static str { + match digest { + vsix::VsixHashAlgorithm::Sha256 => "http://www.w3.org/2001/04/xmlenc#sha256", + vsix::VsixHashAlgorithm::Sha384 => "http://www.w3.org/2001/04/xmldsig-more#sha384", + vsix::VsixHashAlgorithm::Sha512 => "http://www.w3.org/2001/04/xmlenc#sha512", + } +} + +fn clickonce_signature_digest_bytes(digest: vsix::VsixHashAlgorithm, bytes: &[u8]) -> Vec { + match digest { + vsix::VsixHashAlgorithm::Sha256 => sha2::Sha256::digest(bytes).to_vec(), + vsix::VsixHashAlgorithm::Sha384 => sha2::Sha384::digest(bytes).to_vec(), + vsix::VsixHashAlgorithm::Sha512 => sha2::Sha512::digest(bytes).to_vec(), + } +} + #[allow(clippy::too_many_arguments)] fn sign_zip_container_bytes( input_bytes: &[u8], @@ -751,6 +897,21 @@ fn sign_nested_package_entries( timestamp_url, timestamp_digest, )?), + CodeFormat::ClickOnceApplication | CodeFormat::Vsto | CodeFormat::Manifest => { + if skip_signed && clickonce_manifest_has_signature(&bytes) { + None + } else { + Some(sign_clickonce_manifest_bytes( + &bytes, + &nested_label, + cert, + key, + vsix_digest, + timestamp_url, + timestamp_digest, + )?) + } + } CodeFormat::Deploy => Some(sign_clickonce_deploy_bytes( &bytes, &nested_label, @@ -1135,6 +1296,111 @@ fn xml_escape_attr(value: &str) -> String { .replace('>', ">") } +#[derive(Debug)] +struct XmlStartTagSpan { + start: usize, + end: usize, + name: String, +} + +fn find_xml_start_tag_by_local_name( + text: &str, + local_name: &str, + from: usize, +) -> Result> { + let mut cursor = from; + while let Some(rel) = text[cursor..].find('<') { + let start = cursor + rel; + let Some(first) = text[start + 1..].chars().next() else { + return Ok(None); + }; + if matches!(first, '/' | '!' | '?') { + cursor = start + 1; + continue; + } + let end = text[start..] + .find('>') + .map(|offset| start + offset) + .ok_or_else(|| anyhow!("ClickOnce XML tag is not closed"))?; + let name_start = start + 1; + let name_end = text[name_start..=end] + .find(|ch: char| ch.is_whitespace() || ch == '>' || ch == '/') + .map(|offset| name_start + offset) + .unwrap_or(end); + let name = &text[name_start..name_end]; + let name_local = name + .rsplit_once(':') + .map(|(_, local)| local) + .unwrap_or(name); + if name_local == local_name { + return Ok(Some(XmlStartTagSpan { + start, + end, + name: name.to_owned(), + })); + } + cursor = end + 1; + } + Ok(None) +} + +fn find_xml_element_span_by_local_name( + text: &str, + local_name: &str, + from: usize, +) -> Result> { + let Some(start_tag) = find_xml_start_tag_by_local_name(text, local_name, from)? else { + return Ok(None); + }; + let close = format!("", start_tag.name); + let content_start = start_tag.end + 1; + let close_start = text[content_start..] + .find(&close) + .map(|offset| content_start + offset) + .ok_or_else(|| anyhow!("ClickOnce XML tag is not closed", start_tag.name))?; + Ok(Some((start_tag.start, close_start + close.len()))) +} + +fn find_xml_root_start_tag(text: &str) -> Result { + let mut cursor = 0usize; + while let Some(rel) = text[cursor..].find('<') { + let start = cursor + rel; + let Some(first) = text[start + 1..].chars().next() else { + break; + }; + if matches!(first, '?' | '!') { + let end = text[start..] + .find('>') + .map(|offset| start + offset) + .ok_or_else(|| anyhow!("ClickOnce XML declaration/comment is not closed"))?; + cursor = end + 1; + continue; + } + if first == '/' { + return Err(anyhow!( + "ClickOnce XML starts with an unexpected closing tag" + )); + } + let end = text[start..] + .find('>') + .map(|offset| start + offset) + .ok_or_else(|| anyhow!("ClickOnce XML root tag is not closed"))?; + let name_start = start + 1; + let name_end = text[name_start..=end] + .find(|ch: char| ch.is_whitespace() || ch == '>' || ch == '/') + .map(|offset| name_start + offset) + .unwrap_or(end); + return Ok(XmlStartTagSpan { + start, + end, + name: text[name_start..name_end].to_owned(), + }); + } + Err(anyhow!( + "ClickOnce manifest does not contain a root XML element" + )) +} + fn pe_has_signature(bytes: &[u8]) -> bool { psign_sip_digest::verify_pe::pe_pkcs7_signed_data_entry_count(bytes) .is_ok_and(|count| count > 0) diff --git a/tests/code_command.rs b/tests/code_command.rs index 5b43feb..58a9b09 100644 --- a/tests/code_command.rs +++ b/tests/code_command.rs @@ -485,6 +485,126 @@ fn code_signs_clickonce_deploy_pe_payload_with_local_cert_key() { .success(); } +#[test] +fn code_signs_clickonce_manifest_with_local_cert_key() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("app.exe.manifest"); + let output = base.join("app.signed.exe.manifest"); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + write_test_rsa_cert_key(&cert, &key); + std::fs::write(&input, sample_clickonce_manifest()).unwrap(); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("app.exe.manifest"); + cmd.assert() + .success() + .stdout(predicate::str::contains("ClickOnce manifest XMLDSig")); + + let mut verify = psign(); + verify + .args(["portable", "clickonce-verify-manifest-signature"]) + .arg(&output) + .arg("--trusted-ca") + .arg(&cert) + .assert() + .success() + .stdout(predicate::str::contains( + "clickonce-verify-manifest-signature: ok", + )) + .stdout(predicate::str::contains("signer_trust_chain=yes")); +} + +#[test] +fn code_signs_nested_clickonce_manifest_inside_generic_zip() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("bundle.zip"); + let output = base.join("signed.zip"); + let nested_manifest = base.join("nested.signed.manifest"); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + write_test_rsa_cert_key(&cert, &key); + write_zip( + &input, + &[ + ("readme.txt", b"ClickOnce manifest bundle".as_slice()), + ( + "publish/app.exe.manifest", + sample_clickonce_manifest().as_bytes(), + ), + ], + ); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("bundle.zip"); + cmd.assert().success(); + + extract_zip_entry(&output, "publish/app.exe.manifest", &nested_manifest); + let mut verify = psign(); + verify + .args(["portable", "clickonce-verify-manifest-signature"]) + .arg(&nested_manifest) + .arg("--trusted-ca") + .arg(&cert) + .assert() + .success() + .stdout(predicate::str::contains("signature_value_match=yes")); +} + +#[test] +fn code_rejects_clickonce_manifest_timestamping_explicitly() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("app.application"); + let output = base.join("app.signed.application"); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + write_test_rsa_cert_key(&cert, &key); + std::fs::write(&input, sample_clickonce_manifest()).unwrap(); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--timestamp-url", + "http://127.0.0.1:9/tsa", + "--timestamp-digest", + "sha256", + "--output", + ]) + .arg(&output) + .arg("app.application"); + cmd.assert().failure().stderr(predicate::str::contains( + "ClickOnce manifest XMLDSig timestamping is not implemented", + )); +} + #[test] fn code_prepares_msix_with_nested_pe_and_publisher_update() { let temp = tempfile::tempdir().unwrap(); @@ -1489,6 +1609,22 @@ fn write_zip(path: &Path, entries: &[(&str, &[u8])]) { writer.finish().unwrap(); } +fn extract_zip_entry(zip_path: &Path, entry_name: &str, output: &Path) { + let mut archive = zip::ZipArchive::new(std::fs::File::open(zip_path).unwrap()).unwrap(); + let mut entry = archive.by_name(entry_name).unwrap(); + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).unwrap(); + std::fs::write(output, bytes).unwrap(); +} + +fn sample_clickonce_manifest() -> &'static str { + r#" + + + +"# +} + #[cfg(all(feature = "timestamp-server", feature = "timestamp-http"))] struct PsignServerGuard(std::process::Child); From 11403cecf7533045d16b56f9db5bf145c2d3d94f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 22 May 2026 10:39:01 -0400 Subject: [PATCH 03/14] Handle prefixed App Installer packages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psign-digest-cli/src/main.rs | 40 +++++++++++++++++--- docs/gap-analysis-signing-platforms.md | 2 +- docs/linux-signing-pipelines.md | 2 +- docs/migration-dotnet-sign.md | 4 +- src/code.rs | 27 ++++++++++++- tests/cli_pe_digest.rs | 32 ++++++++++++++++ tests/code_command.rs | 52 ++++++++++++++++++++++++++ 7 files changed, 148 insertions(+), 11 deletions(-) diff --git a/crates/psign-digest-cli/src/main.rs b/crates/psign-digest-cli/src/main.rs index 633cc1d..ea548b7 100644 --- a/crates/psign-digest-cli/src/main.rs +++ b/crates/psign-digest-cli/src/main.rs @@ -1221,11 +1221,15 @@ fn parse_appinstaller_descriptor(text: &str) -> Result Result let escaped = xml_escape_attr(publisher); let mut updated = text.to_owned(); for tag in ["MainPackage", "MainBundle"] { - updated = update_attr_for_tags(&updated, tag, "Publisher", &escaped)?; + updated = update_attr_for_local_tags(&updated, tag, "Publisher", &escaped)?; } Ok(updated) } @@ -1362,6 +1366,27 @@ fn update_attr_for_tags(text: &str, tag: &str, attr: &str, escaped_value: &str) Ok(out) } +fn update_attr_for_local_tags( + text: &str, + local_name: &str, + attr: &str, + escaped_value: &str, +) -> Result { + let mut out = String::with_capacity(text.len()); + let mut cursor = 0usize; + while let Some(tag) = find_xml_start_tag_by_local_name(text, local_name, cursor)? { + out.push_str(&text[cursor..tag.start]); + out.push_str(&replace_or_insert_xml_attr( + &text[tag.start..=tag.end], + attr, + escaped_value, + )?); + cursor = tag.end + 1; + } + out.push_str(&text[cursor..]); + Ok(out) +} + fn replace_or_insert_xml_attr(tag: &str, attr: &str, escaped_value: &str) -> Result { let needle = format!("{attr}=\""); if let Some(value_start) = tag.find(&needle).map(|idx| idx + needle.len()) { @@ -1405,6 +1430,11 @@ fn first_tag<'a>(text: &'a str, tag: &str) -> Option<&'a str> { Some(&text[start..=end]) } +fn first_start_tag_by_local_name<'a>(text: &'a str, local_name: &str) -> Result> { + Ok(find_xml_start_tag_by_local_name(text, local_name, 0)? + .map(|tag| &text[tag.start..=tag.end])) +} + fn xml_attr(tag: &str, name: &str) -> Option { let needle = format!("{name}=\""); let start = tag.find(&needle)? + needle.len(); diff --git a/docs/gap-analysis-signing-platforms.md b/docs/gap-analysis-signing-platforms.md index 114ec8d..c98ecd7 100644 --- a/docs/gap-analysis-signing-platforms.md +++ b/docs/gap-analysis-signing-platforms.md @@ -55,7 +55,7 @@ This inventory starts from the in-tree supported formats, then expands to inbox | Surface | Windows mode coverage | Windows-mode gaps | Portable mode coverage | Portable-mode gaps | |---------|-----------------------|-------------------|------------------------|--------------------| | **RDP files** (`.rdp`) | Implemented `rdp` path using Windows certificate stores. | Mostly fixture breadth and native `rdpsign.exe` output-shape parity. | Implemented `portable rdp` with local cert/key or external detached PKCS#7. | No Windows store selection or native `rdpsign.exe` integration by design. | -| **App Installer descriptors** (`.appinstaller`) | Direct embedded signing is rejected by current SignTool; descriptor signing can be represented as unsigned XML plus a PKCS#7 companion artifact generated with SignTool `/p7`. | No full XML+companion signing/verification UX or native parity wrapper. | `appinstaller-info` inspects descriptor metadata and companion `.p7`; `appinstaller-set-publisher` updates MainPackage/MainBundle publisher metadata; `appinstaller-sign-companion` creates a local RSA/SHA-2 detached PKCS#7 companion with optional RFC3161 timestamping; `appinstaller-sign-companion-prehash` + `appinstaller-sign-companion-from-signature` assemble companion CMS from external RSA signatures; detached trust primitives verify the XML plus companion signature; `psign-tool code` can update publisher metadata and create top-level companion signatures with local cert/key and explicit output. | No App Installer-specific policy checks, nested orchestration, or direct cloud-provider integration in the `code` orchestrator. | +| **App Installer descriptors** (`.appinstaller`) | Direct embedded signing is rejected by current SignTool; descriptor signing can be represented as unsigned XML plus a PKCS#7 companion artifact generated with SignTool `/p7`. | No full XML+companion signing/verification UX or native parity wrapper. | `appinstaller-info` inspects descriptor metadata and companion `.p7`; `appinstaller-set-publisher` updates namespace-aware MainPackage/MainBundle publisher metadata; `appinstaller-sign-companion` creates a local RSA/SHA-2 detached PKCS#7 companion with optional RFC3161 timestamping; `appinstaller-sign-companion-prehash` + `appinstaller-sign-companion-from-signature` assemble companion CMS from external RSA signatures; detached trust primitives verify the XML plus companion signature; `psign-tool code` can update publisher metadata and create top-level companion signatures with local cert/key and explicit output. | No App Installer-specific policy checks, nested orchestration, or direct cloud-provider integration in the `code` orchestrator. | | **NuGet packages** (`.nupkg`, `.snupkg`) | Not a `signtool`/WinTrust SIP target in this repo. | No `nuget sign`-compatible author/repository signing workflow in Windows mode. | `psign-opc-sign` groundwork: marker inspection, unsigned package digest, NuGet v1 signature-content generation/verification, local RSA/SHA-2 CMS generation for signature content with RFC3161 timestamp tokens, external-signer CMS assembly via `nupkg-signature-pkcs7-prehash` + `nupkg-signature-pkcs7-from-signature`, `.signature.p7s` embed/overwrite, one-step local `nupkg-sign`, embedded `.signature.p7s` package hash + explicit-anchor CMS verification, top-level local `psign-tool code` execution, package-native nested execution when embedded in VSIX/ZIP containers, nested PE/WinMD signing before package signatures, nested exclude filters, `--skip-signed`, and `--overwrite`. | No repository signatures, full NuGet policy verification, non-PE Authenticode nested payload signing, or direct cloud-provider integration in the `code` orchestrator. | | **VSIX packages** (`.vsix`) | Not a first-class Windows-mode signing surface here. | No VSIX package signing/verification workflow. | Signature marker inspection, signature XML embed primitive with OPC signature content-type and relationship metadata, deterministic XMLDSig Reference/DigestValue generation/verification for package parts, local RSA/SHA-2 XMLDSig `SignatureValue` generation/verification, external-signer XMLDSig assembly via `vsix-signature-xml-prehash` + `vsix-signature-xml-from-signature`, one-step local `vsix-sign`, embedded OPC XMLDSig verification with optional explicit-anchor signer chain validation, top-level local `psign-tool code` execution, package-native nested VSIX/ZIP -> NuGet/VSIX -> PE/WinMD execution, nested exclude filters, `--skip-signed`, and `--overwrite`. | No timestamping, non-PE Authenticode nested payload signing, or direct cloud-provider integration in the `code` orchestrator. | | **ClickOnce / VSTO manifests** (`.manifest`, `.application`, `.vsto`, `.deploy` workflows) | Not implemented. | No `mage.exe`/manifest XMLDSig workflow, certificate embedding, or timestamping. | `psign-tool code --dry-run` classifies ClickOnce/VSTO workflow nodes; portable helpers inspect/copy `.deploy` payloads; guarded `psign-tool code` execution signs PE-like `.deploy` payloads and `.manifest` / `.application` / `.vsto` XMLDSig manifests with local cert/key; portable helpers update and verify manifest file size/digest references; `clickonce-sign-manifest` / `clickonce-sign-manifest-prehash` / `clickonce-sign-manifest-from-signature` / `clickonce-verify-manifest-signature` provide deterministic portable structural local/external XMLDSig signing with embedded signer certificate. | Full Mage-compatible canonicalization/policy, timestamping, full deployment graph orchestration, and ClickOnce/VSTO policy checks remain. | diff --git a/docs/linux-signing-pipelines.md b/docs/linux-signing-pipelines.md index 13cb59c..2f7fd6c 100644 --- a/docs/linux-signing-pipelines.md +++ b/docs/linux-signing-pipelines.md @@ -142,7 +142,7 @@ psign-tool portable clickonce-sign-manifest-from-signature updated.manifest --ce psign-tool portable clickonce-verify-manifest-signature signed.manifest --trusted-ca signer.der ``` -These commands do not yet replace `dotnet/sign` for production recursive package signing. They cover deterministic package hashing/reference generation, local PE/WinMD Authenticode signing, local and external-signer NuGet/App Installer CMS signing, NuGet external-signer CMS assembly via `nupkg-signature-pkcs7-prehash` + `nupkg-signature-pkcs7-from-signature`, local and external-signer VSIX XMLDSig signing with optional explicit-anchor signer chain verification, unsigned MSIX/AppX publisher/block-map prepare, encrypted MSIX/AppX OS-only diagnostics, App Installer publisher update before companion signing, marker embedding, package-native nested VSIX/ZIP/MSIX -> NuGet -> PE/ClickOnce-manifest signing, PE-like ClickOnce `.deploy` payload signing, ClickOnce manifest file hash update/verification plus local/external deterministic portable structural XMLDSig signing, nested exclude filters, and metadata inspection/update while final MSIX signing and full manifest/policy checks are being completed. +These commands do not yet replace `dotnet/sign` for production recursive package signing. They cover deterministic package hashing/reference generation, local PE/WinMD Authenticode signing, local and external-signer NuGet/App Installer CMS signing, NuGet external-signer CMS assembly via `nupkg-signature-pkcs7-prehash` + `nupkg-signature-pkcs7-from-signature`, local and external-signer VSIX XMLDSig signing with optional explicit-anchor signer chain verification, unsigned MSIX/AppX publisher/block-map prepare, encrypted MSIX/AppX OS-only diagnostics, namespace-aware App Installer publisher update before companion signing, marker embedding, package-native nested VSIX/ZIP/MSIX -> NuGet -> PE/ClickOnce-manifest signing, PE-like ClickOnce `.deploy` payload signing, ClickOnce manifest file hash update/verification plus local/external deterministic portable structural XMLDSig signing, nested exclude filters, and metadata inspection/update while final MSIX signing and full manifest/policy checks are being completed. ## 1.5 RFC 3161 TSA query/reply (DER only; no embed) diff --git a/docs/migration-dotnet-sign.md b/docs/migration-dotnet-sign.md index 14e90d6..6451fcb 100644 --- a/docs/migration-dotnet-sign.md +++ b/docs/migration-dotnet-sign.md @@ -29,7 +29,7 @@ | App Installer descriptor inspection | Implemented | `appinstaller-info app.appinstaller --signature app.appinstaller.p7` | | App Installer companion signature verification | Implemented explicit-anchor detached trust path | `appinstaller-verify-companion --signature app.appinstaller.p7 --anchor-dir anchors` | | App Installer companion signature generation | Implemented local and external-signer RSA/SHA-2 detached PKCS#7 companion generation with optional RFC3161 timestamping | `appinstaller-sign-companion --cert signer.der --key signer.pkcs8 --timestamp-url http://tsa --timestamp-digest sha256 --output app.appinstaller.p7`, `appinstaller-sign-companion-prehash --encoding raw --output prehash.bin`, `appinstaller-sign-companion-from-signature --cert signer.der --signature remote.sig --output app.appinstaller.p7` | -| App Installer publisher metadata update | Implemented in portable helper and `code` companion signing | `appinstaller-set-publisher --publisher "CN=Example" --output updated.appinstaller`, `psign-tool code --publisher-name "CN=Example" --output updated.appinstaller.p7 app.appinstaller` | +| App Installer publisher metadata update | Implemented in portable helper and `code` companion signing, including namespace-prefixed `MainPackage` / `MainBundle` tags | `appinstaller-set-publisher --publisher "CN=Example" --output updated.appinstaller`, `psign-tool code --publisher-name "CN=Example" --output updated.appinstaller.p7 app.appinstaller` | | Business Central `.app` NAVX recognition | Implemented diagnostics, planner classification, and explicit execution gap diagnostic | `business-central-app-info package.app` | | MSIX/AppX manifest Identity inspection/update | Implemented unsigned-package helper plus guarded `code` prepare execution and encrypted-package OS-only diagnostics | `msix-manifest-info`, `msix-set-publisher`, `psign-tool code --publisher-name "CN=Publisher" --output prepared.msix app.msix` | | ClickOnce `.deploy` payload handling | Implemented copy-out primitive and guarded PE-like payload signing through `code` | `clickonce-deploy-info`, `clickonce-copy-deploy-payload`, `psign-tool code --cert signer.der --key signer.pkcs8 --output app.signed.exe.deploy app.exe.deploy` | @@ -46,7 +46,7 @@ The remaining dotnet/sign feature gaps are execution and policy work: - VSIX support now produces and verifies deterministic local or external-signer RSA/SHA-2 XMLDSig `SignatureValue` bytes, writes the package-level OPC signature content types and relationships, and can optionally validate the XMLDSig signer certificate chain against explicit anchors, but still lacks timestamping and direct cloud-provider integration in the `code` orchestrator. - ClickOnce/VSTO support includes classification, `.deploy` payload copy-out helpers, guarded local signing for PE-like `.deploy` payloads, portable manifest file hash update/verification, deterministic portable structural local/external XMLDSig manifest signing/verification with embedded signer certificate, and guarded `psign-tool code` routing for top-level or nested manifest XMLDSig signing; timestamping, full Mage-compatible XML canonicalization/policy, and full deployment graph signing remain. - MSIX/AppX `code` execution prepares unsigned cleartext packages by signing nested entries, updating `AppxManifest.xml` Publisher from `--publisher-name`, and regenerating `AppxBlockMap.xml`; encrypted `.eappx`/`.emsix` packages are classified with explicit Windows AppxSip OS-delegation diagnostics; final package signing still uses the existing Windows SignerSignEx3/AppX path. -- App Installer local/external companion generation, RFC3161 timestamping, publisher update before companion signing, and explicit-anchor detached verification exist; full App Installer policy checks remain. +- App Installer local/external companion generation, RFC3161 timestamping, namespace-aware publisher update before companion signing, and explicit-anchor detached verification exist; full App Installer policy checks remain. ## Migration workflow today diff --git a/src/code.rs b/src/code.rs index 9e7fb75..303e2fe 100644 --- a/src/code.rs +++ b/src/code.rs @@ -1165,7 +1165,7 @@ fn update_appinstaller_publisher_bytes(bytes: &[u8], publisher: &str) -> Result< let escaped = xml_escape_attr(publisher); let mut updated = text.to_owned(); for tag in ["MainPackage", "MainBundle"] { - updated = update_attr_for_tags(&updated, tag, "Publisher", &escaped)?; + updated = update_attr_for_local_tags(&updated, tag, "Publisher", &escaped)?; } Ok(updated.into_bytes()) } @@ -1259,6 +1259,27 @@ fn update_attr_for_tags(text: &str, tag: &str, attr: &str, escaped_value: &str) Ok(out) } +fn update_attr_for_local_tags( + text: &str, + local_name: &str, + attr: &str, + escaped_value: &str, +) -> Result { + let mut out = String::with_capacity(text.len()); + let mut cursor = 0usize; + while let Some(tag) = find_xml_start_tag_by_local_name(text, local_name, cursor)? { + out.push_str(&text[cursor..tag.start]); + out.push_str(&replace_or_insert_xml_attr( + &text[tag.start..=tag.end], + attr, + escaped_value, + )?); + cursor = tag.end + 1; + } + out.push_str(&text[cursor..]); + Ok(out) +} + fn replace_or_insert_xml_attr(tag: &str, attr: &str, escaped_value: &str) -> Result { let needle = format!("{attr}=\""); if let Some(value_start) = tag.find(&needle).map(|idx| idx + needle.len()) { @@ -1763,7 +1784,9 @@ fn validate_appinstaller_descriptor(bytes: &[u8]) -> Result<()> { "App Installer descriptor root not found" )); } - if !text.contains(""#, + ) + .unwrap(); + + let mut cmd = portable_cmd(); + cmd.arg("appinstaller-set-publisher") + .arg(&descriptor) + .arg("--publisher") + .arg("CN=Updated Publisher") + .arg("--output") + .arg(&output); + cmd.assert().success(); + + let xml = std::fs::read_to_string(&output).unwrap(); + assert!(xml.contains(r#""#, + ) + .unwrap(); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--publisher-name", + "CN=Updated Bundle Publisher", + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&signature) + .arg("prefixed.appinstaller"); + cmd.assert().success(); + + let xml = std::fs::read_to_string(&descriptor).unwrap(); + assert!(xml.contains(r#" Date: Fri, 22 May 2026 10:48:01 -0400 Subject: [PATCH 04/14] Propagate MSIX upload publisher updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- docs/gap-analysis-signing-platforms.md | 2 +- docs/linux-signing-pipelines.md | 2 +- docs/migration-dotnet-sign.md | 2 +- docs/psign-cli-matrix.json | 2 +- src/code.rs | 26 ++++++++- tests/code_command.rs | 81 ++++++++++++++++++++++++++ 7 files changed, 109 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 928b702..64745b4 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Canonical repository: . - `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). - `cert-store`: Portable file-backed certificate store under `~/.psign/cert-store` by default, with Windows-style store/thumbprint selection. -- `code`: dotnet/sign-style orchestration entry point. It supports `--dry-run` / `--plan-json` planning over inputs, file lists, globs, and nested ZIP/OPC containers, plus guarded local cert/key execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, MSIX/AppX unsigned-package prepare, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer publisher updates + companion signatures, `--continue-on-error`, `--skip-signed`, `--overwrite`, and inside-out VSIX/ZIP -> NuGet/VSIX -> PE/ClickOnce-manifest signing. +- `code`: dotnet/sign-style orchestration entry point. It supports `--dry-run` / `--plan-json` planning over inputs, file lists, globs, and nested ZIP/OPC containers, plus guarded local cert/key execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, MSIX/AppX unsigned-package prepare including nested packages inside upload/bundle containers, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer publisher updates + companion signatures, `--continue-on-error`, `--skip-signed`, `--overwrite`, and inside-out VSIX/ZIP -> NuGet/VSIX -> PE/ClickOnce-manifest signing. - `portable ...`: Cross-platform digest, verification, trust, signing, package, RFC3161, and remote-hash helpers that avoid Win32 APIs, including PE/WinMD signing through Azure Artifact Signing REST without Microsoft client DLLs. ## MSIX parity notes diff --git a/docs/gap-analysis-signing-platforms.md b/docs/gap-analysis-signing-platforms.md index c98ecd7..399e270 100644 --- a/docs/gap-analysis-signing-platforms.md +++ b/docs/gap-analysis-signing-platforms.md @@ -42,7 +42,7 @@ This inventory starts from the in-tree supported formats, then expands to inbox | **Catalog** (`.cat`) and driver-package catalogs | Catalog verify paths and `catdb`; can Authenticode-sign an existing `.cat`. | No catalog authoring (`MakeCat`/`Inf2Cat`/`New-FileCatalog` equivalent) or full driver-package workflow. | `sign-catalog` for portable generic CTL catalogs, `verify-catalog`, `verify-catalog-member` for explicit file + MakeCat/psign catalog inputs, `trust-verify-catalog`, catalog PKCS#7 consistency, signer prehash. | No `CryptCATAdmin` database search, driver/INF policy, OS catalog stores, catalog-store revocation policy, or MakeCat byte-for-byte output. | | **MSI family** (`.msi`, `.msp`, `.mst`) | Sign/verify through `MSISIP.DLL`. | Generic SIP remove is not implemented; optional parity corpus depends on external fixtures. | `verify-msi`, local RSA `sign-msi` through the `DigitalSignature` stream, PKCS#7 extraction/prehash. | No timestamp embed, `MsiDigitalSignatureEx` authoring, or installer policy branches such as `DisableSizeVerification` / `DisableLegacyVerification`. | | **WIM / ESD** (`.wim`, `.esd`) | Sign/verify through `EsdSip.dll`. | Positive parity fixtures are limited; no remove. | `verify-esd`. | No WIM/ESD signing/embed, timestamp embed, or WinTrust policy equivalent. | -| **Cleartext AppX/MSIX** (`.appx`, `.msix`, `.appxbundle`, `.msixbundle`) | Sign/verify with AppX client data and dlib bridge. | Remaining native parity failures can occur around `SignerSignEx3` AppX glue, publisher binding, sealing, and package constraints. | `verify-msix` digest consistency; `msix-manifest-info` / `msix-set-publisher`; guarded `psign-tool code` prepare execution signs nested PE/package entries, updates `AppxManifest.xml` Publisher from `--publisher-name`, regenerates `AppxBlockMap.xml`, and rejects already-final-signed `AppxSignature.p7x` packages before final AppX SIP signing. | No `AppxSipCreateIndirectData` equivalent, package PKCS#7 embed, timestamp/signing, manifest publisher-vs-signer policy, or full package policy. | +| **Cleartext AppX/MSIX** (`.appx`, `.msix`, `.appxbundle`, `.msixbundle`, `.appxupload`, `.msixupload`) | Sign/verify with AppX client data and dlib bridge. | Remaining native parity failures can occur around `SignerSignEx3` AppX glue, publisher binding, sealing, and package constraints. | `verify-msix` digest consistency; `msix-manifest-info` / `msix-set-publisher`; guarded `psign-tool code` prepare execution signs nested PE/package entries, updates `AppxManifest.xml` Publisher from `--publisher-name`, regenerates `AppxBlockMap.xml`, propagates publisher updates into nested packages inside upload/bundle containers, and rejects already-final-signed `AppxSignature.p7x` packages before final AppX SIP signing. | No `AppxSipCreateIndirectData` equivalent, package PKCS#7 embed, timestamp/signing, manifest publisher-vs-signer policy, or full package policy. | | **Encrypted AppX/MSIX** (`.eappx`, `.emsix`, `.eappxbundle`, `.emsixbundle`) | Delegates to OS `EappxSip*` / `EappxBundleSip*`. | No in-tree understanding beyond OS delegation and parity fixtures. | Explicitly rejected by `verify-msix`, MSIX metadata helpers, and `psign-tool code` with Windows AppxSip OS-delegation diagnostics. | Encrypted package crypto/header handling is absent; ZIP-only digest logic is insufficient. | | **AppX extension SIP chain** | Delegates to installed `ExtensionsSip*` providers. | No bundled/provider-specific parity coverage; behavior depends on optional third-party SIP DLLs. | Not implemented. | No extension-provider discovery, DLL contract, or portable provider model. | | **Standalone P7X / PKCX** (`.p7x`) | OS `P7xSip*` can participate when registered; real package signatures are produced as `AppxSignature.p7x` inside signed AppX/MSIX packages. | Direct standalone `.p7x` signing is rejected by current SignTool; first-class commands for extracting/interpreting PKCX remain absent. | Raw PKCS#7 inspection/trust primitives may apply after extraction. | No dedicated PKCX/P7X container command or portable PKCX header handling. | diff --git a/docs/linux-signing-pipelines.md b/docs/linux-signing-pipelines.md index 2f7fd6c..bfc975a 100644 --- a/docs/linux-signing-pipelines.md +++ b/docs/linux-signing-pipelines.md @@ -89,7 +89,7 @@ This path builds Authenticode CMS locally, sends the CMS authenticated-attribute ## 1.4 Package-native helper workflows -`dotnet/sign`-style package orchestration is being added through `psign-tool code` and package-native helpers. The command can plan nested graphs and has guarded local cert/key execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer inputs, `--continue-on-error`, `--skip-signed`, `--overwrite`, and package-native VSIX/ZIP/MSIX -> NuGet -> PE/ClickOnce-manifest nesting: +`dotnet/sign`-style package orchestration is being added through `psign-tool code` and package-native helpers. The command can plan nested graphs and has guarded local cert/key execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare including nested packages inside upload/bundle containers, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer inputs, `--continue-on-error`, `--skip-signed`, `--overwrite`, and package-native VSIX/ZIP/MSIX -> NuGet -> PE/ClickOnce-manifest nesting: ```bash psign-tool code --dry-run --plan-json --base-directory . --file-list files.txt diff --git a/docs/migration-dotnet-sign.md b/docs/migration-dotnet-sign.md index 6451fcb..88270dd 100644 --- a/docs/migration-dotnet-sign.md +++ b/docs/migration-dotnet-sign.md @@ -41,7 +41,7 @@ The remaining dotnet/sign feature gaps are execution and policy work: -- `psign-tool code` execution supports local RSA cert/key PE/WinMD Authenticode signing, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare execution, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer descriptors, `--continue-on-error`, `--max-concurrency`, `--skip-signed`, `--overwrite`, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD/ClickOnce-manifest inside-out signing. Unsupported non-PE nested Authenticode payloads still fail explicitly unless excluded by file-list filters. +- `psign-tool code` execution supports local RSA cert/key PE/WinMD Authenticode signing, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare execution including nested MSIX/AppX packages inside upload/bundle containers, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer descriptors, `--continue-on-error`, `--max-concurrency`, `--skip-signed`, `--overwrite`, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD/ClickOnce-manifest inside-out signing. Unsupported non-PE nested Authenticode payloads still fail explicitly unless excluded by file-list filters. - NuGet support does not yet wrap signature content in full NuGet author/repository signature metadata or enforce NuGet trust policy; local CMS signatures can carry RFC3161 timestamp tokens, and split external CMS assembly can consume a Key Vault/Trusted Signing-style RSA signature over `nupkg-signature-pkcs7-prehash`. - VSIX support now produces and verifies deterministic local or external-signer RSA/SHA-2 XMLDSig `SignatureValue` bytes, writes the package-level OPC signature content types and relationships, and can optionally validate the XMLDSig signer certificate chain against explicit anchors, but still lacks timestamping and direct cloud-provider integration in the `code` orchestrator. - ClickOnce/VSTO support includes classification, `.deploy` payload copy-out helpers, guarded local signing for PE-like `.deploy` payloads, portable manifest file hash update/verification, deterministic portable structural local/external XMLDSig manifest signing/verification with embedded signer certificate, and guarded `psign-tool code` routing for top-level or nested manifest XMLDSig signing; timestamping, full Mage-compatible XML canonicalization/policy, and full deployment graph signing remain. diff --git a/docs/psign-cli-matrix.json b/docs/psign-cli-matrix.json index 916af34..767607d 100644 --- a/docs/psign-cli-matrix.json +++ b/docs/psign-cli-matrix.json @@ -154,7 +154,7 @@ ], "code": [ {"native": "(dotnet/sign-style)", "rust": "code --dry-run --plan-json --base-directory --file-list ", "tier": "P1", "status": "implemented", "notes": "Plans file-list/glob selection plus nested ZIP/OPC inside-out ordering without modifying inputs."}, - {"native": "(dotnet/sign-style)", "rust": "code --cert --key --output [--max-concurrency ] [--skip-signed|--overwrite] ", "tier": "P1", "status": "partial", "notes": "Guarded execution for local RSA cert/key PE/WinMD, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare with --publisher-name and AppxBlockMap regeneration, encrypted MSIX/AppX OS-only diagnostics, PE-like ClickOnce .deploy payloads, App Installer publisher updates plus companion signatures, --continue-on-error, --max-concurrency for independent top-level inputs, --skip-signed, --overwrite, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD nested inside-out signing. Unsupported non-PE nested Authenticode payloads fail explicitly unless excluded."} + {"native": "(dotnet/sign-style)", "rust": "code --cert --key --output [--max-concurrency ] [--skip-signed|--overwrite] ", "tier": "P1", "status": "partial", "notes": "Guarded execution for local RSA cert/key PE/WinMD, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare with --publisher-name and AppxBlockMap regeneration, MSIX/AppX upload/bundle nested package prepare, encrypted MSIX/AppX OS-only diagnostics, ClickOnce .manifest/.application/.vsto XMLDSig signing, PE-like ClickOnce .deploy payloads, namespace-aware App Installer publisher updates plus companion signatures, --continue-on-error, --max-concurrency for independent top-level inputs, --skip-signed, --overwrite, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD/ClickOnce-manifest nested inside-out signing. Unsupported non-PE nested Authenticode payloads fail explicitly unless excluded."} ] }, "tier_summary": { diff --git a/src/code.rs b/src/code.rs index 303e2fe..9a96adf 100644 --- a/src/code.rs +++ b/src/code.rs @@ -273,6 +273,7 @@ fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result &nested_excludes, args.skip_signed, args.overwrite, + None, args.timestamp_url.as_deref(), args.timestamp_digest, ) @@ -377,6 +378,7 @@ fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result &nested_excludes, args.skip_signed, args.overwrite, + None, args.timestamp_url.as_deref(), args.timestamp_digest, ) @@ -409,6 +411,7 @@ fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result &nested_excludes, args.skip_signed, args.overwrite, + args.publisher_name.as_deref(), args.timestamp_url.as_deref(), args.timestamp_digest, ) @@ -443,6 +446,7 @@ fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result &nested_excludes, args.skip_signed, args.overwrite, + node.format.clone(), args.publisher_name.as_deref(), args.timestamp_url.as_deref(), args.timestamp_digest, @@ -595,6 +599,7 @@ fn sign_nuget_bytes( nested_excludes: &[String], skip_signed: bool, overwrite: bool, + publisher: Option<&str>, timestamp_url: Option<&str>, timestamp_digest: Option, ) -> Result> { @@ -613,6 +618,7 @@ fn sign_nuget_bytes( nested_excludes, skip_signed, overwrite, + publisher, timestamp_url, timestamp_digest, )?; @@ -650,6 +656,7 @@ fn sign_vsix_bytes( nested_excludes: &[String], skip_signed: bool, overwrite: bool, + publisher: Option<&str>, timestamp_url: Option<&str>, timestamp_digest: Option, ) -> Result> { @@ -668,6 +675,7 @@ fn sign_vsix_bytes( nested_excludes, skip_signed, overwrite, + publisher, timestamp_url, timestamp_digest, )?; @@ -810,6 +818,7 @@ fn sign_zip_container_bytes( nested_excludes: &[String], skip_signed: bool, overwrite: bool, + publisher: Option<&str>, timestamp_url: Option<&str>, timestamp_digest: Option, ) -> Result> { @@ -825,6 +834,7 @@ fn sign_zip_container_bytes( nested_excludes, skip_signed, overwrite, + publisher, timestamp_url, timestamp_digest, ) @@ -843,6 +853,7 @@ fn sign_nested_package_entries( nested_excludes: &[String], skip_signed: bool, overwrite: bool, + publisher: Option<&str>, timestamp_url: Option<&str>, timestamp_digest: Option, ) -> Result> { @@ -867,7 +878,7 @@ fn sign_nested_package_entries( }) { continue; } - let signed = match format { + let signed = match &format { CodeFormat::Nuget | CodeFormat::Snupkg => Some(sign_nuget_bytes( &bytes, &nested_label, @@ -879,6 +890,7 @@ fn sign_nested_package_entries( nested_excludes, skip_signed, overwrite, + None, timestamp_url, timestamp_digest, )?), @@ -894,6 +906,7 @@ fn sign_nested_package_entries( nested_excludes, skip_signed, overwrite, + None, timestamp_url, timestamp_digest, )?), @@ -944,7 +957,8 @@ fn sign_nested_package_entries( nested_excludes, skip_signed, overwrite, - None, + format.clone(), + publisher, timestamp_url, timestamp_digest, )?), @@ -1073,6 +1087,7 @@ fn prepare_msix_family_bytes( nested_excludes: &[String], skip_signed: bool, overwrite: bool, + format: CodeFormat, publisher: Option<&str>, timestamp_url: Option<&str>, timestamp_digest: Option, @@ -1090,11 +1105,16 @@ fn prepare_msix_family_bytes( nested_excludes, skip_signed, overwrite, + publisher, timestamp_url, timestamp_digest, )?; if let Some(publisher) = publisher { - updated = update_msix_manifest_publisher_bytes(&updated, label, publisher)?; + if zip_contains_entry(&updated, "AppxManifest.xml")? { + updated = update_msix_manifest_publisher_bytes(&updated, label, publisher)?; + } else if matches!(format, CodeFormat::Msix | CodeFormat::Appx) { + return Err(anyhow!("{label} is missing AppxManifest.xml")); + } } if zip_contains_entry(&updated, "AppxBlockMap.xml")? { updated = regenerate_msix_block_map_bytes(&updated, label)?; diff --git a/tests/code_command.rs b/tests/code_command.rs index 13aefdf..8ccb1c4 100644 --- a/tests/code_command.rs +++ b/tests/code_command.rs @@ -748,6 +748,87 @@ fn code_prepares_msix_with_nested_pe_and_publisher_update() { assert!(block_map.contains("AppxManifest.xml")); } +#[test] +fn code_prepares_msixupload_nested_package_with_publisher_update() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let inner = base.join("inner.msix"); + let input = base.join("sample.msixupload"); + let output = base.join("prepared.msixupload"); + let extracted = base.join("prepared-inner.msix"); + let nested_pe = base.join("app.signed.exe"); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + write_test_rsa_cert_key(&cert, &key); + write_zip( + &inner, + &[ + ( + "[Content_Types].xml", + br#""# + .as_slice(), + ), + ( + "AppxManifest.xml", + br#""# + .as_slice(), + ), + ( + "AppxBlockMap.xml", + br#""# + .as_slice(), + ), + ( + "app.exe", + &std::fs::read( + repo_root().join("tests/fixtures/pe-authenticode-upstream/tiny32.efi"), + ) + .unwrap(), + ), + ], + ); + write_zip( + &input, + &[ + ("metadata/readme.txt", b"upload container".as_slice()), + ("packages/inner.msix", &std::fs::read(&inner).unwrap()), + ], + ); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--publisher-name", + "CN=Updated Upload Publisher", + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("sample.msixupload"); + cmd.assert().success(); + + extract_zip_entry(&output, "packages/inner.msix", &extracted); + let mut info = psign(); + info.args(["portable", "msix-manifest-info"]) + .arg(&extracted) + .assert() + .success() + .stdout(predicate::str::contains( + "publisher=CN=Updated Upload Publisher", + )); + extract_zip_entry(&extracted, "app.exe", &nested_pe); + let mut verify = psign(); + verify + .args(["portable", "verify-pe"]) + .arg(&nested_pe) + .assert() + .success(); +} + #[test] fn code_classifies_encrypted_msix_as_os_only_and_fails_explicitly() { let temp = tempfile::tempdir().unwrap(); From 8efc152f306305b56cf3c8ba26f3f8885179e692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 22 May 2026 10:54:33 -0400 Subject: [PATCH 05/14] Reject unsupported VSIX timestamping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/migration-dotnet-sign.md | 2 +- src/code.rs | 5 +++++ tests/code_command.rs | 36 +++++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/migration-dotnet-sign.md b/docs/migration-dotnet-sign.md index 88270dd..c6f2d61 100644 --- a/docs/migration-dotnet-sign.md +++ b/docs/migration-dotnet-sign.md @@ -43,7 +43,7 @@ The remaining dotnet/sign feature gaps are execution and policy work: - `psign-tool code` execution supports local RSA cert/key PE/WinMD Authenticode signing, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare execution including nested MSIX/AppX packages inside upload/bundle containers, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer descriptors, `--continue-on-error`, `--max-concurrency`, `--skip-signed`, `--overwrite`, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD/ClickOnce-manifest inside-out signing. Unsupported non-PE nested Authenticode payloads still fail explicitly unless excluded by file-list filters. - NuGet support does not yet wrap signature content in full NuGet author/repository signature metadata or enforce NuGet trust policy; local CMS signatures can carry RFC3161 timestamp tokens, and split external CMS assembly can consume a Key Vault/Trusted Signing-style RSA signature over `nupkg-signature-pkcs7-prehash`. -- VSIX support now produces and verifies deterministic local or external-signer RSA/SHA-2 XMLDSig `SignatureValue` bytes, writes the package-level OPC signature content types and relationships, and can optionally validate the XMLDSig signer certificate chain against explicit anchors, but still lacks timestamping and direct cloud-provider integration in the `code` orchestrator. +- VSIX support now produces and verifies deterministic local or external-signer RSA/SHA-2 XMLDSig `SignatureValue` bytes, writes the package-level OPC signature content types and relationships, and can optionally validate the XMLDSig signer certificate chain against explicit anchors, but still lacks timestamping and direct cloud-provider integration in the `code` orchestrator; timestamp options fail explicitly rather than producing an untimestamped VSIX signature. - ClickOnce/VSTO support includes classification, `.deploy` payload copy-out helpers, guarded local signing for PE-like `.deploy` payloads, portable manifest file hash update/verification, deterministic portable structural local/external XMLDSig manifest signing/verification with embedded signer certificate, and guarded `psign-tool code` routing for top-level or nested manifest XMLDSig signing; timestamping, full Mage-compatible XML canonicalization/policy, and full deployment graph signing remain. - MSIX/AppX `code` execution prepares unsigned cleartext packages by signing nested entries, updating `AppxManifest.xml` Publisher from `--publisher-name`, and regenerating `AppxBlockMap.xml`; encrypted `.eappx`/`.emsix` packages are classified with explicit Windows AppxSip OS-delegation diagnostics; final package signing still uses the existing Windows SignerSignEx3/AppX path. - App Installer local/external companion generation, RFC3161 timestamping, namespace-aware publisher update before companion signing, and explicit-anchor detached verification exist; full App Installer policy checks remain. diff --git a/src/code.rs b/src/code.rs index 9a96adf..710169e 100644 --- a/src/code.rs +++ b/src/code.rs @@ -660,6 +660,11 @@ fn sign_vsix_bytes( timestamp_url: Option<&str>, timestamp_digest: Option, ) -> Result> { + if timestamp_url.is_some() || timestamp_digest.is_some() { + return Err(anyhow!( + "VSIX XMLDSig timestamping is not implemented in `psign-tool code` yet" + )); + } if skip_signed && package_has_signature(input_bytes, &CodeFormat::Vsix)? { return Ok(input_bytes.to_vec()); } diff --git a/tests/code_command.rs b/tests/code_command.rs index 8ccb1c4..2fb7769 100644 --- a/tests/code_command.rs +++ b/tests/code_command.rs @@ -657,6 +657,42 @@ fn code_rejects_clickonce_manifest_timestamping_explicitly() { )); } +#[test] +fn code_rejects_vsix_timestamping_explicitly() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("sample.vsix"); + let output = base.join("timestamped.vsix"); + 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/package-signing/unsigned/sample.vsix"), + &input, + ) + .unwrap(); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--timestamp-url", + "http://127.0.0.1:9/tsa", + "--timestamp-digest", + "sha256", + "--output", + ]) + .arg(&output) + .arg("sample.vsix"); + cmd.assert().failure().stderr(predicate::str::contains( + "VSIX XMLDSig timestamping is not implemented", + )); +} + #[test] fn code_prepares_msix_with_nested_pe_and_publisher_update() { let temp = tempfile::tempdir().unwrap(); From 8f5e5b49e408e080042be51f2bd9412befd74e98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 22 May 2026 11:03:57 -0400 Subject: [PATCH 06/14] Sign nested App Installer descriptors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- docs/gap-analysis-signing-platforms.md | 2 +- docs/linux-signing-pipelines.md | 2 +- docs/migration-dotnet-sign.md | 2 +- docs/psign-cli-matrix.json | 2 +- src/code.rs | 224 +++++++++++++++---------- tests/code_command.rs | 56 +++++++ 7 files changed, 201 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 64745b4..118fe5e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Canonical repository: . - `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). - `cert-store`: Portable file-backed certificate store under `~/.psign/cert-store` by default, with Windows-style store/thumbprint selection. -- `code`: dotnet/sign-style orchestration entry point. It supports `--dry-run` / `--plan-json` planning over inputs, file lists, globs, and nested ZIP/OPC containers, plus guarded local cert/key execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, MSIX/AppX unsigned-package prepare including nested packages inside upload/bundle containers, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer publisher updates + companion signatures, `--continue-on-error`, `--skip-signed`, `--overwrite`, and inside-out VSIX/ZIP -> NuGet/VSIX -> PE/ClickOnce-manifest signing. +- `code`: dotnet/sign-style orchestration entry point. It supports `--dry-run` / `--plan-json` planning over inputs, file lists, globs, and nested ZIP/OPC containers, plus guarded local cert/key execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, MSIX/AppX unsigned-package prepare including nested packages inside upload/bundle containers, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer publisher updates + top-level or nested companion signatures, `--continue-on-error`, `--skip-signed`, `--overwrite`, and inside-out VSIX/ZIP -> NuGet/VSIX -> PE/ClickOnce-manifest signing. - `portable ...`: Cross-platform digest, verification, trust, signing, package, RFC3161, and remote-hash helpers that avoid Win32 APIs, including PE/WinMD signing through Azure Artifact Signing REST without Microsoft client DLLs. ## MSIX parity notes diff --git a/docs/gap-analysis-signing-platforms.md b/docs/gap-analysis-signing-platforms.md index 399e270..d23e360 100644 --- a/docs/gap-analysis-signing-platforms.md +++ b/docs/gap-analysis-signing-platforms.md @@ -55,7 +55,7 @@ This inventory starts from the in-tree supported formats, then expands to inbox | Surface | Windows mode coverage | Windows-mode gaps | Portable mode coverage | Portable-mode gaps | |---------|-----------------------|-------------------|------------------------|--------------------| | **RDP files** (`.rdp`) | Implemented `rdp` path using Windows certificate stores. | Mostly fixture breadth and native `rdpsign.exe` output-shape parity. | Implemented `portable rdp` with local cert/key or external detached PKCS#7. | No Windows store selection or native `rdpsign.exe` integration by design. | -| **App Installer descriptors** (`.appinstaller`) | Direct embedded signing is rejected by current SignTool; descriptor signing can be represented as unsigned XML plus a PKCS#7 companion artifact generated with SignTool `/p7`. | No full XML+companion signing/verification UX or native parity wrapper. | `appinstaller-info` inspects descriptor metadata and companion `.p7`; `appinstaller-set-publisher` updates namespace-aware MainPackage/MainBundle publisher metadata; `appinstaller-sign-companion` creates a local RSA/SHA-2 detached PKCS#7 companion with optional RFC3161 timestamping; `appinstaller-sign-companion-prehash` + `appinstaller-sign-companion-from-signature` assemble companion CMS from external RSA signatures; detached trust primitives verify the XML plus companion signature; `psign-tool code` can update publisher metadata and create top-level companion signatures with local cert/key and explicit output. | No App Installer-specific policy checks, nested orchestration, or direct cloud-provider integration in the `code` orchestrator. | +| **App Installer descriptors** (`.appinstaller`) | Direct embedded signing is rejected by current SignTool; descriptor signing can be represented as unsigned XML plus a PKCS#7 companion artifact generated with SignTool `/p7`. | No full XML+companion signing/verification UX or native parity wrapper. | `appinstaller-info` inspects descriptor metadata and companion `.p7`; `appinstaller-set-publisher` updates namespace-aware MainPackage/MainBundle publisher metadata; `appinstaller-sign-companion` creates a local RSA/SHA-2 detached PKCS#7 companion with optional RFC3161 timestamping; `appinstaller-sign-companion-prehash` + `appinstaller-sign-companion-from-signature` assemble companion CMS from external RSA signatures; detached trust primitives verify the XML plus companion signature; `psign-tool code` can update publisher metadata and create top-level companion signatures with local cert/key and explicit output, and nested ZIP orchestration writes descriptor-local `.p7` companions. | No App Installer-specific policy checks or direct cloud-provider integration in the `code` orchestrator. | | **NuGet packages** (`.nupkg`, `.snupkg`) | Not a `signtool`/WinTrust SIP target in this repo. | No `nuget sign`-compatible author/repository signing workflow in Windows mode. | `psign-opc-sign` groundwork: marker inspection, unsigned package digest, NuGet v1 signature-content generation/verification, local RSA/SHA-2 CMS generation for signature content with RFC3161 timestamp tokens, external-signer CMS assembly via `nupkg-signature-pkcs7-prehash` + `nupkg-signature-pkcs7-from-signature`, `.signature.p7s` embed/overwrite, one-step local `nupkg-sign`, embedded `.signature.p7s` package hash + explicit-anchor CMS verification, top-level local `psign-tool code` execution, package-native nested execution when embedded in VSIX/ZIP containers, nested PE/WinMD signing before package signatures, nested exclude filters, `--skip-signed`, and `--overwrite`. | No repository signatures, full NuGet policy verification, non-PE Authenticode nested payload signing, or direct cloud-provider integration in the `code` orchestrator. | | **VSIX packages** (`.vsix`) | Not a first-class Windows-mode signing surface here. | No VSIX package signing/verification workflow. | Signature marker inspection, signature XML embed primitive with OPC signature content-type and relationship metadata, deterministic XMLDSig Reference/DigestValue generation/verification for package parts, local RSA/SHA-2 XMLDSig `SignatureValue` generation/verification, external-signer XMLDSig assembly via `vsix-signature-xml-prehash` + `vsix-signature-xml-from-signature`, one-step local `vsix-sign`, embedded OPC XMLDSig verification with optional explicit-anchor signer chain validation, top-level local `psign-tool code` execution, package-native nested VSIX/ZIP -> NuGet/VSIX -> PE/WinMD execution, nested exclude filters, `--skip-signed`, and `--overwrite`. | No timestamping, non-PE Authenticode nested payload signing, or direct cloud-provider integration in the `code` orchestrator. | | **ClickOnce / VSTO manifests** (`.manifest`, `.application`, `.vsto`, `.deploy` workflows) | Not implemented. | No `mage.exe`/manifest XMLDSig workflow, certificate embedding, or timestamping. | `psign-tool code --dry-run` classifies ClickOnce/VSTO workflow nodes; portable helpers inspect/copy `.deploy` payloads; guarded `psign-tool code` execution signs PE-like `.deploy` payloads and `.manifest` / `.application` / `.vsto` XMLDSig manifests with local cert/key; portable helpers update and verify manifest file size/digest references; `clickonce-sign-manifest` / `clickonce-sign-manifest-prehash` / `clickonce-sign-manifest-from-signature` / `clickonce-verify-manifest-signature` provide deterministic portable structural local/external XMLDSig signing with embedded signer certificate. | Full Mage-compatible canonicalization/policy, timestamping, full deployment graph orchestration, and ClickOnce/VSTO policy checks remain. | diff --git a/docs/linux-signing-pipelines.md b/docs/linux-signing-pipelines.md index bfc975a..f968739 100644 --- a/docs/linux-signing-pipelines.md +++ b/docs/linux-signing-pipelines.md @@ -142,7 +142,7 @@ psign-tool portable clickonce-sign-manifest-from-signature updated.manifest --ce psign-tool portable clickonce-verify-manifest-signature signed.manifest --trusted-ca signer.der ``` -These commands do not yet replace `dotnet/sign` for production recursive package signing. They cover deterministic package hashing/reference generation, local PE/WinMD Authenticode signing, local and external-signer NuGet/App Installer CMS signing, NuGet external-signer CMS assembly via `nupkg-signature-pkcs7-prehash` + `nupkg-signature-pkcs7-from-signature`, local and external-signer VSIX XMLDSig signing with optional explicit-anchor signer chain verification, unsigned MSIX/AppX publisher/block-map prepare, encrypted MSIX/AppX OS-only diagnostics, namespace-aware App Installer publisher update before companion signing, marker embedding, package-native nested VSIX/ZIP/MSIX -> NuGet -> PE/ClickOnce-manifest signing, PE-like ClickOnce `.deploy` payload signing, ClickOnce manifest file hash update/verification plus local/external deterministic portable structural XMLDSig signing, nested exclude filters, and metadata inspection/update while final MSIX signing and full manifest/policy checks are being completed. +These commands do not yet replace `dotnet/sign` for production recursive package signing. They cover deterministic package hashing/reference generation, local PE/WinMD Authenticode signing, local and external-signer NuGet/App Installer CMS signing, NuGet external-signer CMS assembly via `nupkg-signature-pkcs7-prehash` + `nupkg-signature-pkcs7-from-signature`, local and external-signer VSIX XMLDSig signing with optional explicit-anchor signer chain verification, unsigned MSIX/AppX publisher/block-map prepare, encrypted MSIX/AppX OS-only diagnostics, namespace-aware App Installer publisher update before companion signing, nested ZIP App Installer companion creation, marker embedding, package-native nested VSIX/ZIP/MSIX -> NuGet -> PE/ClickOnce-manifest signing, PE-like ClickOnce `.deploy` payload signing, ClickOnce manifest file hash update/verification plus local/external deterministic portable structural XMLDSig signing, nested exclude filters, and metadata inspection/update while final MSIX signing and full manifest/policy checks are being completed. ## 1.5 RFC 3161 TSA query/reply (DER only; no embed) diff --git a/docs/migration-dotnet-sign.md b/docs/migration-dotnet-sign.md index c6f2d61..bff11a6 100644 --- a/docs/migration-dotnet-sign.md +++ b/docs/migration-dotnet-sign.md @@ -41,7 +41,7 @@ The remaining dotnet/sign feature gaps are execution and policy work: -- `psign-tool code` execution supports local RSA cert/key PE/WinMD Authenticode signing, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare execution including nested MSIX/AppX packages inside upload/bundle containers, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer descriptors, `--continue-on-error`, `--max-concurrency`, `--skip-signed`, `--overwrite`, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD/ClickOnce-manifest inside-out signing. Unsupported non-PE nested Authenticode payloads still fail explicitly unless excluded by file-list filters. +- `psign-tool code` execution supports local RSA cert/key PE/WinMD Authenticode signing, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare execution including nested MSIX/AppX packages inside upload/bundle containers, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer descriptors including nested descriptors in ZIP containers, `--continue-on-error`, `--max-concurrency`, `--skip-signed`, `--overwrite`, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD/ClickOnce-manifest/App-Installer-companion inside-out signing. Unsupported non-PE nested Authenticode payloads still fail explicitly unless excluded by file-list filters. - NuGet support does not yet wrap signature content in full NuGet author/repository signature metadata or enforce NuGet trust policy; local CMS signatures can carry RFC3161 timestamp tokens, and split external CMS assembly can consume a Key Vault/Trusted Signing-style RSA signature over `nupkg-signature-pkcs7-prehash`. - VSIX support now produces and verifies deterministic local or external-signer RSA/SHA-2 XMLDSig `SignatureValue` bytes, writes the package-level OPC signature content types and relationships, and can optionally validate the XMLDSig signer certificate chain against explicit anchors, but still lacks timestamping and direct cloud-provider integration in the `code` orchestrator; timestamp options fail explicitly rather than producing an untimestamped VSIX signature. - ClickOnce/VSTO support includes classification, `.deploy` payload copy-out helpers, guarded local signing for PE-like `.deploy` payloads, portable manifest file hash update/verification, deterministic portable structural local/external XMLDSig manifest signing/verification with embedded signer certificate, and guarded `psign-tool code` routing for top-level or nested manifest XMLDSig signing; timestamping, full Mage-compatible XML canonicalization/policy, and full deployment graph signing remain. diff --git a/docs/psign-cli-matrix.json b/docs/psign-cli-matrix.json index 767607d..75ffbe5 100644 --- a/docs/psign-cli-matrix.json +++ b/docs/psign-cli-matrix.json @@ -154,7 +154,7 @@ ], "code": [ {"native": "(dotnet/sign-style)", "rust": "code --dry-run --plan-json --base-directory --file-list ", "tier": "P1", "status": "implemented", "notes": "Plans file-list/glob selection plus nested ZIP/OPC inside-out ordering without modifying inputs."}, - {"native": "(dotnet/sign-style)", "rust": "code --cert --key --output [--max-concurrency ] [--skip-signed|--overwrite] ", "tier": "P1", "status": "partial", "notes": "Guarded execution for local RSA cert/key PE/WinMD, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare with --publisher-name and AppxBlockMap regeneration, MSIX/AppX upload/bundle nested package prepare, encrypted MSIX/AppX OS-only diagnostics, ClickOnce .manifest/.application/.vsto XMLDSig signing, PE-like ClickOnce .deploy payloads, namespace-aware App Installer publisher updates plus companion signatures, --continue-on-error, --max-concurrency for independent top-level inputs, --skip-signed, --overwrite, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD/ClickOnce-manifest nested inside-out signing. Unsupported non-PE nested Authenticode payloads fail explicitly unless excluded."} + {"native": "(dotnet/sign-style)", "rust": "code --cert --key --output [--max-concurrency ] [--skip-signed|--overwrite] ", "tier": "P1", "status": "partial", "notes": "Guarded execution for local RSA cert/key PE/WinMD, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare with --publisher-name and AppxBlockMap regeneration, MSIX/AppX upload/bundle nested package prepare, encrypted MSIX/AppX OS-only diagnostics, ClickOnce .manifest/.application/.vsto XMLDSig signing, PE-like ClickOnce .deploy payloads, namespace-aware App Installer publisher updates plus top-level and nested ZIP companion signatures, --continue-on-error, --max-concurrency for independent top-level inputs, --skip-signed, --overwrite, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD/ClickOnce-manifest/App-Installer-companion nested inside-out signing. Unsupported non-PE nested Authenticode payloads fail explicitly unless excluded."} ] }, "tier_summary": { diff --git a/src/code.rs b/src/code.rs index 710169e..1f4d83f 100644 --- a/src/code.rs +++ b/src/code.rs @@ -883,104 +883,160 @@ fn sign_nested_package_entries( }) { continue; } - let signed = match &format { - CodeFormat::Nuget | CodeFormat::Snupkg => Some(sign_nuget_bytes( - &bytes, - &nested_label, - digest, - signing_digest, - cert, - key, - chain_certs.clone(), - nested_excludes, - skip_signed, - overwrite, - None, - timestamp_url, - timestamp_digest, - )?), - CodeFormat::Vsix => Some(sign_vsix_bytes( - &bytes, - &nested_label, - digest, - signing_digest, - vsix_digest, - cert, - key, - chain_certs.clone(), - nested_excludes, - skip_signed, - overwrite, - None, - timestamp_url, - timestamp_digest, - )?), + let mut entry_updates = Vec::new(); + match &format { + CodeFormat::Nuget | CodeFormat::Snupkg => entry_updates.push(ZipEntryUpdate { + name, + bytes: sign_nuget_bytes( + &bytes, + &nested_label, + digest, + signing_digest, + cert, + key, + chain_certs.clone(), + nested_excludes, + skip_signed, + overwrite, + None, + timestamp_url, + timestamp_digest, + )?, + compression, + }), + CodeFormat::Vsix => entry_updates.push(ZipEntryUpdate { + name, + bytes: sign_vsix_bytes( + &bytes, + &nested_label, + digest, + signing_digest, + vsix_digest, + cert, + key, + chain_certs.clone(), + nested_excludes, + skip_signed, + overwrite, + None, + timestamp_url, + timestamp_digest, + )?, + compression, + }), + CodeFormat::AppInstaller => { + validate_appinstaller_descriptor(&bytes) + .with_context(|| format!("validate nested App Installer {nested_label}"))?; + let mut descriptor = bytes; + if let Some(publisher) = publisher { + descriptor = update_appinstaller_publisher_bytes(&descriptor, publisher) + .with_context(|| { + format!("update nested App Installer publisher for {nested_label}") + })?; + entry_updates.push(ZipEntryUpdate { + name: name.clone(), + bytes: descriptor.clone(), + compression, + }); + } + let companion_name = format!("{name}.p7"); + if !overwrite && zip_contains_entry(input_bytes, &companion_name)? { + return Err(anyhow!( + "{nested_label} already has companion signature {companion_name}; use --overwrite to replace it" + )); + } + let pkcs7 = sign_pkcs7_id_data( + &descriptor, + cert, + key, + chain_certs.clone(), + signing_digest, + timestamp_url, + timestamp_digest, + ) + .with_context(|| { + format!("create nested App Installer companion signature for {nested_label}") + })?; + entry_updates.push(ZipEntryUpdate { + name: companion_name, + bytes: pkcs7, + compression: zip::CompressionMethod::Stored, + }); + } CodeFormat::ClickOnceApplication | CodeFormat::Vsto | CodeFormat::Manifest => { - if skip_signed && clickonce_manifest_has_signature(&bytes) { - None - } else { - Some(sign_clickonce_manifest_bytes( - &bytes, - &nested_label, - cert, - key, - vsix_digest, - timestamp_url, - timestamp_digest, - )?) + if !(skip_signed && clickonce_manifest_has_signature(&bytes)) { + entry_updates.push(ZipEntryUpdate { + name, + bytes: sign_clickonce_manifest_bytes( + &bytes, + &nested_label, + cert, + key, + vsix_digest, + timestamp_url, + timestamp_digest, + )?, + compression, + }); } } - CodeFormat::Deploy => Some(sign_clickonce_deploy_bytes( - &bytes, - &nested_label, - cert, - key, - signing_digest, - )?), - CodeFormat::Pe | CodeFormat::Winmd => Some(sign_pe_bytes( - &bytes, - &nested_label, - cert, - key, - signing_digest, - skip_signed, - )?), + CodeFormat::Deploy => entry_updates.push(ZipEntryUpdate { + name, + bytes: sign_clickonce_deploy_bytes( + &bytes, + &nested_label, + cert, + key, + signing_digest, + )?, + compression, + }), + CodeFormat::Pe | CodeFormat::Winmd => entry_updates.push(ZipEntryUpdate { + name, + bytes: sign_pe_bytes( + &bytes, + &nested_label, + cert, + key, + signing_digest, + skip_signed, + )?, + compression, + }), CodeFormat::Msix | CodeFormat::Appx | CodeFormat::MsixBundle | CodeFormat::AppxBundle | CodeFormat::AppxUpload - | CodeFormat::MsixUpload => Some(prepare_msix_family_bytes( - &bytes, - &nested_label, - digest, - signing_digest, - vsix_digest, - cert, - key, - chain_certs.clone(), - nested_excludes, - skip_signed, - overwrite, - format.clone(), - publisher, - timestamp_url, - timestamp_digest, - )?), + | CodeFormat::MsixUpload => entry_updates.push(ZipEntryUpdate { + name, + bytes: prepare_msix_family_bytes( + &bytes, + &nested_label, + digest, + signing_digest, + vsix_digest, + cert, + key, + chain_certs.clone(), + nested_excludes, + skip_signed, + overwrite, + format.clone(), + publisher, + timestamp_url, + timestamp_digest, + )?, + compression, + }), _ if is_unsupported_nested_signable(&format) => { return Err(anyhow!( "`psign-tool code` nested execution cannot sign {nested_label} yet ({format:?})" )); } - _ => None, - }; - if let Some(bytes) = signed { - updates.push(ZipEntryUpdate { - name, - bytes, - compression, - }); + _ => {} } + updates.extend(entry_updates); } drop(archive); diff --git a/tests/code_command.rs b/tests/code_command.rs index 2fb7769..6b37f7a 100644 --- a/tests/code_command.rs +++ b/tests/code_command.rs @@ -498,6 +498,62 @@ fn code_updates_prefixed_appinstaller_main_bundle_before_signing() { )); } +#[test] +fn code_signs_nested_appinstaller_inside_generic_zip() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("bundle.zip"); + let output = base.join("signed-bundle.zip"); + let descriptor = base.join("nested.appinstaller"); + let signature = base.join("nested.appinstaller.p7"); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + write_test_rsa_cert_key(&cert, &key); + write_zip( + &input, + &[ + ("readme.txt", b"App Installer bundle".as_slice()), + ( + "descriptors/app.appinstaller", + br#""#.as_slice(), + ), + ], + ); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--publisher-name", + "CN=Nested App Installer Publisher", + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("bundle.zip"); + cmd.assert().success(); + + extract_zip_entry(&output, "descriptors/app.appinstaller", &descriptor); + extract_zip_entry(&output, "descriptors/app.appinstaller.p7", &signature); + let mut verify = psign(); + verify + .args(["portable", "appinstaller-verify-companion"]) + .arg(&descriptor) + .args(["--signature"]) + .arg(&signature) + .args(["--trusted-ca"]) + .arg(&cert) + .args(["--allow-loose-signing-cert"]) + .assert() + .success() + .stdout(predicate::str::contains( + "appinstaller-verify-companion: ok", + )); +} + #[test] fn code_signs_clickonce_deploy_pe_payload_with_local_cert_key() { let temp = tempfile::tempdir().unwrap(); From 65254e64eebfe1f3bc13728370e0285ec87643f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 22 May 2026 11:23:39 -0400 Subject: [PATCH 07/14] Support code signing from cert store Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 3 +- docs/gap-analysis-signing-platforms.md | 2 +- docs/linux-signing-pipelines.md | 3 +- docs/migration-dotnet-sign.md | 5 +- docs/psign-cli-matrix.json | 2 +- src/cli.rs | 28 ++++++++- src/code.rs | 79 ++++++++++++++++++++++---- tests/code_command.rs | 75 +++++++++++++++++++++++- 8 files changed, 177 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 118fe5e..3013597 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Canonical repository: . - `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). - `cert-store`: Portable file-backed certificate store under `~/.psign/cert-store` by default, with Windows-style store/thumbprint selection. -- `code`: dotnet/sign-style orchestration entry point. It supports `--dry-run` / `--plan-json` planning over inputs, file lists, globs, and nested ZIP/OPC containers, plus guarded local cert/key execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, MSIX/AppX unsigned-package prepare including nested packages inside upload/bundle containers, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer publisher updates + top-level or nested companion signatures, `--continue-on-error`, `--skip-signed`, `--overwrite`, and inside-out VSIX/ZIP -> NuGet/VSIX -> PE/ClickOnce-manifest signing. +- `code`: dotnet/sign-style orchestration entry point. It supports `--dry-run` / `--plan-json` planning over inputs, file lists, globs, and nested ZIP/OPC containers, plus guarded local cert/key or portable cert-store SHA-1 execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, MSIX/AppX unsigned-package prepare including nested packages inside upload/bundle containers, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer publisher updates + top-level or nested companion signatures, `--continue-on-error`, `--skip-signed`, `--overwrite`, and inside-out VSIX/ZIP -> NuGet/VSIX -> PE/ClickOnce-manifest signing. - `portable ...`: Cross-platform digest, verification, trust, signing, package, RFC3161, and remote-hash helpers that avoid Win32 APIs, including PE/WinMD signing through Azure Artifact Signing REST without Microsoft client DLLs. ## MSIX parity notes @@ -139,6 +139,7 @@ cargo build -p psign --bin psign-tool --locked # psign-tool code --dry-run --plan-json --base-directory . --file-list files.txt # Initial guarded code execution for PE/NuGet/VSIX/ZIP/MSIX/ClickOnce/App Installer inputs: # psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.exe app.exe +# psign-tool code --base-directory . --cert-store-dir ~/.psign/cert-store --sha1 --output signed.nupkg package.nupkg # psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --timestamp-url http://tsa --timestamp-digest sha256 --output signed.nupkg package.nupkg # psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.vsix extension.vsix # psign-tool code --base-directory . --overwrite --cert signer.der --key signer.pkcs8 --output resigned.nupkg signed-package.nupkg diff --git a/docs/gap-analysis-signing-platforms.md b/docs/gap-analysis-signing-platforms.md index d23e360..d108b1a 100644 --- a/docs/gap-analysis-signing-platforms.md +++ b/docs/gap-analysis-signing-platforms.md @@ -56,7 +56,7 @@ This inventory starts from the in-tree supported formats, then expands to inbox |---------|-----------------------|-------------------|------------------------|--------------------| | **RDP files** (`.rdp`) | Implemented `rdp` path using Windows certificate stores. | Mostly fixture breadth and native `rdpsign.exe` output-shape parity. | Implemented `portable rdp` with local cert/key or external detached PKCS#7. | No Windows store selection or native `rdpsign.exe` integration by design. | | **App Installer descriptors** (`.appinstaller`) | Direct embedded signing is rejected by current SignTool; descriptor signing can be represented as unsigned XML plus a PKCS#7 companion artifact generated with SignTool `/p7`. | No full XML+companion signing/verification UX or native parity wrapper. | `appinstaller-info` inspects descriptor metadata and companion `.p7`; `appinstaller-set-publisher` updates namespace-aware MainPackage/MainBundle publisher metadata; `appinstaller-sign-companion` creates a local RSA/SHA-2 detached PKCS#7 companion with optional RFC3161 timestamping; `appinstaller-sign-companion-prehash` + `appinstaller-sign-companion-from-signature` assemble companion CMS from external RSA signatures; detached trust primitives verify the XML plus companion signature; `psign-tool code` can update publisher metadata and create top-level companion signatures with local cert/key and explicit output, and nested ZIP orchestration writes descriptor-local `.p7` companions. | No App Installer-specific policy checks or direct cloud-provider integration in the `code` orchestrator. | -| **NuGet packages** (`.nupkg`, `.snupkg`) | Not a `signtool`/WinTrust SIP target in this repo. | No `nuget sign`-compatible author/repository signing workflow in Windows mode. | `psign-opc-sign` groundwork: marker inspection, unsigned package digest, NuGet v1 signature-content generation/verification, local RSA/SHA-2 CMS generation for signature content with RFC3161 timestamp tokens, external-signer CMS assembly via `nupkg-signature-pkcs7-prehash` + `nupkg-signature-pkcs7-from-signature`, `.signature.p7s` embed/overwrite, one-step local `nupkg-sign`, embedded `.signature.p7s` package hash + explicit-anchor CMS verification, top-level local `psign-tool code` execution, package-native nested execution when embedded in VSIX/ZIP containers, nested PE/WinMD signing before package signatures, nested exclude filters, `--skip-signed`, and `--overwrite`. | No repository signatures, full NuGet policy verification, non-PE Authenticode nested payload signing, or direct cloud-provider integration in the `code` orchestrator. | +| **NuGet packages** (`.nupkg`, `.snupkg`) | Not a `signtool`/WinTrust SIP target in this repo. | No `nuget sign`-compatible author/repository signing workflow in Windows mode. | `psign-opc-sign` groundwork: marker inspection, unsigned package digest, NuGet v1 signature-content generation/verification, local RSA/SHA-2 CMS generation for signature content with RFC3161 timestamp tokens, external-signer CMS assembly via `nupkg-signature-pkcs7-prehash` + `nupkg-signature-pkcs7-from-signature`, `.signature.p7s` embed/overwrite, one-step local `nupkg-sign`, embedded `.signature.p7s` package hash + explicit-anchor CMS verification, top-level local `psign-tool code` execution from explicit cert/key or portable cert-store SHA-1 identity, package-native nested execution when embedded in VSIX/ZIP containers, nested PE/WinMD signing before package signatures, nested exclude filters, `--skip-signed`, and `--overwrite`. | No repository signatures, full NuGet policy verification, non-PE Authenticode nested payload signing, or direct cloud-provider integration in the `code` orchestrator. | | **VSIX packages** (`.vsix`) | Not a first-class Windows-mode signing surface here. | No VSIX package signing/verification workflow. | Signature marker inspection, signature XML embed primitive with OPC signature content-type and relationship metadata, deterministic XMLDSig Reference/DigestValue generation/verification for package parts, local RSA/SHA-2 XMLDSig `SignatureValue` generation/verification, external-signer XMLDSig assembly via `vsix-signature-xml-prehash` + `vsix-signature-xml-from-signature`, one-step local `vsix-sign`, embedded OPC XMLDSig verification with optional explicit-anchor signer chain validation, top-level local `psign-tool code` execution, package-native nested VSIX/ZIP -> NuGet/VSIX -> PE/WinMD execution, nested exclude filters, `--skip-signed`, and `--overwrite`. | No timestamping, non-PE Authenticode nested payload signing, or direct cloud-provider integration in the `code` orchestrator. | | **ClickOnce / VSTO manifests** (`.manifest`, `.application`, `.vsto`, `.deploy` workflows) | Not implemented. | No `mage.exe`/manifest XMLDSig workflow, certificate embedding, or timestamping. | `psign-tool code --dry-run` classifies ClickOnce/VSTO workflow nodes; portable helpers inspect/copy `.deploy` payloads; guarded `psign-tool code` execution signs PE-like `.deploy` payloads and `.manifest` / `.application` / `.vsto` XMLDSig manifests with local cert/key; portable helpers update and verify manifest file size/digest references; `clickonce-sign-manifest` / `clickonce-sign-manifest-prehash` / `clickonce-sign-manifest-from-signature` / `clickonce-verify-manifest-signature` provide deterministic portable structural local/external XMLDSig signing with embedded signer certificate. | Full Mage-compatible canonicalization/policy, timestamping, full deployment graph orchestration, and ClickOnce/VSTO policy checks remain. | | **Business Central `.app`** | Format-specific behavior is not implemented. | No confirmed NAVX signing/verification workflow. | `business-central-app-info` detects NAVX headers, `psign-tool code --dry-run` classifies NAVX `.app` files, and signing execution now reports a Business Central-specific unsupported diagnostic instead of silently treating them as generic files. | Actual package signing and verification policy remain pending format confirmation. | diff --git a/docs/linux-signing-pipelines.md b/docs/linux-signing-pipelines.md index f968739..d7443c2 100644 --- a/docs/linux-signing-pipelines.md +++ b/docs/linux-signing-pipelines.md @@ -89,12 +89,13 @@ This path builds Authenticode CMS locally, sends the CMS authenticated-attribute ## 1.4 Package-native helper workflows -`dotnet/sign`-style package orchestration is being added through `psign-tool code` and package-native helpers. The command can plan nested graphs and has guarded local cert/key execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare including nested packages inside upload/bundle containers, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer inputs, `--continue-on-error`, `--skip-signed`, `--overwrite`, and package-native VSIX/ZIP/MSIX -> NuGet -> PE/ClickOnce-manifest nesting: +`dotnet/sign`-style package orchestration is being added through `psign-tool code` and package-native helpers. The command can plan nested graphs and has guarded local cert/key or portable cert-store SHA-1 execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare including nested packages inside upload/bundle containers, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer inputs, `--continue-on-error`, `--skip-signed`, `--overwrite`, and package-native VSIX/ZIP/MSIX -> NuGet -> PE/ClickOnce-manifest nesting: ```bash psign-tool code --dry-run --plan-json --base-directory . --file-list files.txt psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.exe app.exe psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.nupkg package.nupkg +psign-tool code --base-directory . --cert-store-dir .psign-store --sha1 --output signed.nupkg package.nupkg psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.vsix extension.vsix psign-tool code --base-directory . --overwrite --cert signer.der --key signer.pkcs8 --output resigned.nupkg signed-package.nupkg psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.zip bundle.zip diff --git a/docs/migration-dotnet-sign.md b/docs/migration-dotnet-sign.md index bff11a6..ec9867a 100644 --- a/docs/migration-dotnet-sign.md +++ b/docs/migration-dotnet-sign.md @@ -10,8 +10,8 @@ |---------------------|--------------|---------------| | Expand files, file lists, `!` excludes, braces, ranges, and recursive globs | Implemented for dry-run planning | `psign-tool code --dry-run --plan-json --base-directory . --file-list files.txt` | | Nested package graph / inside-out ordering | Implemented for dry-run planning across ZIP/OPC containers | `psign-tool code --dry-run --plan-json package.vsix` | -| Top-level NuGet/VSIX/App Installer local execution | Implemented for local cert/key plus explicit output | `psign-tool code --cert signer.der --key signer.pkcs8 --output signed.nupkg package.nupkg` | -| Authenticode PE/WinMD execution | Implemented for top-level and nested PE/WinMD with local cert/key | `psign-tool code --cert signer.der --key signer.pkcs8 --output signed.exe app.exe` | +| Top-level NuGet/VSIX/App Installer local execution | Implemented for local cert/key or portable cert-store SHA-1 identity plus explicit output | `psign-tool code --cert signer.der --key signer.pkcs8 --output signed.nupkg package.nupkg`, `psign-tool code --cert-store-dir .psign-store --sha1 --output signed.nupkg package.nupkg` | +| Authenticode PE/WinMD execution | Implemented for top-level and nested PE/WinMD with local cert/key or portable cert-store SHA-1 identity | `psign-tool code --cert signer.der --key signer.pkcs8 --output signed.exe app.exe` | | Package-native nested execution | Implemented for VSIX/ZIP -> NuGet/VSIX -> PE/WinMD inside-out signing without unsupported non-PE inner Authenticode payloads | `psign-tool code --cert signer.der --key signer.pkcs8 --output signed.vsix extension.vsix` | | Continue after top-level errors | Implemented for `code` execution | `psign-tool code --continue-on-error --output signed-dir ...` | | Independent top-level concurrency | Implemented for `code` execution | `psign-tool code --max-concurrency 4 --output signed-dir ...` | @@ -60,6 +60,7 @@ Use guarded local execution for package-native inputs while broader Authenticode ```sh psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.nupkg package.nupkg +psign-tool code --base-directory . --cert-store-dir .psign-store --sha1 --output signed.nupkg package.nupkg psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.vsix extension.vsix psign-tool code --base-directory . --overwrite --cert signer.der --key signer.pkcs8 --output resigned.nupkg signed-package.nupkg psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.zip bundle.zip diff --git a/docs/psign-cli-matrix.json b/docs/psign-cli-matrix.json index 75ffbe5..34dad4d 100644 --- a/docs/psign-cli-matrix.json +++ b/docs/psign-cli-matrix.json @@ -154,7 +154,7 @@ ], "code": [ {"native": "(dotnet/sign-style)", "rust": "code --dry-run --plan-json --base-directory --file-list ", "tier": "P1", "status": "implemented", "notes": "Plans file-list/glob selection plus nested ZIP/OPC inside-out ordering without modifying inputs."}, - {"native": "(dotnet/sign-style)", "rust": "code --cert --key --output [--max-concurrency ] [--skip-signed|--overwrite] ", "tier": "P1", "status": "partial", "notes": "Guarded execution for local RSA cert/key PE/WinMD, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare with --publisher-name and AppxBlockMap regeneration, MSIX/AppX upload/bundle nested package prepare, encrypted MSIX/AppX OS-only diagnostics, ClickOnce .manifest/.application/.vsto XMLDSig signing, PE-like ClickOnce .deploy payloads, namespace-aware App Installer publisher updates plus top-level and nested ZIP companion signatures, --continue-on-error, --max-concurrency for independent top-level inputs, --skip-signed, --overwrite, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD/ClickOnce-manifest/App-Installer-companion nested inside-out signing. Unsupported non-PE nested Authenticode payloads fail explicitly unless excluded."} + {"native": "(dotnet/sign-style)", "rust": "code (--cert --key |--sha1 [--cert-store-dir ]) --output [--max-concurrency ] [--skip-signed|--overwrite] ", "tier": "P1", "status": "partial", "notes": "Guarded execution for local RSA cert/key or portable cert-store SHA-1 identity over PE/WinMD, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare with --publisher-name and AppxBlockMap regeneration, MSIX/AppX upload/bundle nested package prepare, encrypted MSIX/AppX OS-only diagnostics, ClickOnce .manifest/.application/.vsto XMLDSig signing, PE-like ClickOnce .deploy payloads, namespace-aware App Installer publisher updates plus top-level and nested ZIP companion signatures, --continue-on-error, --max-concurrency for independent top-level inputs, --skip-signed, --overwrite, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD/ClickOnce-manifest/App-Installer-companion nested inside-out signing. Unsupported non-PE nested Authenticode payloads fail explicitly unless excluded."} ] }, "tier_summary": { diff --git a/src/cli.rs b/src/cli.rs index d322d12..e31d6b1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -128,13 +128,35 @@ pub struct CodeArgs { #[arg(long, conflicts_with = "skip_signed")] pub overwrite: bool, /// Signer certificate as DER or PEM for initial local package signing execution. - #[arg(long, value_name = "PATH", requires = "key")] + #[arg( + long, + value_name = "PATH", + requires = "key", + conflicts_with = "cert_sha1" + )] pub cert: Option, /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM for initial local package signing execution. - #[arg(long, value_name = "PATH", requires = "cert")] + #[arg( + long, + value_name = "PATH", + requires = "cert", + conflicts_with = "cert_sha1" + )] pub key: Option, + /// Resolve a local portable cert-store signing identity by SHA-1 thumbprint. + #[arg(long = "sha1", visible_alias = "cert-sha1")] + pub cert_sha1: Option, + /// Portable certificate store base directory for `--sha1`. + #[arg(long)] + pub cert_store_dir: Option, + /// Use the LocalMachine scope in the portable certificate store for `--sha1`. + #[arg(long, visible_alias = "sm")] + pub machine_store: bool, + /// Portable certificate store name for `--sha1`. + #[arg(long = "store", visible_alias = "s", default_value = "MY")] + pub store_name: String, /// Additional certificate to include in generated package PKCS#7 signatures. - #[arg(long = "chain-cert", value_name = "PATH", requires = "cert")] + #[arg(long = "chain-cert", value_name = "PATH")] pub chain_certs: Vec, /// Build and print the signing graph without modifying files. #[arg(long)] diff --git a/src/code.rs b/src/code.rs index 1f4d83f..02f7a1b 100644 --- a/src/code.rs +++ b/src/code.rs @@ -12,6 +12,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs::File; use std::io::{Cursor, Read, Seek, SeekFrom, Write}; use std::path::{Component, Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; use x509_cert::der::{ Encode as _, asn1::{ObjectIdentifier, OctetString}, @@ -192,16 +193,9 @@ pub fn build_code_plan(args: &CodeArgs) -> Result { } fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result { - let Some(cert) = args.cert.as_deref() else { - return Err(anyhow!( - "`psign-tool code` signing execution currently requires --cert and --key for local package signing" - )); - }; - let Some(key) = args.key.as_deref() else { - return Err(anyhow!( - "`psign-tool code` signing execution currently requires --cert and --key for local package signing" - )); - }; + let signer = resolve_code_signer_paths(args)?; + let cert = signer.cert.as_path(); + let key = signer.key.as_path(); if args.output.is_none() { return Err(anyhow!( "`psign-tool code` signing execution currently requires --output to avoid in-place package mutation" @@ -1729,6 +1723,71 @@ fn timestamp_digest_bytes(digest: DigestAlgorithm, bytes: &[u8]) -> Result, +} + +impl Drop for CodeSignerPaths { + fn drop(&mut self) { + if let Some(dir) = &self.temp_dir { + let _ = std::fs::remove_dir_all(dir); + } + } +} + +fn resolve_code_signer_paths(args: &CodeArgs) -> Result { + if args.cert.is_some() || args.key.is_some() { + let cert = args.cert.clone().ok_or_else(|| { + anyhow!("`psign-tool code` signing execution requires --cert with --key") + })?; + let key = args.key.clone().ok_or_else(|| { + anyhow!("`psign-tool code` signing execution requires --key with --cert") + })?; + return Ok(CodeSignerPaths { + cert, + key, + temp_dir: None, + }); + } + + if let Some(sha1) = args.cert_sha1.as_deref() { + let identity = crate::cert_store::resolve_signing_identity( + args.cert_store_dir.as_deref(), + args.machine_store, + &args.store_name, + sha1, + )?; + let dir = unique_code_signer_temp_dir()?; + std::fs::create_dir_all(&dir) + .with_context(|| format!("create signer material temp directory {}", dir.display()))?; + let cert = dir.join("signer.der"); + let key = dir.join("signer.key"); + std::fs::write(&cert, identity.cert_der) + .with_context(|| format!("write temporary signer certificate {}", cert.display()))?; + std::fs::write(&key, identity.key_pem) + .with_context(|| format!("write temporary signer key {}", key.display()))?; + return Ok(CodeSignerPaths { + cert, + key, + temp_dir: Some(dir), + }); + } + + Err(anyhow!( + "`psign-tool code` signing execution currently requires --cert and --key or a portable cert-store --sha1 identity" + )) +} + +fn unique_code_signer_temp_dir() -> Result { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| anyhow!("system clock before Unix epoch: {e}"))? + .as_nanos(); + Ok(std::env::temp_dir().join(format!("psign-code-signer-{}-{nanos}", std::process::id()))) +} + fn nuget_hash_algorithm(digest: DigestAlgorithm) -> Result { match digest { DigestAlgorithm::Sha256 => Ok(nuget::NuGetHashAlgorithm::Sha256), diff --git a/tests/code_command.rs b/tests/code_command.rs index 6b37f7a..6dbb472 100644 --- a/tests/code_command.rs +++ b/tests/code_command.rs @@ -4,7 +4,7 @@ use psign_opc_sign::nuget; use rand::rngs::OsRng; use rsa::RsaPrivateKey; use rsa::pkcs1v15::SigningKey; -use rsa::pkcs8::EncodePrivateKey; +use rsa::pkcs8::{EncodePrivateKey, LineEnding}; use rsa::signature::Keypair; use serde_json::Value; use sha2::Sha256; @@ -177,6 +177,61 @@ fn code_signs_top_level_nupkg_with_local_cert_key() { .stdout(predicate::str::contains("signature_stored=yes")); } +#[test] +fn code_signs_nupkg_with_portable_cert_store_identity() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let store = base.join("cert-store"); + let cert = base.join("signer.der"); + let key_der = base.join("signer.pkcs8"); + let key_pem = base.join("signer.pem"); + let input = base.join("sample.nupkg"); + let output = base.join("signed-store.nupkg"); + write_test_rsa_cert_key_and_pem(&cert, &key_der, &key_pem); + std::fs::copy( + repo_root().join("tests/fixtures/package-signing/unsigned/sample.nupkg"), + &input, + ) + .unwrap(); + + let import = psign() + .args(["cert-store", "import", "--cert-store-dir"]) + .arg(&store) + .arg("--key") + .arg(&key_pem) + .arg(&cert) + .output() + .expect("import cert-store identity"); + assert!( + import.status.success(), + "cert-store import failed: {}", + String::from_utf8_lossy(&import.stderr) + ); + let import_stdout = String::from_utf8(import.stdout).unwrap(); + let thumbprint = import_stdout + .lines() + .find_map(|line| line.strip_prefix("thumbprint_sha1=")) + .expect("import reports thumbprint"); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .arg("--cert-store-dir") + .arg(&store) + .args(["--sha1", thumbprint, "--output"]) + .arg(&output) + .arg("sample.nupkg"); + cmd.assert().success(); + + let mut info = psign(); + info.args(["portable", "nupkg-signature-info"]) + .arg(&output) + .assert() + .success() + .stdout(predicate::str::contains("signed=yes")) + .stdout(predicate::str::contains("signature_stored=yes")); +} + #[test] fn code_signs_top_level_pe_with_local_cert_key() { let temp = tempfile::tempdir().unwrap(); @@ -1893,6 +1948,14 @@ fn repo_root() -> PathBuf { } fn write_test_rsa_cert_key(cert_path: &Path, key_path: &Path) { + write_test_rsa_cert_key_inner(cert_path, key_path, None); +} + +fn write_test_rsa_cert_key_and_pem(cert_path: &Path, key_path: &Path, pem_path: &Path) { + write_test_rsa_cert_key_inner(cert_path, key_path, Some(pem_path)); +} + +fn write_test_rsa_cert_key_inner(cert_path: &Path, key_path: &Path, pem_path: Option<&Path>) { let private_key = RsaPrivateKey::new(&mut OsRng, 2048).expect("rsa private key"); let signing_key = SigningKey::::new(private_key.clone()); let subject = Name::from_str("CN=psign code orchestrator test").expect("subject name"); @@ -1911,6 +1974,16 @@ fn write_test_rsa_cert_key(cert_path: &Path, key_path: &Path) { .build::() .expect("self-signed certificate"); std::fs::write(cert_path, cert.to_der().expect("certificate DER")).expect("write cert"); + if let Some(pem_path) = pem_path { + std::fs::write( + pem_path, + private_key + .to_pkcs8_pem(LineEnding::LF) + .expect("PKCS#8 private key PEM") + .as_bytes(), + ) + .expect("write key PEM"); + } std::fs::write( key_path, private_key From 7d5511dac6bf8dee46383be11ec9c673de37f68a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 22 May 2026 11:32:48 -0400 Subject: [PATCH 08/14] Support code signing from PFX Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 3 +- docs/migration-dotnet-sign.md | 5 ++- docs/psign-cli-matrix.json | 2 +- src/cli.rs | 17 ++++++-- src/code.rs | 42 ++++++++++++------- tests/code_command.rs | 79 +++++++++++++++++++++++++++++++++++ 6 files changed, 126 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 3013597..8e3853c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Canonical repository: . - `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). - `cert-store`: Portable file-backed certificate store under `~/.psign/cert-store` by default, with Windows-style store/thumbprint selection. -- `code`: dotnet/sign-style orchestration entry point. It supports `--dry-run` / `--plan-json` planning over inputs, file lists, globs, and nested ZIP/OPC containers, plus guarded local cert/key or portable cert-store SHA-1 execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, MSIX/AppX unsigned-package prepare including nested packages inside upload/bundle containers, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer publisher updates + top-level or nested companion signatures, `--continue-on-error`, `--skip-signed`, `--overwrite`, and inside-out VSIX/ZIP -> NuGet/VSIX -> PE/ClickOnce-manifest signing. +- `code`: dotnet/sign-style orchestration entry point. It supports `--dry-run` / `--plan-json` planning over inputs, file lists, globs, and nested ZIP/OPC containers, plus guarded local cert/key, PFX, or portable cert-store SHA-1 execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, MSIX/AppX unsigned-package prepare including nested packages inside upload/bundle containers, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer publisher updates + top-level or nested companion signatures, `--continue-on-error`, `--skip-signed`, `--overwrite`, and inside-out VSIX/ZIP -> NuGet/VSIX -> PE/ClickOnce-manifest signing. - `portable ...`: Cross-platform digest, verification, trust, signing, package, RFC3161, and remote-hash helpers that avoid Win32 APIs, including PE/WinMD signing through Azure Artifact Signing REST without Microsoft client DLLs. ## MSIX parity notes @@ -139,6 +139,7 @@ cargo build -p psign --bin psign-tool --locked # psign-tool code --dry-run --plan-json --base-directory . --file-list files.txt # Initial guarded code execution for PE/NuGet/VSIX/ZIP/MSIX/ClickOnce/App Installer inputs: # psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.exe app.exe +# psign-tool code --base-directory . --pfx signer.pfx --password "pfx-password" --output signed.nupkg package.nupkg # psign-tool code --base-directory . --cert-store-dir ~/.psign/cert-store --sha1 --output signed.nupkg package.nupkg # psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --timestamp-url http://tsa --timestamp-digest sha256 --output signed.nupkg package.nupkg # psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.vsix extension.vsix diff --git a/docs/migration-dotnet-sign.md b/docs/migration-dotnet-sign.md index ec9867a..40a0082 100644 --- a/docs/migration-dotnet-sign.md +++ b/docs/migration-dotnet-sign.md @@ -10,8 +10,8 @@ |---------------------|--------------|---------------| | Expand files, file lists, `!` excludes, braces, ranges, and recursive globs | Implemented for dry-run planning | `psign-tool code --dry-run --plan-json --base-directory . --file-list files.txt` | | Nested package graph / inside-out ordering | Implemented for dry-run planning across ZIP/OPC containers | `psign-tool code --dry-run --plan-json package.vsix` | -| Top-level NuGet/VSIX/App Installer local execution | Implemented for local cert/key or portable cert-store SHA-1 identity plus explicit output | `psign-tool code --cert signer.der --key signer.pkcs8 --output signed.nupkg package.nupkg`, `psign-tool code --cert-store-dir .psign-store --sha1 --output signed.nupkg package.nupkg` | -| Authenticode PE/WinMD execution | Implemented for top-level and nested PE/WinMD with local cert/key or portable cert-store SHA-1 identity | `psign-tool code --cert signer.der --key signer.pkcs8 --output signed.exe app.exe` | +| Top-level NuGet/VSIX/App Installer local execution | Implemented for local cert/key, PFX, or portable cert-store SHA-1 identity plus explicit output | `psign-tool code --cert signer.der --key signer.pkcs8 --output signed.nupkg package.nupkg`, `psign-tool code --pfx signer.pfx --password pfx-password --output signed.nupkg package.nupkg`, `psign-tool code --cert-store-dir .psign-store --sha1 --output signed.nupkg package.nupkg` | +| Authenticode PE/WinMD execution | Implemented for top-level and nested PE/WinMD with local cert/key, PFX, or portable cert-store SHA-1 identity | `psign-tool code --cert signer.der --key signer.pkcs8 --output signed.exe app.exe` | | Package-native nested execution | Implemented for VSIX/ZIP -> NuGet/VSIX -> PE/WinMD inside-out signing without unsupported non-PE inner Authenticode payloads | `psign-tool code --cert signer.der --key signer.pkcs8 --output signed.vsix extension.vsix` | | Continue after top-level errors | Implemented for `code` execution | `psign-tool code --continue-on-error --output signed-dir ...` | | Independent top-level concurrency | Implemented for `code` execution | `psign-tool code --max-concurrency 4 --output signed-dir ...` | @@ -60,6 +60,7 @@ Use guarded local execution for package-native inputs while broader Authenticode ```sh psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.nupkg package.nupkg +psign-tool code --base-directory . --pfx signer.pfx --password pfx-password --output signed.nupkg package.nupkg psign-tool code --base-directory . --cert-store-dir .psign-store --sha1 --output signed.nupkg package.nupkg psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.vsix extension.vsix psign-tool code --base-directory . --overwrite --cert signer.der --key signer.pkcs8 --output resigned.nupkg signed-package.nupkg diff --git a/docs/psign-cli-matrix.json b/docs/psign-cli-matrix.json index 34dad4d..e06225b 100644 --- a/docs/psign-cli-matrix.json +++ b/docs/psign-cli-matrix.json @@ -154,7 +154,7 @@ ], "code": [ {"native": "(dotnet/sign-style)", "rust": "code --dry-run --plan-json --base-directory --file-list ", "tier": "P1", "status": "implemented", "notes": "Plans file-list/glob selection plus nested ZIP/OPC inside-out ordering without modifying inputs."}, - {"native": "(dotnet/sign-style)", "rust": "code (--cert --key |--sha1 [--cert-store-dir ]) --output [--max-concurrency ] [--skip-signed|--overwrite] ", "tier": "P1", "status": "partial", "notes": "Guarded execution for local RSA cert/key or portable cert-store SHA-1 identity over PE/WinMD, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare with --publisher-name and AppxBlockMap regeneration, MSIX/AppX upload/bundle nested package prepare, encrypted MSIX/AppX OS-only diagnostics, ClickOnce .manifest/.application/.vsto XMLDSig signing, PE-like ClickOnce .deploy payloads, namespace-aware App Installer publisher updates plus top-level and nested ZIP companion signatures, --continue-on-error, --max-concurrency for independent top-level inputs, --skip-signed, --overwrite, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD/ClickOnce-manifest/App-Installer-companion nested inside-out signing. Unsupported non-PE nested Authenticode payloads fail explicitly unless excluded."} + {"native": "(dotnet/sign-style)", "rust": "code (--cert --key |--pfx [--password ]|--sha1 [--cert-store-dir ]) --output [--max-concurrency ] [--skip-signed|--overwrite] ", "tier": "P1", "status": "partial", "notes": "Guarded execution for local RSA cert/key, PFX, or portable cert-store SHA-1 identity over PE/WinMD, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare with --publisher-name and AppxBlockMap regeneration, MSIX/AppX upload/bundle nested package prepare, encrypted MSIX/AppX OS-only diagnostics, ClickOnce .manifest/.application/.vsto XMLDSig signing, PE-like ClickOnce .deploy payloads, namespace-aware App Installer publisher updates plus top-level and nested ZIP companion signatures, --continue-on-error, --max-concurrency for independent top-level inputs, --skip-signed, --overwrite, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD/ClickOnce-manifest/App-Installer-companion nested inside-out signing. Unsupported non-PE nested Authenticode payloads fail explicitly unless excluded."} ] }, "tier_summary": { diff --git a/src/cli.rs b/src/cli.rs index e31d6b1..25c07a3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -132,7 +132,7 @@ pub struct CodeArgs { long, value_name = "PATH", requires = "key", - conflicts_with = "cert_sha1" + conflicts_with_all = ["cert_sha1", "pfx"] )] pub cert: Option, /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM for initial local package signing execution. @@ -140,11 +140,22 @@ pub struct CodeArgs { long, value_name = "PATH", requires = "cert", - conflicts_with = "cert_sha1" + conflicts_with_all = ["cert_sha1", "pfx"] )] pub key: Option, + /// PFX/PKCS#12 signer identity for local package signing execution. + #[arg( + long, + visible_alias = "f", + value_name = "PATH", + conflicts_with = "cert_sha1" + )] + pub pfx: Option, + /// Optional password for the PFX/PKCS#12 signer identity. + #[arg(long, visible_alias = "p", requires = "pfx")] + pub password: Option, /// Resolve a local portable cert-store signing identity by SHA-1 thumbprint. - #[arg(long = "sha1", visible_alias = "cert-sha1")] + #[arg(long = "sha1", visible_alias = "cert-sha1", conflicts_with = "pfx")] pub cert_sha1: Option, /// Portable certificate store base directory for `--sha1`. #[arg(long)] diff --git a/src/code.rs b/src/code.rs index 02f7a1b..d23b2c0 100644 --- a/src/code.rs +++ b/src/code.rs @@ -1752,6 +1752,14 @@ fn resolve_code_signer_paths(args: &CodeArgs) -> Result { }); } + if let Some(pfx) = args.pfx.as_deref() { + let bytes = std::fs::read(pfx).with_context(|| format!("read PFX '{}'", pfx.display()))?; + let password = args.password.as_deref().unwrap_or(""); + let (cert_der, key_pem) = crate::cert_store::load_pfx_cert_and_key(&bytes, password) + .with_context(|| format!("extract signer identity from PFX '{}'", pfx.display()))?; + return write_temp_signer_material(cert_der, key_pem.into_bytes()); + } + if let Some(sha1) = args.cert_sha1.as_deref() { let identity = crate::cert_store::resolve_signing_identity( args.cert_store_dir.as_deref(), @@ -1759,27 +1767,31 @@ fn resolve_code_signer_paths(args: &CodeArgs) -> Result { &args.store_name, sha1, )?; - let dir = unique_code_signer_temp_dir()?; - std::fs::create_dir_all(&dir) - .with_context(|| format!("create signer material temp directory {}", dir.display()))?; - let cert = dir.join("signer.der"); - let key = dir.join("signer.key"); - std::fs::write(&cert, identity.cert_der) - .with_context(|| format!("write temporary signer certificate {}", cert.display()))?; - std::fs::write(&key, identity.key_pem) - .with_context(|| format!("write temporary signer key {}", key.display()))?; - return Ok(CodeSignerPaths { - cert, - key, - temp_dir: Some(dir), - }); + return write_temp_signer_material(identity.cert_der, identity.key_pem); } Err(anyhow!( - "`psign-tool code` signing execution currently requires --cert and --key or a portable cert-store --sha1 identity" + "`psign-tool code` signing execution currently requires --cert and --key, --pfx, or a portable cert-store --sha1 identity" )) } +fn write_temp_signer_material(cert_der: Vec, key_pem: Vec) -> Result { + let dir = unique_code_signer_temp_dir()?; + std::fs::create_dir_all(&dir) + .with_context(|| format!("create signer material temp directory {}", dir.display()))?; + let cert = dir.join("signer.der"); + let key = dir.join("signer.key"); + std::fs::write(&cert, cert_der) + .with_context(|| format!("write temporary signer certificate {}", cert.display()))?; + std::fs::write(&key, key_pem) + .with_context(|| format!("write temporary signer key {}", key.display()))?; + Ok(CodeSignerPaths { + cert, + key, + temp_dir: Some(dir), + }) +} + fn unique_code_signer_temp_dir() -> Result { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/tests/code_command.rs b/tests/code_command.rs index 6dbb472..735a17e 100644 --- a/tests/code_command.rs +++ b/tests/code_command.rs @@ -232,6 +232,39 @@ fn code_signs_nupkg_with_portable_cert_store_identity() { .stdout(predicate::str::contains("signature_stored=yes")); } +#[test] +fn code_signs_nupkg_with_pfx_identity() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let pfx = base.join("signer.pfx"); + let input = base.join("sample.nupkg"); + let output = base.join("signed-pfx.nupkg"); + write_test_rsa_pfx(&pfx, "secret"); + std::fs::copy( + repo_root().join("tests/fixtures/package-signing/unsigned/sample.nupkg"), + &input, + ) + .unwrap(); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .arg("--pfx") + .arg(&pfx) + .args(["--password", "secret", "--output"]) + .arg(&output) + .arg("sample.nupkg"); + cmd.assert().success(); + + let mut info = psign(); + info.args(["portable", "nupkg-signature-info"]) + .arg(&output) + .assert() + .success() + .stdout(predicate::str::contains("signed=yes")) + .stdout(predicate::str::contains("signature_stored=yes")); +} + #[test] fn code_signs_top_level_pe_with_local_cert_key() { let temp = tempfile::tempdir().unwrap(); @@ -1955,6 +1988,52 @@ fn write_test_rsa_cert_key_and_pem(cert_path: &Path, key_path: &Path, pem_path: write_test_rsa_cert_key_inner(cert_path, key_path, Some(pem_path)); } +fn write_test_rsa_pfx(pfx_path: &Path, password: &str) { + use picky::key::PrivateKey; + use picky::pkcs12::{ + Pfx, Pkcs12CryptoContext, Pkcs12HashAlgorithm, Pkcs12MacAlgorithmHmac, SafeBag, + SafeContents, + }; + use picky::x509::Cert; + + let private_key = RsaPrivateKey::new(&mut OsRng, 2048).expect("rsa private key"); + let signing_key = SigningKey::::new(private_key.clone()); + let subject = Name::from_str("CN=psign code pfx orchestrator test").expect("subject name"); + let spki = SubjectPublicKeyInfoOwned::from_key(signing_key.verifying_key()) + .expect("subject public key info"); + let builder = CertificateBuilder::new( + Profile::Root, + SerialNumber::from(85u32), + Validity::from_now(Duration::from_secs(86_400)).expect("validity"), + subject, + spki, + &signing_key, + ) + .expect("certificate builder"); + let cert = builder + .build::() + .expect("self-signed certificate"); + let key_der = private_key + .to_pkcs8_der() + .expect("PKCS#8 private key") + .as_bytes() + .to_vec(); + let key = PrivateKey::from_pkcs8(&key_der).expect("picky private key"); + let cert = Cert::from_der(&cert.to_der().expect("certificate DER")).expect("picky cert"); + let cert_bag = SafeBag::new_certificate(cert, vec![]).expect("cert bag"); + let key_bag = SafeBag::new_key(key, vec![]).expect("key bag"); + let mut context = Pkcs12CryptoContext::new_with_password(password).expect("PFX context"); + let pfx = Pfx::new_with_hmac( + vec![SafeContents::new(vec![cert_bag, key_bag])], + Pkcs12MacAlgorithmHmac::new(Pkcs12HashAlgorithm::Sha256), + &mut context, + ) + .expect("PFX") + .to_der() + .expect("PFX DER"); + std::fs::write(pfx_path, pfx).expect("write PFX"); +} + fn write_test_rsa_cert_key_inner(cert_path: &Path, key_path: &Path, pem_path: Option<&Path>) { let private_key = RsaPrivateKey::new(&mut OsRng, 2048).expect("rsa private key"); let signing_key = SigningKey::::new(private_key.clone()); From 9963b88328c80c6f9319e24b416dd6d7b0391a9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 22 May 2026 13:11:25 -0400 Subject: [PATCH 09/14] Wire Azure Key Vault and Artifact Signing into code orchestrator Refactors psign-tool code signing from explicit cert/key paths to a unified CodeSigner abstraction supporting five identity providers: local cert/key files, PFX, portable cert-store, Azure Key Vault, and Azure Artifact Signing. - Introduces CodeSigner/CodeSignerBackend enum with Local, AzureKeyVault, and ArtifactSigning variants - Moves psign-azure-kv-rest and psign-codesigning-rest to cross-platform deps (still feature-gated) - Adds mock Azure Key Vault and Artifact Signing servers for E2E - Adds E2E tests for NuGet signing with all 5 provider types - Updates docs and CLI matrix for cloud provider support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.toml | 4 +- README.md | 4 +- docs/migration-dotnet-sign.md | 6 +- docs/psign-cli-matrix.json | 2 +- src/cli.rs | 63 +++ src/code.rs | 822 ++++++++++++++++++++++++++++------ tests/code_command.rs | 149 +++++- 7 files changed, 897 insertions(+), 153 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 23cf3d4..198fb23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,12 +78,12 @@ rsa = { version = "0.9.10", features = ["sha2"] } x509-cert = "0.2.5" zip = { version = "0.6.6", default-features = false, features = ["deflate"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true } +psign-azure-kv-rest = { path = "crates/psign-azure-kv-rest", optional = true } +psign-codesigning-rest = { path = "crates/psign-codesigning-rest", optional = true } [target.'cfg(windows)'.dependencies] glob = "0.3" rayon = "1.10" -psign-azure-kv-rest = { path = "crates/psign-azure-kv-rest", optional = true } -psign-codesigning-rest = { path = "crates/psign-codesigning-rest", optional = true } uuid = "1" windows = { version = "0.59", features = [ "Win32_Foundation", diff --git a/README.md b/README.md index 8e3853c..5d0f304 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Canonical repository: . - `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). - `cert-store`: Portable file-backed certificate store under `~/.psign/cert-store` by default, with Windows-style store/thumbprint selection. -- `code`: dotnet/sign-style orchestration entry point. It supports `--dry-run` / `--plan-json` planning over inputs, file lists, globs, and nested ZIP/OPC containers, plus guarded local cert/key, PFX, or portable cert-store SHA-1 execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, MSIX/AppX unsigned-package prepare including nested packages inside upload/bundle containers, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer publisher updates + top-level or nested companion signatures, `--continue-on-error`, `--skip-signed`, `--overwrite`, and inside-out VSIX/ZIP -> NuGet/VSIX -> PE/ClickOnce-manifest signing. +- `code`: dotnet/sign-style orchestration entry point. It supports `--dry-run` / `--plan-json` planning over inputs, file lists, globs, and nested ZIP/OPC containers, plus guarded local cert/key, PFX, portable cert-store SHA-1, Azure Key Vault, or Artifact Signing execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, MSIX/AppX unsigned-package prepare including nested packages inside upload/bundle containers, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer publisher updates + top-level or nested companion signatures, `--continue-on-error`, `--skip-signed`, `--overwrite`, and inside-out VSIX/ZIP -> NuGet/VSIX -> PE/ClickOnce-manifest signing. - `portable ...`: Cross-platform digest, verification, trust, signing, package, RFC3161, and remote-hash helpers that avoid Win32 APIs, including PE/WinMD signing through Azure Artifact Signing REST without Microsoft client DLLs. ## MSIX parity notes @@ -141,6 +141,8 @@ cargo build -p psign --bin psign-tool --locked # psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.exe app.exe # psign-tool code --base-directory . --pfx signer.pfx --password "pfx-password" --output signed.nupkg package.nupkg # psign-tool code --base-directory . --cert-store-dir ~/.psign/cert-store --sha1 --output signed.nupkg package.nupkg +# psign-tool code --base-directory . --azure-key-vault-url https://vault.vault.azure.net --azure-key-vault-certificate cert --azure-key-vault-accesstoken "$TOKEN" --output signed.nupkg package.nupkg +# psign-tool code --base-directory . --artifact-signing-endpoint https://wus2.codesigning.azure.net --artifact-signing-account-name acct --artifact-signing-profile-name profile --artifact-signing-access-token "$TOKEN" --output signed.nupkg package.nupkg # psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --timestamp-url http://tsa --timestamp-digest sha256 --output signed.nupkg package.nupkg # psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.vsix extension.vsix # psign-tool code --base-directory . --overwrite --cert signer.der --key signer.pkcs8 --output resigned.nupkg signed-package.nupkg diff --git a/docs/migration-dotnet-sign.md b/docs/migration-dotnet-sign.md index 40a0082..98d2465 100644 --- a/docs/migration-dotnet-sign.md +++ b/docs/migration-dotnet-sign.md @@ -10,8 +10,8 @@ |---------------------|--------------|---------------| | Expand files, file lists, `!` excludes, braces, ranges, and recursive globs | Implemented for dry-run planning | `psign-tool code --dry-run --plan-json --base-directory . --file-list files.txt` | | Nested package graph / inside-out ordering | Implemented for dry-run planning across ZIP/OPC containers | `psign-tool code --dry-run --plan-json package.vsix` | -| Top-level NuGet/VSIX/App Installer local execution | Implemented for local cert/key, PFX, or portable cert-store SHA-1 identity plus explicit output | `psign-tool code --cert signer.der --key signer.pkcs8 --output signed.nupkg package.nupkg`, `psign-tool code --pfx signer.pfx --password pfx-password --output signed.nupkg package.nupkg`, `psign-tool code --cert-store-dir .psign-store --sha1 --output signed.nupkg package.nupkg` | -| Authenticode PE/WinMD execution | Implemented for top-level and nested PE/WinMD with local cert/key, PFX, or portable cert-store SHA-1 identity | `psign-tool code --cert signer.der --key signer.pkcs8 --output signed.exe app.exe` | +| Top-level NuGet/VSIX/App Installer execution | Implemented for local cert/key, PFX, portable cert-store SHA-1, Azure Key Vault, or Artifact Signing identity plus explicit output | `psign-tool code --cert signer.der --key signer.pkcs8 --output signed.nupkg package.nupkg`, `psign-tool code --pfx signer.pfx --password pfx-password --output signed.nupkg package.nupkg`, `psign-tool code --cert-store-dir .psign-store --sha1 --output signed.nupkg package.nupkg`, `psign-tool code --azure-key-vault-url https://vault.vault.azure.net --azure-key-vault-certificate cert --azure-key-vault-accesstoken $TOKEN --output signed.nupkg package.nupkg`, `psign-tool code --artifact-signing-endpoint https://wus2.codesigning.azure.net --artifact-signing-account-name acct --artifact-signing-profile-name profile --artifact-signing-access-token $TOKEN --output signed.nupkg package.nupkg` | +| Authenticode PE/WinMD execution | Implemented for top-level and nested PE/WinMD with local cert/key, PFX, portable cert-store SHA-1, Azure Key Vault, or Artifact Signing identity | `psign-tool code --cert signer.der --key signer.pkcs8 --output signed.exe app.exe` | | Package-native nested execution | Implemented for VSIX/ZIP -> NuGet/VSIX -> PE/WinMD inside-out signing without unsupported non-PE inner Authenticode payloads | `psign-tool code --cert signer.der --key signer.pkcs8 --output signed.vsix extension.vsix` | | Continue after top-level errors | Implemented for `code` execution | `psign-tool code --continue-on-error --output signed-dir ...` | | Independent top-level concurrency | Implemented for `code` execution | `psign-tool code --max-concurrency 4 --output signed-dir ...` | @@ -62,6 +62,8 @@ Use guarded local execution for package-native inputs while broader Authenticode psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.nupkg package.nupkg psign-tool code --base-directory . --pfx signer.pfx --password pfx-password --output signed.nupkg package.nupkg psign-tool code --base-directory . --cert-store-dir .psign-store --sha1 --output signed.nupkg package.nupkg +psign-tool code --base-directory . --azure-key-vault-url https://vault.vault.azure.net --azure-key-vault-certificate cert --azure-key-vault-accesstoken "$TOKEN" --output signed.nupkg package.nupkg +psign-tool code --base-directory . --artifact-signing-endpoint https://wus2.codesigning.azure.net --artifact-signing-account-name acct --artifact-signing-profile-name profile --artifact-signing-access-token "$TOKEN" --output signed.nupkg package.nupkg psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.vsix extension.vsix psign-tool code --base-directory . --overwrite --cert signer.der --key signer.pkcs8 --output resigned.nupkg signed-package.nupkg psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.zip bundle.zip diff --git a/docs/psign-cli-matrix.json b/docs/psign-cli-matrix.json index e06225b..1e291e4 100644 --- a/docs/psign-cli-matrix.json +++ b/docs/psign-cli-matrix.json @@ -154,7 +154,7 @@ ], "code": [ {"native": "(dotnet/sign-style)", "rust": "code --dry-run --plan-json --base-directory --file-list ", "tier": "P1", "status": "implemented", "notes": "Plans file-list/glob selection plus nested ZIP/OPC inside-out ordering without modifying inputs."}, - {"native": "(dotnet/sign-style)", "rust": "code (--cert --key |--pfx [--password ]|--sha1 [--cert-store-dir ]) --output [--max-concurrency ] [--skip-signed|--overwrite] ", "tier": "P1", "status": "partial", "notes": "Guarded execution for local RSA cert/key, PFX, or portable cert-store SHA-1 identity over PE/WinMD, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare with --publisher-name and AppxBlockMap regeneration, MSIX/AppX upload/bundle nested package prepare, encrypted MSIX/AppX OS-only diagnostics, ClickOnce .manifest/.application/.vsto XMLDSig signing, PE-like ClickOnce .deploy payloads, namespace-aware App Installer publisher updates plus top-level and nested ZIP companion signatures, --continue-on-error, --max-concurrency for independent top-level inputs, --skip-signed, --overwrite, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD/ClickOnce-manifest/App-Installer-companion nested inside-out signing. Unsupported non-PE nested Authenticode payloads fail explicitly unless excluded."} + {"native": "(dotnet/sign-style)", "rust": "code (--cert --key |--pfx [--password ]|--sha1 [--cert-store-dir ]|--azure-key-vault-url --azure-key-vault-certificate |--artifact-signing-endpoint --artifact-signing-account-name --artifact-signing-profile-name ) --output [--max-concurrency ] [--skip-signed|--overwrite] ", "tier": "P1", "status": "partial", "notes": "Guarded execution for local RSA cert/key, PFX, portable cert-store SHA-1, Azure Key Vault, or Artifact Signing identity over PE/WinMD, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare with --publisher-name and AppxBlockMap regeneration, MSIX/AppX upload/bundle nested package prepare, encrypted MSIX/AppX OS-only diagnostics, ClickOnce .manifest/.application/.vsto XMLDSig signing, PE-like ClickOnce .deploy payloads, namespace-aware App Installer publisher updates plus top-level and nested ZIP companion signatures, --continue-on-error, --max-concurrency for independent top-level inputs, --skip-signed, --overwrite, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD/ClickOnce-manifest/App-Installer-companion nested inside-out signing. Unsupported non-PE nested Authenticode payloads fail explicitly unless excluded."} ] }, "tier_summary": { diff --git a/src/cli.rs b/src/cli.rs index 25c07a3..1b1fe9e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -166,6 +166,69 @@ pub struct CodeArgs { /// Portable certificate store name for `--sha1`. #[arg(long = "store", visible_alias = "s", default_value = "MY")] pub store_name: String, + /// Azure Key Vault URL for remote package/orchestrator signing. + #[arg(long = "azure-key-vault-url", visible_alias = "kvu")] + pub azure_key_vault_url: Option, + /// Azure Key Vault signing certificate name. + #[arg(long = "azure-key-vault-certificate", visible_alias = "kvc")] + pub azure_key_vault_certificate: Option, + /// Optional Azure Key Vault certificate version. + #[arg(long = "azure-key-vault-certificate-version", visible_alias = "kvcv")] + pub azure_key_vault_certificate_version: Option, + #[arg(long = "azure-key-vault-client-id", visible_alias = "kvi")] + pub azure_key_vault_client_id: Option, + #[arg(long = "azure-key-vault-client-secret", visible_alias = "kvs")] + pub azure_key_vault_client_secret: Option, + #[arg(long = "azure-key-vault-tenant-id", visible_alias = "kvt")] + pub azure_key_vault_tenant_id: Option, + #[arg(long = "azure-key-vault-accesstoken", visible_alias = "kva")] + pub azure_key_vault_access_token: Option, + /// Managed identity / DefaultAzureCredential-style acquisition via IMDS. + #[arg(long = "azure-key-vault-managed-identity", visible_alias = "kvm")] + pub azure_key_vault_managed_identity: bool, + /// Azure.Identity-style credential selector for Key Vault signing. + #[arg(long = "azure-key-vault-credential-type", value_enum)] + pub azure_key_vault_credential_type: Option, + /// OAuth authority host prefix, e.g. `https://login.microsoftonline.com`. + #[arg(long = "azure-authority", visible_alias = "au")] + pub azure_authority: Option, + /// Artifact Signing metadata JSON (same shape as Microsoft's dlib `/dmdf` file). + #[arg(long = "artifact-signing-metadata")] + pub artifact_signing_metadata: Option, + /// Artifact Signing regional hostname segment, e.g. `westus`, when not using metadata Endpoint. + #[arg(long = "artifact-signing-region")] + pub artifact_signing_region: Option, + /// Artifact Signing data-plane endpoint, e.g. `https://wus2.codesigning.azure.net`. + #[arg(long = "artifact-signing-endpoint")] + pub artifact_signing_endpoint: Option, + #[arg(long = "artifact-signing-account-name")] + pub artifact_signing_account_name: Option, + #[arg(long = "artifact-signing-profile-name")] + pub artifact_signing_profile_name: Option, + #[arg(long = "artifact-signing-signature-algorithm")] + pub artifact_signing_signature_algorithm: Option, + #[arg(long = "artifact-signing-api-version")] + pub artifact_signing_api_version: Option, + #[arg(long = "artifact-signing-correlation-id")] + pub artifact_signing_correlation_id: Option, + #[arg(long = "artifact-signing-access-token")] + pub artifact_signing_access_token: Option, + #[arg(long = "artifact-signing-managed-identity")] + pub artifact_signing_managed_identity: bool, + /// Azure.Identity-style credential selector for Artifact Signing. + #[arg(long = "artifact-signing-credential-type", value_enum)] + pub artifact_signing_credential_type: Option, + #[arg(long = "artifact-signing-tenant-id")] + pub artifact_signing_tenant_id: Option, + #[arg(long = "artifact-signing-client-id")] + pub artifact_signing_client_id: Option, + #[arg(long = "artifact-signing-client-secret")] + pub artifact_signing_client_secret: Option, + #[arg(long = "artifact-signing-authority")] + pub artifact_signing_authority: Option, + /// Override Artifact Signing data-plane origin for deterministic local tests. + #[arg(long = "artifact-signing-endpoint-base-url", hide = true)] + pub artifact_signing_endpoint_base_url: Option, /// Additional certificate to include in generated package PKCS#7 signatures. #[arg(long = "chain-cert", value_name = "PATH")] pub chain_certs: Vec, diff --git a/src/code.rs b/src/code.rs index d23b2c0..d8daa54 100644 --- a/src/code.rs +++ b/src/code.rs @@ -1,12 +1,23 @@ use crate::CommandOutput; -use crate::cli::{CodeArgs, DigestAlgorithm}; +use crate::cli::{AzureCredentialType, CodeArgs, DigestAlgorithm}; use anyhow::{Context, Result, anyhow}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +#[cfg(feature = "azure-kv-sign")] +use psign_azure_kv_rest::{ + KeyVaultCertificate, KvAuthParams, KvHashAlg, KvPublicKeyKind, acquire_kv_access_token, + fetch_kv_certificate, kv_decode_cer_b64, kv_public_key_kind_from_cer_der, + kv_sign_digest_from_certificate, +}; +#[cfg(feature = "artifact-signing-rest")] +use psign_codesigning_rest::{ + CodesigningAuth, CodesigningSubmitParams, DEFAULT_API_VERSION, + submit_codesign_hash_signature_blocking, +}; 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::{pkcs7, rdp}; +use psign_sip_digest::{pe_digest, pe_embed, pkcs7, rdp}; use rsa::signature::{SignatureEncoding as _, Signer as _}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sha2::Digest as _; use std::collections::{BTreeMap, BTreeSet}; use std::fs::File; @@ -193,9 +204,7 @@ pub fn build_code_plan(args: &CodeArgs) -> Result { } fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result { - let signer = resolve_code_signer_paths(args)?; - let cert = signer.cert.as_path(); - let key = signer.key.as_path(); + let signer = resolve_code_signer(args)?; if args.output.is_none() { return Err(anyhow!( "`psign-tool code` signing execution currently requires --output to avoid in-place package mutation" @@ -227,10 +236,10 @@ fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result )) } else { let signed = - sign_pe_bytes(&input_bytes, &node.path, cert, key, signing_digest, false) + sign_pe_bytes(&input_bytes, &node.path, &signer, signing_digest, false) .with_context(|| { - format!("sign Authenticode payload {}", input.display()) - })?; + format!("sign Authenticode payload {}", input.display()) + })?; std::fs::write(&output, signed).with_context(|| { format!("write signed Authenticode payload {}", output.display()) })?; @@ -261,8 +270,7 @@ fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result &node.path, digest, signing_digest, - cert, - key, + &signer, args.chain_certs.clone(), &nested_excludes, args.skip_signed, @@ -311,8 +319,7 @@ fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result }; let pkcs7 = sign_pkcs7_id_data( &bytes, - cert, - key, + &signer, args.chain_certs.clone(), signing_digest, args.timestamp_url.as_deref(), @@ -344,10 +351,6 @@ fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result CodeFormat::Vsix => { let output = output_path_for_node(node)?; ensure_parent_dir(&output)?; - let cert_bytes = - std::fs::read(cert).with_context(|| format!("read {}", cert.display()))?; - rdp::parse_certificate(&cert_bytes) - .with_context(|| format!("parse signer certificate {}", cert.display()))?; let input_bytes = std::fs::read(&input).with_context(|| format!("read {}", input.display()))?; if args.skip_signed && package_has_signature(&input_bytes, &node.format)? { @@ -366,8 +369,7 @@ fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result digest, signing_digest, vsix_digest, - cert, - key, + &signer, args.chain_certs.clone(), &nested_excludes, args.skip_signed, @@ -399,8 +401,7 @@ fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result digest, signing_digest, vsix_digest, - cert, - key, + &signer, args.chain_certs.clone(), &nested_excludes, args.skip_signed, @@ -434,8 +435,7 @@ fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result digest, signing_digest, vsix_digest, - cert, - key, + &signer, args.chain_certs.clone(), &nested_excludes, args.skip_signed, @@ -477,8 +477,7 @@ fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result let signed = sign_clickonce_manifest_bytes( &input_bytes, &node.path, - cert, - key, + &signer, vsix_digest, args.timestamp_url.as_deref(), args.timestamp_digest, @@ -499,14 +498,11 @@ 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, - cert, - key, - signing_digest, - ) - .with_context(|| format!("sign ClickOnce deploy payload {}", input.display()))?; + let signed = + sign_clickonce_deploy_bytes(&input_bytes, &node.path, &signer, signing_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()) })?; @@ -587,8 +583,7 @@ fn sign_nuget_bytes( label: &str, digest: nuget::NuGetHashAlgorithm, signing_digest: pkcs7::AuthenticodeSigningDigest, - cert: &Path, - key: &Path, + signer: &CodeSigner, chain_certs: Vec, nested_excludes: &[String], skip_signed: bool, @@ -606,8 +601,7 @@ fn sign_nuget_bytes( digest, signing_digest, vsix::VsixHashAlgorithm::Sha256, - cert, - key, + signer, chain_certs.clone(), nested_excludes, skip_signed, @@ -624,8 +618,7 @@ fn sign_nuget_bytes( let content = nuget::signature_content_bytes(digest, &digest.hash(&unsigned)); let pkcs7 = sign_pkcs7_id_data( &content, - cert, - key, + signer, chain_certs, signing_digest, timestamp_url, @@ -644,8 +637,7 @@ fn sign_vsix_bytes( digest: nuget::NuGetHashAlgorithm, signing_digest: pkcs7::AuthenticodeSigningDigest, vsix_digest: vsix::VsixHashAlgorithm, - cert: &Path, - key: &Path, + signer: &CodeSigner, chain_certs: Vec, nested_excludes: &[String], skip_signed: bool, @@ -668,8 +660,7 @@ fn sign_vsix_bytes( digest, signing_digest, vsix_digest, - cert, - key, + signer, chain_certs, nested_excludes, skip_signed, @@ -678,13 +669,9 @@ fn sign_vsix_bytes( timestamp_url, timestamp_digest, )?; - let cert_bytes = std::fs::read(cert).with_context(|| format!("read {}", cert.display()))?; - let key_bytes = std::fs::read(key).with_context(|| format!("read {}", key.display()))?; - let private_key = rdp::parse_rsa_private_key(&key_bytes) - .with_context(|| format!("parse RSA private key {}", key.display()))?; let signed_info = vsix::signed_info_xml(Cursor::new(updated.clone()), vsix_digest) .with_context(|| format!("create VSIX SignedInfo for {label}"))?; - let signature = sign_xml_signed_info(vsix_digest, private_key, &signed_info); + let (signature, cert_bytes) = signer.sign_xml_signed_info(vsix_digest, &signed_info)?; let xml = vsix::signature_xml_from_signed_info(&signed_info, &signature, Some(&cert_bytes)) .into_bytes(); let mut out = Cursor::new(Vec::new()); @@ -696,8 +683,7 @@ fn sign_vsix_bytes( fn sign_clickonce_manifest_bytes( input_bytes: &[u8], label: &str, - cert: &Path, - key: &Path, + signer: &CodeSigner, digest: vsix::VsixHashAlgorithm, timestamp_url: Option<&str>, timestamp_digest: Option, @@ -710,12 +696,8 @@ fn sign_clickonce_manifest_bytes( let text = std::str::from_utf8(input_bytes) .with_context(|| format!("read ClickOnce manifest {label} as UTF-8 XML"))?; let unsigned = unsigned_clickonce_manifest_text(text)?; - let cert_bytes = std::fs::read(cert).with_context(|| format!("read {}", cert.display()))?; - let key_bytes = std::fs::read(key).with_context(|| format!("read {}", key.display()))?; - let private_key = rdp::parse_rsa_private_key(&key_bytes) - .with_context(|| format!("parse RSA private key {}", key.display()))?; let signed_info = clickonce_manifest_signed_info_xml(&unsigned, digest); - let signature = sign_xml_signed_info(digest, private_key, &signed_info); + let (signature, cert_bytes) = signer.sign_xml_signed_info(digest, &signed_info)?; let signature_xml = clickonce_manifest_signature_xml(&signed_info, &signature, &cert_bytes); Ok(insert_clickonce_signature_xml(&unsigned, &signature_xml)?.into_bytes()) } @@ -811,8 +793,7 @@ fn sign_zip_container_bytes( digest: nuget::NuGetHashAlgorithm, signing_digest: pkcs7::AuthenticodeSigningDigest, vsix_digest: vsix::VsixHashAlgorithm, - cert: &Path, - key: &Path, + signer: &CodeSigner, chain_certs: Vec, nested_excludes: &[String], skip_signed: bool, @@ -827,8 +808,7 @@ fn sign_zip_container_bytes( digest, signing_digest, vsix_digest, - cert, - key, + signer, chain_certs, nested_excludes, skip_signed, @@ -846,8 +826,7 @@ fn sign_nested_package_entries( digest: nuget::NuGetHashAlgorithm, signing_digest: pkcs7::AuthenticodeSigningDigest, vsix_digest: vsix::VsixHashAlgorithm, - cert: &Path, - key: &Path, + signer: &CodeSigner, chain_certs: Vec, nested_excludes: &[String], skip_signed: bool, @@ -886,8 +865,7 @@ fn sign_nested_package_entries( &nested_label, digest, signing_digest, - cert, - key, + signer, chain_certs.clone(), nested_excludes, skip_signed, @@ -906,8 +884,7 @@ fn sign_nested_package_entries( digest, signing_digest, vsix_digest, - cert, - key, + signer, chain_certs.clone(), nested_excludes, skip_signed, @@ -941,8 +918,7 @@ fn sign_nested_package_entries( } let pkcs7 = sign_pkcs7_id_data( &descriptor, - cert, - key, + signer, chain_certs.clone(), signing_digest, timestamp_url, @@ -964,8 +940,7 @@ fn sign_nested_package_entries( bytes: sign_clickonce_manifest_bytes( &bytes, &nested_label, - cert, - key, + signer, vsix_digest, timestamp_url, timestamp_digest, @@ -976,25 +951,12 @@ fn sign_nested_package_entries( } CodeFormat::Deploy => entry_updates.push(ZipEntryUpdate { name, - bytes: sign_clickonce_deploy_bytes( - &bytes, - &nested_label, - cert, - key, - signing_digest, - )?, + bytes: sign_clickonce_deploy_bytes(&bytes, &nested_label, signer, signing_digest)?, compression, }), CodeFormat::Pe | CodeFormat::Winmd => entry_updates.push(ZipEntryUpdate { name, - bytes: sign_pe_bytes( - &bytes, - &nested_label, - cert, - key, - signing_digest, - skip_signed, - )?, + bytes: sign_pe_bytes(&bytes, &nested_label, signer, signing_digest, skip_signed)?, compression, }), CodeFormat::Msix @@ -1010,8 +972,7 @@ fn sign_nested_package_entries( digest, signing_digest, vsix_digest, - cert, - key, + signer, chain_certs.clone(), nested_excludes, skip_signed, @@ -1058,8 +1019,7 @@ fn ensure_nuget_unsigned(bytes: &[u8], label: &str) -> Result<()> { fn sign_clickonce_deploy_bytes( input_bytes: &[u8], label: &str, - cert: &Path, - key: &Path, + signer: &CodeSigner, signing_digest: pkcs7::AuthenticodeSigningDigest, ) -> Result> { if signing_digest != pkcs7::AuthenticodeSigningDigest::Sha256 { @@ -1079,9 +1039,7 @@ fn sign_clickonce_deploy_bytes( "ClickOnce .deploy payload {label} maps to unsupported content name {content_name}" )); } - let cert_bytes = std::fs::read(cert).with_context(|| format!("read {}", cert.display()))?; - let key_bytes = std::fs::read(key).with_context(|| format!("read {}", key.display()))?; - sign_pe_bytes_with_key(input_bytes, &cert_bytes, &key_bytes) + sign_pe_bytes(input_bytes, label, signer, signing_digest, false) .with_context(|| format!("sign ClickOnce .deploy PE payload {label}")) } @@ -1104,8 +1062,7 @@ fn is_pe_like_extension(ext: &str) -> bool { fn sign_pe_bytes( input_bytes: &[u8], label: &str, - cert: &Path, - key: &Path, + signer: &CodeSigner, signing_digest: pkcs7::AuthenticodeSigningDigest, skip_signed: bool, ) -> Result> { @@ -1115,20 +1072,11 @@ fn sign_pe_bytes( if signing_digest != pkcs7::AuthenticodeSigningDigest::Sha256 { return Err(anyhow!("PE/WinMD signing currently supports only SHA-256")); } - let cert_bytes = std::fs::read(cert).with_context(|| format!("read {}", cert.display()))?; - let key_bytes = std::fs::read(key).with_context(|| format!("read {}", key.display()))?; - sign_pe_bytes_with_key(input_bytes, &cert_bytes, &key_bytes) + signer + .sign_pe_bytes(input_bytes, signing_digest) .with_context(|| format!("sign PE/WinMD payload {label}")) } -fn sign_pe_bytes_with_key( - input_bytes: &[u8], - cert_bytes: &[u8], - key_bytes: &[u8], -) -> Result> { - psign_sip_digest::pe_sign::sign_pe_image_rsa_sha256(input_bytes, cert_bytes, key_bytes) -} - #[allow(clippy::too_many_arguments)] fn prepare_msix_family_bytes( input_bytes: &[u8], @@ -1136,8 +1084,7 @@ fn prepare_msix_family_bytes( digest: nuget::NuGetHashAlgorithm, signing_digest: pkcs7::AuthenticodeSigningDigest, vsix_digest: vsix::VsixHashAlgorithm, - cert: &Path, - key: &Path, + signer: &CodeSigner, chain_certs: Vec, nested_excludes: &[String], skip_signed: bool, @@ -1154,8 +1101,7 @@ fn prepare_msix_family_bytes( digest, signing_digest, vsix_digest, - cert, - key, + signer, chain_certs, nested_excludes, skip_signed, @@ -1553,42 +1499,20 @@ fn is_unsupported_nested_signable(format: &CodeFormat) -> bool { fn sign_pkcs7_id_data( content: &[u8], - cert: &Path, - key: &Path, + signer: &CodeSigner, chain_certs: Vec, digest: pkcs7::AuthenticodeSigningDigest, timestamp_url: Option<&str>, timestamp_digest: Option, ) -> Result> { - let cert_bytes = std::fs::read(cert).with_context(|| format!("read {}", cert.display()))?; - let signer_cert = rdp::parse_certificate(&cert_bytes) - .with_context(|| format!("parse signer certificate {}", cert.display()))?; - let key_bytes = std::fs::read(key).with_context(|| format!("read {}", key.display()))?; - let private_key = rdp::parse_rsa_private_key(&key_bytes) - .with_context(|| format!("parse RSA private key {}", key.display()))?; - let mut chain = Vec::with_capacity(chain_certs.len()); - for chain_cert in chain_certs { - let bytes = - std::fs::read(&chain_cert).with_context(|| format!("read {}", chain_cert.display()))?; - chain.push( - rdp::parse_certificate(&bytes) - .with_context(|| format!("parse chain certificate {}", chain_cert.display()))?, - ); - } + let chain = load_chain_certs(chain_certs)?; let econtent_der = OctetString::new(content.to_vec()) .map_err(|e| anyhow!("encode CMS id-data OCTET STRING: {e}"))? .to_der() .map_err(|e| anyhow!("encode CMS id-data DER: {e}"))?; let id_data = ObjectIdentifier::new(pkcs7::PKCS7_ID_DATA_OID) .map_err(|e| anyhow!("parse CMS id-data OID: {e}"))?; - let pkcs7 = pkcs7::create_pkcs7_signed_data_der_rsa( - id_data, - &econtent_der, - digest, - signer_cert, - chain, - private_key, - )?; + let pkcs7 = signer.sign_pkcs7(id_data, &econtent_der, digest, chain, true)?; let mut detached = pkcs7::parse_pkcs7_signed_data_der(&pkcs7) .context("parse generated CMS before detaching eContent")?; detached.encap_content_info.econtent = None; @@ -1723,12 +1647,65 @@ fn timestamp_digest_bytes(digest: DigestAlgorithm, bytes: &[u8]) -> Result, } +#[cfg(feature = "azure-kv-sign")] +struct CodeAzureKeyVaultSigner { + http: reqwest::blocking::Client, + token: String, + certificate: KeyVaultCertificate, + cert_der: Vec, +} + +#[cfg(feature = "artifact-signing-rest")] +struct CodeArtifactSigningSigner { + metadata: Option, + region: Option, + endpoint: Option, + account_name: Option, + profile_name: Option, + signature_algorithm: Option, + api_version: Option, + correlation_id: Option, + access_token: Option, + managed_identity: bool, + tenant_id: Option, + client_id: Option, + client_secret: Option, + authority: Option, + endpoint_base_url: Option, +} + +struct RemoteCodeSignature { + signature: Vec, + signer_cert_der: Vec, + signer_cert: x509_cert::Certificate, + chain: Vec, +} + +#[derive(Clone, Copy)] +enum CodeRemoteDigestAlgorithm { + Sha256, + Sha384, + Sha512, +} + impl Drop for CodeSignerPaths { fn drop(&mut self) { if let Some(dir) = &self.temp_dir { @@ -1737,18 +1714,314 @@ impl Drop for CodeSignerPaths { } } -fn resolve_code_signer_paths(args: &CodeArgs) -> Result { - if args.cert.is_some() || args.key.is_some() { +impl CodeSigner { + fn local_paths(&self) -> Option<(&Path, &Path)> { + match &self.backend { + CodeSignerBackend::Local(paths) => Some((&paths.cert, &paths.key)), + #[cfg(feature = "azure-kv-sign")] + CodeSignerBackend::AzureKeyVault(_) => None, + #[cfg(feature = "artifact-signing-rest")] + CodeSignerBackend::ArtifactSigning(_) => None, + } + } + + fn sign_pkcs7( + &self, + econtent_type: ObjectIdentifier, + econtent_der: &[u8], + digest: pkcs7::AuthenticodeSigningDigest, + chain: Vec, + detached: bool, + ) -> Result> { + if let Some((cert, key)) = self.local_paths() { + let cert_bytes = + std::fs::read(cert).with_context(|| format!("read {}", cert.display()))?; + let signer_cert = rdp::parse_certificate(&cert_bytes) + .with_context(|| format!("parse signer certificate {}", cert.display()))?; + let key_bytes = + std::fs::read(key).with_context(|| format!("read {}", key.display()))?; + let private_key = rdp::parse_rsa_private_key(&key_bytes) + .with_context(|| format!("parse RSA private key {}", key.display()))?; + let pkcs7 = pkcs7::create_pkcs7_signed_data_der_rsa( + econtent_type, + econtent_der, + digest, + signer_cert, + chain, + private_key, + )?; + if !detached { + return Ok(pkcs7); + } + let mut detached_pkcs7 = pkcs7::parse_pkcs7_signed_data_der(&pkcs7) + .context("parse generated CMS before detaching eContent")?; + detached_pkcs7.encap_content_info.econtent = None; + return pkcs7::encode_pkcs7_content_info_signed_data_der(&detached_pkcs7); + } + + let prehash = + pkcs7::pkcs7_remote_rsa_signed_attrs_digest(econtent_type, econtent_der, digest)?; + let mut remote = + self.sign_remote_digest(code_remote_digest_from_pkcs7(digest), &prehash)?; + remote.chain.extend(chain); + pkcs7::create_pkcs7_signed_data_der_with_rsa_signature( + econtent_type, + econtent_der, + digest, + remote.signer_cert, + remote.chain, + &remote.signature, + detached, + ) + } + + fn sign_xml_signed_info( + &self, + algorithm: vsix::VsixHashAlgorithm, + signed_info: &[u8], + ) -> Result<(Vec, Vec)> { + if let Some((cert, key)) = self.local_paths() { + let cert_bytes = + std::fs::read(cert).with_context(|| format!("read {}", cert.display()))?; + let key_bytes = + std::fs::read(key).with_context(|| format!("read {}", key.display()))?; + let private_key = rdp::parse_rsa_private_key(&key_bytes) + .with_context(|| format!("parse RSA private key {}", key.display()))?; + return Ok(( + sign_xml_signed_info(algorithm, private_key, signed_info), + cert_bytes, + )); + } + + let prehash = clickonce_signature_digest_bytes(algorithm, signed_info); + let remote = self.sign_remote_digest(code_remote_digest_from_vsix(algorithm), &prehash)?; + Ok((remote.signature, remote.signer_cert_der)) + } + + fn sign_pe_bytes( + &self, + input_bytes: &[u8], + signing_digest: pkcs7::AuthenticodeSigningDigest, + ) -> Result> { + if let Some((cert, key)) = self.local_paths() { + let cert_bytes = + std::fs::read(cert).with_context(|| format!("read {}", cert.display()))?; + let key_bytes = + std::fs::read(key).with_context(|| format!("read {}", key.display()))?; + return psign_sip_digest::pe_sign::sign_pe_image_rsa_sha256( + input_bytes, + &cert_bytes, + &key_bytes, + ); + } + + let pe_digest = + pe_digest::pe_authenticode_digest(input_bytes, signing_digest.pe_hash_kind()) + .context("compute PE/WinMD Authenticode digest")?; + let indirect = pkcs7::pe_spc_indirect_data(signing_digest, &pe_digest)?; + let prehash = + pkcs7::authenticode_remote_rsa_signed_attrs_digest(&indirect, signing_digest)?; + let remote = + self.sign_remote_digest(code_remote_digest_from_pkcs7(signing_digest), &prehash)?; + let pkcs7 = pkcs7::create_authenticode_pkcs7_der_with_rsa_signature( + indirect, + signing_digest, + remote.signer_cert, + remote.chain, + &remote.signature, + )?; + pe_embed::pe_append_authenticode_pkcs7_certificate(input_bytes.to_vec(), &pkcs7) + .context("embed Authenticode PKCS#7") + } + + fn sign_remote_digest( + &self, + algorithm: CodeRemoteDigestAlgorithm, + digest: &[u8], + ) -> Result { + match &self.backend { + CodeSignerBackend::Local(_) => Err(anyhow!("local signer cannot remote-sign a digest")), + #[cfg(feature = "azure-kv-sign")] + CodeSignerBackend::AzureKeyVault(signer) => signer.sign_digest(algorithm, digest), + #[cfg(feature = "artifact-signing-rest")] + CodeSignerBackend::ArtifactSigning(signer) => signer.sign_digest(algorithm, digest), + } + } +} + +#[cfg(feature = "azure-kv-sign")] +impl CodeAzureKeyVaultSigner { + fn sign_digest( + &self, + algorithm: CodeRemoteDigestAlgorithm, + digest: &[u8], + ) -> Result { + let signature = kv_sign_digest_from_certificate( + &self.http, + &self.token, + &self.certificate, + kv_hash_algorithm(algorithm), + digest, + )?; + let signer_cert = rdp::parse_certificate(&self.cert_der) + .context("parse Azure Key Vault signer certificate")?; + Ok(RemoteCodeSignature { + signature, + signer_cert_der: self.cert_der.clone(), + signer_cert, + chain: Vec::new(), + }) + } +} + +#[cfg(feature = "artifact-signing-rest")] +impl CodeArtifactSigningSigner { + fn sign_digest( + &self, + algorithm: CodeRemoteDigestAlgorithm, + digest: &[u8], + ) -> Result { + let params = + self.params_for_digest(digest.to_vec(), artifact_signature_algorithm(algorithm))?; + let signed = submit_codesign_hash_signature_blocking(¶ms, |_| {})?; + let (signer_cert, chain) = + parse_artifact_signing_certificates(&signed.signing_certificate)?; + let signer_cert_der = signer_cert + .to_der() + .map_err(|e| anyhow!("encode Artifact Signing signer certificate: {e}"))?; + Ok(RemoteCodeSignature { + signature: signed.signature, + signer_cert_der, + signer_cert, + chain, + }) + } + + fn params_for_digest( + &self, + digest: Vec, + default_signature_algorithm: &str, + ) -> Result { + let metadata = if let Some(path) = self.metadata.as_deref() { + let raw = std::fs::read(path) + .with_context(|| format!("read Artifact Signing metadata {}", path.display()))?; + Some( + serde_json::from_slice::(&raw) + .context("parse Artifact Signing metadata JSON")?, + ) + } else { + None + }; + let endpoint = text_opt(self.endpoint_base_url.as_deref()) + .or_else(|| text_opt(self.endpoint.as_deref())) + .or_else(|| metadata.as_ref().and_then(|m| text_opt(Some(&m.Endpoint)))); + let account_name = text_opt(self.account_name.as_deref()) + .or_else(|| { + metadata + .as_ref() + .and_then(|m| text_opt(Some(&m.CodeSigningAccountName))) + }) + .ok_or_else(|| anyhow!("Artifact Signing requires --artifact-signing-account-name or metadata CodeSigningAccountName"))?; + let profile_name = text_opt(self.profile_name.as_deref()) + .or_else(|| { + metadata + .as_ref() + .and_then(|m| text_opt(Some(&m.CertificateProfileName))) + }) + .ok_or_else(|| anyhow!("Artifact Signing requires --artifact-signing-profile-name or metadata CertificateProfileName"))?; + Ok(CodesigningSubmitParams { + region: text_opt(self.region.as_deref()).unwrap_or_else(|| "unused".to_string()), + account_name, + profile_name, + digest, + signature_algorithm: text_opt(self.signature_algorithm.as_deref()) + .unwrap_or_else(|| default_signature_algorithm.to_string()), + api_version: text_opt(self.api_version.as_deref()) + .unwrap_or_else(|| DEFAULT_API_VERSION.to_string()), + correlation_id: text_opt(self.correlation_id.as_deref()).or_else(|| { + metadata + .as_ref() + .and_then(|m| text_opt(m.CorrelationId.as_deref())) + }), + authority: text_opt(self.authority.as_deref()), + auth: self.auth()?, + endpoint_base_url: endpoint, + }) + } + + fn auth(&self) -> Result { + let has_token = text_opt(self.access_token.as_deref()).is_some(); + let tenant = text_opt(self.tenant_id.as_deref()); + let client = text_opt(self.client_id.as_deref()); + let secret = text_opt(self.client_secret.as_deref()); + let client_parts = tenant.is_some() as u8 + client.is_some() as u8 + secret.is_some() as u8; + if self.managed_identity { + if has_token || client_parts != 0 { + return Err(anyhow!( + "use either Artifact Signing managed identity, access token, or client credentials, not multiple" + )); + } + return Ok(CodesigningAuth::ManagedIdentity); + } + if let Some(token) = text_opt(self.access_token.as_deref()) { + if client_parts != 0 { + return Err(anyhow!( + "use either Artifact Signing access token or client credentials, not both" + )); + } + return Ok(CodesigningAuth::Bearer(token)); + } + if client_parts != 0 && client_parts != 3 { + return Err(anyhow!( + "Artifact Signing client credentials require all of tenant-id, client-id, and client-secret" + )); + } + if client_parts == 0 { + return Err(anyhow!( + "choose Artifact Signing authentication: managed identity, access token, or tenant/client-id/client-secret" + )); + } + Ok(CodesigningAuth::ClientCredentials { + tenant_id: tenant.unwrap(), + client_id: client.unwrap(), + client_secret: secret.unwrap(), + }) + } +} + +fn resolve_code_signer(args: &CodeArgs) -> Result { + let local_files = args.cert.is_some() || args.key.is_some(); + let pfx = args.pfx.is_some(); + let portable_store = args + .cert_sha1 + .as_deref() + .is_some_and(|s| !s.trim().is_empty()); + let azure_key_vault = azure_key_vault_requested(args); + let artifact_signing = artifact_signing_requested(args); + let provider_count = local_files as u8 + + pfx as u8 + + portable_store as u8 + + azure_key_vault as u8 + + artifact_signing as u8; + if provider_count != 1 { + return Err(anyhow!( + "`psign-tool code` signing execution requires exactly one signer: --cert/--key, --pfx, --sha1, Azure Key Vault, or Artifact Signing" + )); + } + + if local_files { let cert = args.cert.clone().ok_or_else(|| { anyhow!("`psign-tool code` signing execution requires --cert with --key") })?; let key = args.key.clone().ok_or_else(|| { anyhow!("`psign-tool code` signing execution requires --key with --cert") })?; - return Ok(CodeSignerPaths { - cert, - key, - temp_dir: None, + return Ok(CodeSigner { + backend: CodeSignerBackend::Local(CodeSignerPaths { + cert, + key, + temp_dir: None, + }), }); } @@ -1757,7 +2030,12 @@ fn resolve_code_signer_paths(args: &CodeArgs) -> Result { let password = args.password.as_deref().unwrap_or(""); let (cert_der, key_pem) = crate::cert_store::load_pfx_cert_and_key(&bytes, password) .with_context(|| format!("extract signer identity from PFX '{}'", pfx.display()))?; - return write_temp_signer_material(cert_der, key_pem.into_bytes()); + return Ok(CodeSigner { + backend: CodeSignerBackend::Local(write_temp_signer_material( + cert_der, + key_pem.into_bytes(), + )?), + }); } if let Some(sha1) = args.cert_sha1.as_deref() { @@ -1767,12 +2045,23 @@ fn resolve_code_signer_paths(args: &CodeArgs) -> Result { &args.store_name, sha1, )?; - return write_temp_signer_material(identity.cert_der, identity.key_pem); + return Ok(CodeSigner { + backend: CodeSignerBackend::Local(write_temp_signer_material( + identity.cert_der, + identity.key_pem, + )?), + }); } - Err(anyhow!( - "`psign-tool code` signing execution currently requires --cert and --key, --pfx, or a portable cert-store --sha1 identity" - )) + if azure_key_vault { + return resolve_code_signer_azure_key_vault(args); + } + + if artifact_signing { + return resolve_code_signer_artifact_signing(args); + } + + unreachable!("provider_count checked above"); } fn write_temp_signer_material(cert_der: Vec, key_pem: Vec) -> Result { @@ -1800,6 +2089,251 @@ fn unique_code_signer_temp_dir() -> Result { Ok(std::env::temp_dir().join(format!("psign-code-signer-{}-{nanos}", std::process::id()))) } +#[cfg(feature = "azure-kv-sign")] +fn resolve_code_signer_azure_key_vault(args: &CodeArgs) -> Result { + if matches!( + args.azure_key_vault_credential_type, + Some(AzureCredentialType::WorkloadIdentity) + ) { + return Err(anyhow!( + "`psign-tool code` Azure Key Vault execution does not support workload identity yet" + )); + } + let vault_url = required_text("--azure-key-vault-url", args.azure_key_vault_url.as_deref())?; + let certificate_name = required_text( + "--azure-key-vault-certificate", + args.azure_key_vault_certificate.as_deref(), + )?; + let managed_identity = args.azure_key_vault_managed_identity + || matches!( + args.azure_key_vault_credential_type, + Some(AzureCredentialType::ManagedIdentity) + ); + let auth = KvAuthParams { + access_token: args.azure_key_vault_access_token.as_deref(), + managed_identity, + tenant_id: args.azure_key_vault_tenant_id.as_deref(), + client_id: args.azure_key_vault_client_id.as_deref(), + client_secret: args.azure_key_vault_client_secret.as_deref(), + authority: args.azure_authority.as_deref(), + }; + let token = acquire_kv_access_token(&auth)?; + let http = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(300)) + .build() + .map_err(|e| anyhow!("HTTP client: {e}"))?; + let certificate = fetch_kv_certificate( + &http, + &vault_url, + &certificate_name, + args.azure_key_vault_certificate_version.as_deref(), + &token, + )?; + let cert_der = kv_decode_cer_b64(&certificate.cer)?; + if kv_public_key_kind_from_cer_der(&cert_der)? != KvPublicKeyKind::Rsa { + return Err(anyhow!( + "`psign-tool code` Azure Key Vault package orchestration requires an RSA signing certificate" + )); + } + Ok(CodeSigner { + backend: CodeSignerBackend::AzureKeyVault(CodeAzureKeyVaultSigner { + http, + token, + certificate, + cert_der, + }), + }) +} + +#[cfg(not(feature = "azure-kv-sign"))] +fn resolve_code_signer_azure_key_vault(_args: &CodeArgs) -> Result { + Err(anyhow!( + "`psign-tool code` Azure Key Vault execution requires the azure-kv-sign feature" + )) +} + +#[cfg(feature = "artifact-signing-rest")] +fn resolve_code_signer_artifact_signing(args: &CodeArgs) -> Result { + if matches!( + args.artifact_signing_credential_type, + Some(AzureCredentialType::WorkloadIdentity) + ) { + return Err(anyhow!( + "`psign-tool code` Artifact Signing execution does not support workload identity yet" + )); + } + Ok(CodeSigner { + backend: CodeSignerBackend::ArtifactSigning(CodeArtifactSigningSigner { + metadata: args.artifact_signing_metadata.clone(), + region: args.artifact_signing_region.clone(), + endpoint: args.artifact_signing_endpoint.clone(), + account_name: args.artifact_signing_account_name.clone(), + profile_name: args.artifact_signing_profile_name.clone(), + signature_algorithm: args.artifact_signing_signature_algorithm.clone(), + api_version: args.artifact_signing_api_version.clone(), + correlation_id: args.artifact_signing_correlation_id.clone(), + access_token: args.artifact_signing_access_token.clone(), + managed_identity: args.artifact_signing_managed_identity + || matches!( + args.artifact_signing_credential_type, + Some(AzureCredentialType::ManagedIdentity) + ), + tenant_id: args.artifact_signing_tenant_id.clone(), + client_id: args.artifact_signing_client_id.clone(), + client_secret: args.artifact_signing_client_secret.clone(), + authority: args.artifact_signing_authority.clone(), + endpoint_base_url: args.artifact_signing_endpoint_base_url.clone(), + }), + }) +} + +#[cfg(not(feature = "artifact-signing-rest"))] +fn resolve_code_signer_artifact_signing(_args: &CodeArgs) -> Result { + Err(anyhow!( + "`psign-tool code` Artifact Signing execution requires the artifact-signing-rest feature" + )) +} + +fn azure_key_vault_requested(args: &CodeArgs) -> bool { + text_opt(args.azure_key_vault_url.as_deref()).is_some() + || text_opt(args.azure_key_vault_certificate.as_deref()).is_some() + || text_opt(args.azure_key_vault_certificate_version.as_deref()).is_some() + || text_opt(args.azure_key_vault_client_id.as_deref()).is_some() + || text_opt(args.azure_key_vault_client_secret.as_deref()).is_some() + || text_opt(args.azure_key_vault_tenant_id.as_deref()).is_some() + || text_opt(args.azure_key_vault_access_token.as_deref()).is_some() + || args.azure_key_vault_managed_identity + || args.azure_key_vault_credential_type.is_some() + || text_opt(args.azure_authority.as_deref()).is_some() +} + +fn artifact_signing_requested(args: &CodeArgs) -> bool { + args.artifact_signing_metadata.is_some() + || text_opt(args.artifact_signing_region.as_deref()).is_some() + || text_opt(args.artifact_signing_endpoint.as_deref()).is_some() + || text_opt(args.artifact_signing_account_name.as_deref()).is_some() + || text_opt(args.artifact_signing_profile_name.as_deref()).is_some() + || text_opt(args.artifact_signing_signature_algorithm.as_deref()).is_some() + || text_opt(args.artifact_signing_api_version.as_deref()).is_some() + || text_opt(args.artifact_signing_correlation_id.as_deref()).is_some() + || text_opt(args.artifact_signing_access_token.as_deref()).is_some() + || args.artifact_signing_managed_identity + || args.artifact_signing_credential_type.is_some() + || text_opt(args.artifact_signing_tenant_id.as_deref()).is_some() + || text_opt(args.artifact_signing_client_id.as_deref()).is_some() + || text_opt(args.artifact_signing_client_secret.as_deref()).is_some() + || text_opt(args.artifact_signing_authority.as_deref()).is_some() + || text_opt(args.artifact_signing_endpoint_base_url.as_deref()).is_some() +} + +fn required_text(name: &str, value: Option<&str>) -> Result { + text_opt(value).ok_or_else(|| anyhow!("{name} is required for the selected signing provider")) +} + +fn text_opt(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned) +} + +fn code_remote_digest_from_pkcs7( + digest: pkcs7::AuthenticodeSigningDigest, +) -> CodeRemoteDigestAlgorithm { + match digest { + pkcs7::AuthenticodeSigningDigest::Sha256 => CodeRemoteDigestAlgorithm::Sha256, + pkcs7::AuthenticodeSigningDigest::Sha384 => CodeRemoteDigestAlgorithm::Sha384, + pkcs7::AuthenticodeSigningDigest::Sha512 => CodeRemoteDigestAlgorithm::Sha512, + } +} + +fn code_remote_digest_from_vsix(digest: vsix::VsixHashAlgorithm) -> CodeRemoteDigestAlgorithm { + match digest { + vsix::VsixHashAlgorithm::Sha256 => CodeRemoteDigestAlgorithm::Sha256, + vsix::VsixHashAlgorithm::Sha384 => CodeRemoteDigestAlgorithm::Sha384, + vsix::VsixHashAlgorithm::Sha512 => CodeRemoteDigestAlgorithm::Sha512, + } +} + +#[cfg(feature = "azure-kv-sign")] +fn kv_hash_algorithm(algorithm: CodeRemoteDigestAlgorithm) -> KvHashAlg { + match algorithm { + CodeRemoteDigestAlgorithm::Sha256 => KvHashAlg::Sha256, + CodeRemoteDigestAlgorithm::Sha384 => KvHashAlg::Sha384, + CodeRemoteDigestAlgorithm::Sha512 => KvHashAlg::Sha512, + } +} + +#[cfg(feature = "artifact-signing-rest")] +fn artifact_signature_algorithm(algorithm: CodeRemoteDigestAlgorithm) -> &'static str { + match algorithm { + CodeRemoteDigestAlgorithm::Sha256 => "RS256", + CodeRemoteDigestAlgorithm::Sha384 => "RS384", + CodeRemoteDigestAlgorithm::Sha512 => "RS512", + } +} + +fn load_chain_certs(paths: Vec) -> Result> { + let mut chain = Vec::with_capacity(paths.len()); + for chain_cert in paths { + let bytes = + std::fs::read(&chain_cert).with_context(|| format!("read {}", chain_cert.display()))?; + chain.push( + rdp::parse_certificate(&bytes) + .with_context(|| format!("parse chain certificate {}", chain_cert.display()))?, + ); + } + Ok(chain) +} + +#[cfg(feature = "artifact-signing-rest")] +#[derive(Debug, Deserialize)] +#[allow(non_snake_case, dead_code)] +struct ArtifactSigningMetadataDoc { + Endpoint: String, + CodeSigningAccountName: String, + CertificateProfileName: String, + #[serde(default)] + CorrelationId: Option, + #[serde(default)] + ExcludeCredentials: Option>, +} + +#[cfg(feature = "artifact-signing-rest")] +fn parse_artifact_signing_certificates( + bytes: &[u8], +) -> Result<(x509_cert::Certificate, Vec)> { + if let Ok(text) = std::str::from_utf8(bytes) + && text.contains("-----BEGIN CERTIFICATE-----") + { + let mut certs = Vec::new(); + let mut rest = text; + while let Some(start) = rest.find("-----BEGIN CERTIFICATE-----") { + rest = &rest[start..]; + let Some(end) = rest.find("-----END CERTIFICATE-----") else { + return Err(anyhow!( + "unterminated PEM certificate in Artifact Signing signingCertificate" + )); + }; + let end = end + "-----END CERTIFICATE-----".len(); + certs.push( + rdp::parse_certificate(&rest.as_bytes()[..end]) + .context("parse Artifact Signing PEM certificate")?, + ); + rest = &rest[end..]; + } + let mut iter = certs.into_iter(); + let signer = iter.next().ok_or_else(|| { + anyhow!("Artifact Signing signingCertificate did not contain a certificate") + })?; + return Ok((signer, iter.collect())); + } + Ok(( + rdp::parse_certificate(bytes).context("parse Artifact Signing DER signing certificate")?, + Vec::new(), + )) +} + fn nuget_hash_algorithm(digest: DigestAlgorithm) -> Result { match digest { DigestAlgorithm::Sha256 => Ok(nuget::NuGetHashAlgorithm::Sha256), diff --git a/tests/code_command.rs b/tests/code_command.rs index 735a17e..5911367 100644 --- a/tests/code_command.rs +++ b/tests/code_command.rs @@ -136,7 +136,7 @@ fn code_without_dry_run_fails_safely() { .assert() .failure() .stderr(predicate::str::contains( - "currently requires --cert and --key", + "requires exactly one signer", )); } @@ -265,6 +265,86 @@ fn code_signs_nupkg_with_pfx_identity() { .stdout(predicate::str::contains("signature_stored=yes")); } +#[cfg(all(feature = "timestamp-server", feature = "azure-kv-sign"))] +#[test] +fn code_signs_nupkg_with_azure_key_vault_identity() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("sample.nupkg"); + let output = base.join("signed-kv.nupkg"); + std::fs::copy( + repo_root().join("tests/fixtures/package-signing/unsigned/sample.nupkg"), + &input, + ) + .unwrap(); + + let (mut guard, url, certificate) = spawn_azure_key_vault_server(2); + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .arg("--azure-key-vault-url") + .arg(&url) + .arg("--azure-key-vault-certificate") + .arg(&certificate) + .args(["--azure-key-vault-accesstoken", "test-token", "--output"]) + .arg(&output) + .arg("sample.nupkg"); + cmd.assert().success(); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut info = psign(); + info.args(["portable", "nupkg-signature-info"]) + .arg(&output) + .assert() + .success() + .stdout(predicate::str::contains("signed=yes")) + .stdout(predicate::str::contains("signature_stored=yes")); +} + +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn code_signs_nupkg_with_artifact_signing_identity() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("sample.nupkg"); + let output = base.join("signed-artifact.nupkg"); + std::fs::copy( + repo_root().join("tests/fixtures/package-signing/unsigned/sample.nupkg"), + &input, + ) + .unwrap(); + + let (mut guard, endpoint) = spawn_artifact_signing_server(2); + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .arg("--artifact-signing-endpoint") + .arg(&endpoint) + .args([ + "--artifact-signing-account-name", + "acct", + "--artifact-signing-profile-name", + "profile", + "--artifact-signing-access-token", + "test-token", + "--output", + ]) + .arg(&output) + .arg("sample.nupkg"); + cmd.assert().success(); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut info = psign(); + info.args(["portable", "nupkg-signature-info"]) + .arg(&output) + .assert() + .success() + .stdout(predicate::str::contains("signed=yes")) + .stdout(predicate::str::contains("signature_stored=yes")); +} + #[test] fn code_signs_top_level_pe_with_local_cert_key() { let temp = tempfile::tempdir().unwrap(); @@ -1938,10 +2018,10 @@ fn sample_clickonce_manifest() -> &'static str { "# } -#[cfg(all(feature = "timestamp-server", feature = "timestamp-http"))] +#[cfg(feature = "timestamp-server")] struct PsignServerGuard(std::process::Child); -#[cfg(all(feature = "timestamp-server", feature = "timestamp-http"))] +#[cfg(feature = "timestamp-server")] impl Drop for PsignServerGuard { fn drop(&mut self) { let _ = self.0.kill(); @@ -1976,6 +2056,69 @@ fn spawn_timestamp_server() -> (PsignServerGuard, String) { (guard, url) } +#[cfg(all(feature = "timestamp-server", feature = "azure-kv-sign"))] +fn spawn_azure_key_vault_server(max_requests: u64) -> (PsignServerGuard, String, 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([ + "azure-key-vault-server", + "--listen", + "127.0.0.1:0", + "--max-requests", + max_requests.as_str(), + ]); + server_cmd.stdout(std::process::Stdio::piped()); + server_cmd.stderr(std::process::Stdio::piped()); + let mut guard = PsignServerGuard(server_cmd.spawn().expect("spawn psign-server")); + let stdout = guard.0.stdout.take().expect("server stdout"); + let mut reader = std::io::BufReader::new(stdout); + let mut listen_line = String::new(); + std::io::BufRead::read_line(&mut reader, &mut listen_line).expect("read listening line"); + let mut cert_line = String::new(); + std::io::BufRead::read_line(&mut reader, &mut cert_line).expect("read certificate line"); + let mut ignored = String::new(); + std::io::BufRead::read_line(&mut reader, &mut ignored).expect("read leaf line"); + let url = listen_line + .trim() + .strip_prefix("psign-server azure-key-vault-server listening on ") + .expect("listening URL") + .to_string(); + let certificate = cert_line + .trim() + .strip_prefix("psign-server azure-key-vault-server certificate ") + .expect("certificate name") + .to_string(); + (guard, url, certificate) +} + +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +fn spawn_artifact_signing_server(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([ + "artifact-signing-server", + "--listen", + "127.0.0.1:0", + "--max-requests", + max_requests.as_str(), + ]); + server_cmd.stdout(std::process::Stdio::piped()); + server_cmd.stderr(std::process::Stdio::piped()); + let mut guard = PsignServerGuard(server_cmd.spawn().expect("spawn psign-server")); + let stdout = guard.0.stdout.take().expect("server stdout"); + let mut reader = std::io::BufReader::new(stdout); + let mut listen_line = String::new(); + std::io::BufRead::read_line(&mut reader, &mut listen_line).expect("read listening line"); + let mut ignored = String::new(); + std::io::BufRead::read_line(&mut reader, &mut ignored).expect("read endpoint line"); + let url = listen_line + .trim() + .strip_prefix("psign-server artifact-signing-server listening on ") + .expect("listening URL") + .to_string(); + (guard, url) +} + fn repo_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) } From 418f93fe77e2648f1c15a1ee52ca408d3e9ce721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 22 May 2026 13:21:24 -0400 Subject: [PATCH 10/14] Add cloud-provider E2E tests for PE, VSIX, App Installer, and ClickOnce Adds Azure Key Vault and Artifact Signing E2E signing tests for: - PE/EFI Authenticode signing with both cloud providers - VSIX XMLDSig signing with both cloud providers - App Installer companion PKCS#7 with Azure Key Vault - ClickOnce manifest XMLDSig with Azure Key Vault Also fixes code_without_dry_run_fails_safely assertion to match the updated error message from the multi-provider signer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/code_command.rs | 218 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) diff --git a/tests/code_command.rs b/tests/code_command.rs index 5911367..d4759e1 100644 --- a/tests/code_command.rs +++ b/tests/code_command.rs @@ -384,6 +384,88 @@ fn code_signs_top_level_pe_with_local_cert_key() { .success(); } +#[cfg(all(feature = "timestamp-server", feature = "azure-kv-sign"))] +#[test] +fn code_signs_pe_with_azure_key_vault_identity() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("app.exe"); + let output = base.join("app.signed-kv.exe"); + std::fs::copy( + repo_root().join("tests/fixtures/pe-authenticode-upstream/tiny32.efi"), + &input, + ) + .unwrap(); + + let (mut guard, url, certificate) = spawn_azure_key_vault_server(2); + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .arg("--azure-key-vault-url") + .arg(&url) + .arg("--azure-key-vault-certificate") + .arg(&certificate) + .args(["--azure-key-vault-accesstoken", "test-token", "--output"]) + .arg(&output) + .arg("app.exe"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Authenticode PE/WinMD")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut verify = psign(); + verify + .args(["portable", "verify-pe"]) + .arg(&output) + .assert() + .success(); +} + +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn code_signs_pe_with_artifact_signing_identity() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("app.exe"); + let output = base.join("app.signed-artifact.exe"); + std::fs::copy( + repo_root().join("tests/fixtures/pe-authenticode-upstream/tiny32.efi"), + &input, + ) + .unwrap(); + + let (mut guard, endpoint) = spawn_artifact_signing_server(2); + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .arg("--artifact-signing-endpoint") + .arg(&endpoint) + .args([ + "--artifact-signing-account-name", + "acct", + "--artifact-signing-profile-name", + "profile", + "--artifact-signing-access-token", + "test-token", + "--output", + ]) + .arg(&output) + .arg("app.exe"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Authenticode PE/WinMD")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut verify = psign(); + verify + .args(["portable", "verify-pe"]) + .arg(&output) + .assert() + .success(); +} + #[test] fn code_skip_signed_copies_already_signed_pe() { let temp = tempfile::tempdir().unwrap(); @@ -666,6 +748,33 @@ fn code_updates_prefixed_appinstaller_main_bundle_before_signing() { )); } +#[cfg(all(feature = "timestamp-server", feature = "azure-kv-sign"))] +#[test] +fn code_signs_appinstaller_with_azure_key_vault_identity() { + let repo = repo_root(); + let temp = tempfile::tempdir().unwrap(); + let output = temp.path().join("sample.appinstaller.p7"); + + let (mut guard, url, certificate) = spawn_azure_key_vault_server(2); + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(&repo) + .arg("--azure-key-vault-url") + .arg(&url) + .arg("--azure-key-vault-certificate") + .arg(&certificate) + .args(["--azure-key-vault-accesstoken", "test-token", "--output"]) + .arg(&output) + .arg("tests/fixtures/generated-unsigned/appinstaller/sample.appinstaller"); + cmd.assert() + .success() + .stdout(predicate::str::contains("detached PKCS#7 companion")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + assert!(output.exists(), "companion .p7 not created"); +} + #[test] fn code_signs_nested_appinstaller_inside_generic_zip() { let temp = tempfile::tempdir().unwrap(); @@ -802,6 +911,43 @@ fn code_signs_clickonce_manifest_with_local_cert_key() { .stdout(predicate::str::contains("signer_trust_chain=yes")); } +#[cfg(all(feature = "timestamp-server", feature = "azure-kv-sign"))] +#[test] +fn code_signs_clickonce_manifest_with_azure_key_vault_identity() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("app.exe.manifest"); + let output = base.join("app.signed.exe.manifest"); + std::fs::write(&input, sample_clickonce_manifest()).unwrap(); + + let (mut guard, url, certificate) = spawn_azure_key_vault_server(2); + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .arg("--azure-key-vault-url") + .arg(&url) + .arg("--azure-key-vault-certificate") + .arg(&certificate) + .args(["--azure-key-vault-accesstoken", "test-token", "--output"]) + .arg(&output) + .arg("app.exe.manifest"); + cmd.assert() + .success() + .stdout(predicate::str::contains("ClickOnce manifest XMLDSig")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut verify = psign(); + verify + .args(["portable", "clickonce-verify-manifest-signature"]) + .arg(&output) + .assert() + .success() + .stdout(predicate::str::contains( + "clickonce-verify-manifest-signature: ok", + )); +} + #[test] fn code_signs_nested_clickonce_manifest_inside_generic_zip() { let temp = tempfile::tempdir().unwrap(); @@ -1180,6 +1326,78 @@ fn code_signs_top_level_vsix_with_local_cert_key() { .stdout(predicate::str::contains("signature_value_match=yes")); } +#[cfg(all(feature = "timestamp-server", feature = "azure-kv-sign"))] +#[test] +fn code_signs_vsix_with_azure_key_vault_identity() { + let repo = repo_root(); + let temp = tempfile::tempdir().unwrap(); + let output = temp.path().join("signed-kv.vsix"); + + let (mut guard, url, certificate) = spawn_azure_key_vault_server(2); + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(&repo) + .arg("--azure-key-vault-url") + .arg(&url) + .arg("--azure-key-vault-certificate") + .arg(&certificate) + .args(["--azure-key-vault-accesstoken", "test-token", "--output"]) + .arg(&output) + .arg("tests/fixtures/package-signing/unsigned/sample.vsix"); + cmd.assert() + .success() + .stdout(predicate::str::contains("psign-signature.psdsxs")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut verify = psign(); + verify + .args(["portable", "vsix-verify-signature"]) + .arg(&output) + .assert() + .success() + .stdout(predicate::str::contains("vsix-verify-signature: ok")); +} + +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn code_signs_vsix_with_artifact_signing_identity() { + let repo = repo_root(); + let temp = tempfile::tempdir().unwrap(); + let output = temp.path().join("signed-artifact.vsix"); + + let (mut guard, endpoint) = spawn_artifact_signing_server(2); + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(&repo) + .arg("--artifact-signing-endpoint") + .arg(&endpoint) + .args([ + "--artifact-signing-account-name", + "acct", + "--artifact-signing-profile-name", + "profile", + "--artifact-signing-access-token", + "test-token", + "--output", + ]) + .arg(&output) + .arg("tests/fixtures/package-signing/unsigned/sample.vsix"); + cmd.assert() + .success() + .stdout(predicate::str::contains("psign-signature.psdsxs")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut verify = psign(); + verify + .args(["portable", "vsix-verify-signature"]) + .arg(&output) + .assert() + .success() + .stdout(predicate::str::contains("vsix-verify-signature: ok")); +} + #[test] fn code_overwrite_resigns_already_signed_vsix() { let repo = repo_root(); From 787c479f98ffbe1aafaacf7400f59b3ed178741d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 22 May 2026 13:23:08 -0400 Subject: [PATCH 11/14] Update gap analysis docs for cloud-provider code orchestration Reflects that NuGet, VSIX, App Installer, and ClickOnce signing through psign-tool code now supports all five identity providers (local cert/key, PFX, portable cert-store, Azure Key Vault, and Artifact Signing) rather than only local identities. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/gap-analysis-signing-platforms.md | 6 +++--- docs/migration-dotnet-sign.md | 2 +- tests/code_command.rs | 4 +--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/gap-analysis-signing-platforms.md b/docs/gap-analysis-signing-platforms.md index d108b1a..a852903 100644 --- a/docs/gap-analysis-signing-platforms.md +++ b/docs/gap-analysis-signing-platforms.md @@ -55,9 +55,9 @@ This inventory starts from the in-tree supported formats, then expands to inbox | Surface | Windows mode coverage | Windows-mode gaps | Portable mode coverage | Portable-mode gaps | |---------|-----------------------|-------------------|------------------------|--------------------| | **RDP files** (`.rdp`) | Implemented `rdp` path using Windows certificate stores. | Mostly fixture breadth and native `rdpsign.exe` output-shape parity. | Implemented `portable rdp` with local cert/key or external detached PKCS#7. | No Windows store selection or native `rdpsign.exe` integration by design. | -| **App Installer descriptors** (`.appinstaller`) | Direct embedded signing is rejected by current SignTool; descriptor signing can be represented as unsigned XML plus a PKCS#7 companion artifact generated with SignTool `/p7`. | No full XML+companion signing/verification UX or native parity wrapper. | `appinstaller-info` inspects descriptor metadata and companion `.p7`; `appinstaller-set-publisher` updates namespace-aware MainPackage/MainBundle publisher metadata; `appinstaller-sign-companion` creates a local RSA/SHA-2 detached PKCS#7 companion with optional RFC3161 timestamping; `appinstaller-sign-companion-prehash` + `appinstaller-sign-companion-from-signature` assemble companion CMS from external RSA signatures; detached trust primitives verify the XML plus companion signature; `psign-tool code` can update publisher metadata and create top-level companion signatures with local cert/key and explicit output, and nested ZIP orchestration writes descriptor-local `.p7` companions. | No App Installer-specific policy checks or direct cloud-provider integration in the `code` orchestrator. | -| **NuGet packages** (`.nupkg`, `.snupkg`) | Not a `signtool`/WinTrust SIP target in this repo. | No `nuget sign`-compatible author/repository signing workflow in Windows mode. | `psign-opc-sign` groundwork: marker inspection, unsigned package digest, NuGet v1 signature-content generation/verification, local RSA/SHA-2 CMS generation for signature content with RFC3161 timestamp tokens, external-signer CMS assembly via `nupkg-signature-pkcs7-prehash` + `nupkg-signature-pkcs7-from-signature`, `.signature.p7s` embed/overwrite, one-step local `nupkg-sign`, embedded `.signature.p7s` package hash + explicit-anchor CMS verification, top-level local `psign-tool code` execution from explicit cert/key or portable cert-store SHA-1 identity, package-native nested execution when embedded in VSIX/ZIP containers, nested PE/WinMD signing before package signatures, nested exclude filters, `--skip-signed`, and `--overwrite`. | No repository signatures, full NuGet policy verification, non-PE Authenticode nested payload signing, or direct cloud-provider integration in the `code` orchestrator. | -| **VSIX packages** (`.vsix`) | Not a first-class Windows-mode signing surface here. | No VSIX package signing/verification workflow. | Signature marker inspection, signature XML embed primitive with OPC signature content-type and relationship metadata, deterministic XMLDSig Reference/DigestValue generation/verification for package parts, local RSA/SHA-2 XMLDSig `SignatureValue` generation/verification, external-signer XMLDSig assembly via `vsix-signature-xml-prehash` + `vsix-signature-xml-from-signature`, one-step local `vsix-sign`, embedded OPC XMLDSig verification with optional explicit-anchor signer chain validation, top-level local `psign-tool code` execution, package-native nested VSIX/ZIP -> NuGet/VSIX -> PE/WinMD execution, nested exclude filters, `--skip-signed`, and `--overwrite`. | No timestamping, non-PE Authenticode nested payload signing, or direct cloud-provider integration in the `code` orchestrator. | +| **App Installer descriptors** (`.appinstaller`) | Direct embedded signing is rejected by current SignTool; descriptor signing can be represented as unsigned XML plus a PKCS#7 companion artifact generated with SignTool `/p7`. | No full XML+companion signing/verification UX or native parity wrapper. | `appinstaller-info` inspects descriptor metadata and companion `.p7`; `appinstaller-set-publisher` updates namespace-aware MainPackage/MainBundle publisher metadata; `appinstaller-sign-companion` creates a local RSA/SHA-2 detached PKCS#7 companion with optional RFC3161 timestamping; `appinstaller-sign-companion-prehash` + `appinstaller-sign-companion-from-signature` assemble companion CMS from external RSA signatures; detached trust primitives verify the XML plus companion signature; `psign-tool code` creates top-level and nested companion signatures with local cert/key, PFX, portable cert-store SHA-1, Azure Key Vault, or Artifact Signing identity, and nested ZIP orchestration writes descriptor-local `.p7` companions. | No App Installer-specific policy checks. | +| **NuGet packages** (`.nupkg`, `.snupkg`) | Not a `signtool`/WinTrust SIP target in this repo. | No `nuget sign`-compatible author/repository signing workflow in Windows mode. | `psign-opc-sign` groundwork: marker inspection, unsigned package digest, NuGet v1 signature-content generation/verification, local RSA/SHA-2 CMS generation for signature content with RFC3161 timestamp tokens, external-signer CMS assembly via `nupkg-signature-pkcs7-prehash` + `nupkg-signature-pkcs7-from-signature`, `.signature.p7s` embed/overwrite, one-step local `nupkg-sign`, embedded `.signature.p7s` package hash + explicit-anchor CMS verification, top-level `psign-tool code` execution from local cert/key, PFX, portable cert-store SHA-1, Azure Key Vault, or Artifact Signing identity, package-native nested execution when embedded in VSIX/ZIP containers, nested PE/WinMD signing before package signatures, nested exclude filters, `--skip-signed`, and `--overwrite`. | No repository signatures, full NuGet policy verification, or non-PE Authenticode nested payload signing. | +| **VSIX packages** (`.vsix`) | Not a first-class Windows-mode signing surface here. | No VSIX package signing/verification workflow. | Signature marker inspection, signature XML embed primitive with OPC signature content-type and relationship metadata, deterministic XMLDSig Reference/DigestValue generation/verification for package parts, local RSA/SHA-2 XMLDSig `SignatureValue` generation/verification, external-signer XMLDSig assembly via `vsix-signature-xml-prehash` + `vsix-signature-xml-from-signature`, one-step local `vsix-sign`, embedded OPC XMLDSig verification with optional explicit-anchor signer chain validation, top-level `psign-tool code` execution from local cert/key, PFX, portable cert-store SHA-1, Azure Key Vault, or Artifact Signing identity, package-native nested VSIX/ZIP -> NuGet/VSIX -> PE/WinMD execution, nested exclude filters, `--skip-signed`, and `--overwrite`. | No timestamping or non-PE Authenticode nested payload signing. | | **ClickOnce / VSTO manifests** (`.manifest`, `.application`, `.vsto`, `.deploy` workflows) | Not implemented. | No `mage.exe`/manifest XMLDSig workflow, certificate embedding, or timestamping. | `psign-tool code --dry-run` classifies ClickOnce/VSTO workflow nodes; portable helpers inspect/copy `.deploy` payloads; guarded `psign-tool code` execution signs PE-like `.deploy` payloads and `.manifest` / `.application` / `.vsto` XMLDSig manifests with local cert/key; portable helpers update and verify manifest file size/digest references; `clickonce-sign-manifest` / `clickonce-sign-manifest-prehash` / `clickonce-sign-manifest-from-signature` / `clickonce-verify-manifest-signature` provide deterministic portable structural local/external XMLDSig signing with embedded signer certificate. | Full Mage-compatible canonicalization/policy, timestamping, full deployment graph orchestration, and ClickOnce/VSTO policy checks remain. | | **Business Central `.app`** | Format-specific behavior is not implemented. | No confirmed NAVX signing/verification workflow. | `business-central-app-info` detects NAVX headers, `psign-tool code --dry-run` classifies NAVX `.app` files, and signing execution now reports a Business Central-specific unsupported diagnostic instead of silently treating them as generic files. | Actual package signing and verification policy remain pending format confirmation. | | **File catalog authoring** | Can sign/verify an existing `.cat` at the Authenticode layer. | No catalog creation from arbitrary file sets or INF/driver package metadata. | `sign-catalog` authors generic CTL catalogs; catalog PKCS#7 consistency/trust and explicit `verify-catalog-member` cover committed MakeCat-style and psign-authored generic catalogs. | Driver/INF policy, OS catalog database search, and MakeCat byte-for-byte output remain out of scope. | diff --git a/docs/migration-dotnet-sign.md b/docs/migration-dotnet-sign.md index 98d2465..9fae54b 100644 --- a/docs/migration-dotnet-sign.md +++ b/docs/migration-dotnet-sign.md @@ -43,7 +43,7 @@ The remaining dotnet/sign feature gaps are execution and policy work: - `psign-tool code` execution supports local RSA cert/key PE/WinMD Authenticode signing, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare execution including nested MSIX/AppX packages inside upload/bundle containers, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer descriptors including nested descriptors in ZIP containers, `--continue-on-error`, `--max-concurrency`, `--skip-signed`, `--overwrite`, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD/ClickOnce-manifest/App-Installer-companion inside-out signing. Unsupported non-PE nested Authenticode payloads still fail explicitly unless excluded by file-list filters. - NuGet support does not yet wrap signature content in full NuGet author/repository signature metadata or enforce NuGet trust policy; local CMS signatures can carry RFC3161 timestamp tokens, and split external CMS assembly can consume a Key Vault/Trusted Signing-style RSA signature over `nupkg-signature-pkcs7-prehash`. -- VSIX support now produces and verifies deterministic local or external-signer RSA/SHA-2 XMLDSig `SignatureValue` bytes, writes the package-level OPC signature content types and relationships, and can optionally validate the XMLDSig signer certificate chain against explicit anchors, but still lacks timestamping and direct cloud-provider integration in the `code` orchestrator; timestamp options fail explicitly rather than producing an untimestamped VSIX signature. +- VSIX support now produces and verifies deterministic local or external-signer RSA/SHA-2 XMLDSig `SignatureValue` bytes, writes the package-level OPC signature content types and relationships, and can optionally validate the XMLDSig signer certificate chain against explicit anchors; cloud-provider signing is supported through the `code` orchestrator; timestamp options fail explicitly rather than producing an untimestamped VSIX signature. - ClickOnce/VSTO support includes classification, `.deploy` payload copy-out helpers, guarded local signing for PE-like `.deploy` payloads, portable manifest file hash update/verification, deterministic portable structural local/external XMLDSig manifest signing/verification with embedded signer certificate, and guarded `psign-tool code` routing for top-level or nested manifest XMLDSig signing; timestamping, full Mage-compatible XML canonicalization/policy, and full deployment graph signing remain. - MSIX/AppX `code` execution prepares unsigned cleartext packages by signing nested entries, updating `AppxManifest.xml` Publisher from `--publisher-name`, and regenerating `AppxBlockMap.xml`; encrypted `.eappx`/`.emsix` packages are classified with explicit Windows AppxSip OS-delegation diagnostics; final package signing still uses the existing Windows SignerSignEx3/AppX path. - App Installer local/external companion generation, RFC3161 timestamping, namespace-aware publisher update before companion signing, and explicit-anchor detached verification exist; full App Installer policy checks remain. diff --git a/tests/code_command.rs b/tests/code_command.rs index d4759e1..0c53dd4 100644 --- a/tests/code_command.rs +++ b/tests/code_command.rs @@ -135,9 +135,7 @@ fn code_without_dry_run_fails_safely() { ]) .assert() .failure() - .stderr(predicate::str::contains( - "requires exactly one signer", - )); + .stderr(predicate::str::contains("requires exactly one signer")); } #[test] From 2e95f206e4aee97be1cbe3099c693215ca88f87a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 22 May 2026 14:10:20 -0400 Subject: [PATCH 12/14] feat: wire package-native signing and cloud providers into PowerShell module - Add NuGet, Vsix, ClickOnceManifest, AppInstaller format variants to psign-portable-core with format-specific sign and inspect functions - Route .nupkg/.snupkg to NuGet CMS, .vsix to OPC XMLDSig, .manifest/.application/.vsto to ClickOnce XMLDSig, .appinstaller to detached PKCS#7 companion (.p7) - Add Azure Key Vault and Artifact Signing fields to PortableSignRequest with mutual-exclusion validation and clear feature-gate diagnostics - Forward cloud provider features through psign-portable-ffi - Add -AzureKeyVault* and -ArtifactSigning* parameters to Set-PortableSignature cmdlet with validation - Update PortableModuleFiles to enumerate .dll, .exe, .nupkg, .snupkg, .vsix, .manifest, .application, .vsto, .appinstaller in directories - Add 4 unit tests for cloud provider validation - Add PowerShell module documentation to migration-dotnet-sign.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 4 + crates/psign-portable-core/Cargo.toml | 9 + crates/psign-portable-core/src/lib.rs | 633 +++++++++++++++++- crates/psign-portable-ffi/Cargo.toml | 5 + docs/migration-dotnet-sign.md | 57 ++ .../Cmdlets/SetPortableSignatureCommand.cs | 137 +++- .../Models/PortableRequests.cs | 47 ++ .../Utilities/PortableModuleFiles.cs | 16 +- 8 files changed, 884 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dca7530..742aa18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2128,8 +2128,12 @@ version = "0.2.0" dependencies = [ "anyhow", "base64", + "der 0.7.10", "picky", "psign-authenticode-trust", + "psign-azure-kv-rest", + "psign-codesigning-rest", + "psign-opc-sign", "psign-sip-digest", "reqwest", "rsa 0.9.10", diff --git a/crates/psign-portable-core/Cargo.toml b/crates/psign-portable-core/Cargo.toml index f01416c..36be861 100644 --- a/crates/psign-portable-core/Cargo.toml +++ b/crates/psign-portable-core/Cargo.toml @@ -6,10 +6,19 @@ description = "Reusable portable Authenticode signing and inspection APIs for ps license.workspace = true repository.workspace = true +[features] +default = [] +azure-kv-sign = ["dep:psign-azure-kv-rest"] +artifact-signing-rest = ["dep:psign-codesigning-rest"] + [dependencies] anyhow = "1" base64 = "0.22" +der = "0.7" psign-authenticode-trust = { path = "../psign-authenticode-trust" } +psign-azure-kv-rest = { path = "../psign-azure-kv-rest", optional = true } +psign-codesigning-rest = { path = "../psign-codesigning-rest", optional = true } +psign-opc-sign = { path = "../psign-opc-sign" } psign-sip-digest = { path = "../psign-sip-digest" } picky = { version = "7.0.0-rc.23", default-features = false, features = ["pkcs12"] } rsa = "0.9.10" diff --git a/crates/psign-portable-core/src/lib.rs b/crates/psign-portable-core/src/lib.rs index 9c70b05..b85934d 100644 --- a/crates/psign-portable-core/src/lib.rs +++ b/crates/psign-portable-core/src/lib.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use base64::Engine as _; +use der::Encode as _; use picky::key::PrivateKey; use picky::pkcs12::{ Pfx, Pkcs12CryptoContext, Pkcs12ParsingParams, SafeBag, SafeBagKind, SafeContentsKind, @@ -39,6 +40,10 @@ pub enum PortableFileFormat { Msix, Catalog, Zip, + NuGet, + Vsix, + ClickOnceManifest, + AppInstaller, PowerShellScript, WshScript, Unknown, @@ -174,6 +179,38 @@ pub struct PortableSignRequest { pub timestamp_server: Option, #[serde(default)] pub timestamp_hash_algorithm: Option, + // Azure Key Vault cloud signing + #[serde(default)] + pub azure_key_vault_url: Option, + #[serde(default)] + pub azure_key_vault_certificate: Option, + #[serde(default)] + pub azure_key_vault_access_token: Option, + #[serde(default)] + pub azure_key_vault_client_id: Option, + #[serde(default)] + pub azure_key_vault_client_secret: Option, + #[serde(default)] + pub azure_key_vault_tenant_id: Option, + #[serde(default)] + pub azure_key_vault_managed_identity: Option, + // Azure Artifact Signing / Trusted Signing + #[serde(default)] + pub artifact_signing_endpoint: Option, + #[serde(default)] + pub artifact_signing_account_name: Option, + #[serde(default)] + pub artifact_signing_profile_name: Option, + #[serde(default)] + pub artifact_signing_access_token: Option, + #[serde(default)] + pub artifact_signing_managed_identity: Option, + #[serde(default)] + pub artifact_signing_tenant_id: Option, + #[serde(default)] + pub artifact_signing_client_id: Option, + #[serde(default)] + pub artifact_signing_client_secret: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -262,6 +299,10 @@ pub fn portable_sign(request: PortableSignRequest) -> Result sign_cab(&request, &output_path), PortableFileFormat::Msi => sign_msi(&request, &output_path), PortableFileFormat::Msix => sign_msix(&request, &output_path), + PortableFileFormat::NuGet => sign_nuget(&request, &output_path), + PortableFileFormat::Vsix => sign_vsix(&request, &output_path), + PortableFileFormat::ClickOnceManifest => sign_clickonce_manifest(&request, &output_path), + PortableFileFormat::AppInstaller => sign_appinstaller(&request, &output_path), PortableFileFormat::Zip => sign_zip(&request, &output_path), PortableFileFormat::PowerShellScript => sign_script(&request, &output_path), PortableFileFormat::WshScript => bail!("portable WSH script signing is not supported yet"), @@ -300,6 +341,10 @@ pub fn portable_get_signature( PortableFileFormat::Cab => inspect_cab(&request.path), PortableFileFormat::Msi => inspect_msi(&request.path), PortableFileFormat::Msix => inspect_msix(&request.path), + PortableFileFormat::NuGet => inspect_nuget(&request.path, &data), + PortableFileFormat::Vsix => inspect_vsix_opc(&request.path, &data), + PortableFileFormat::ClickOnceManifest => inspect_clickonce_manifest(&request.path, &data), + PortableFileFormat::AppInstaller => inspect_appinstaller(&request.path), PortableFileFormat::Zip => inspect_zip(&request.path, &data), PortableFileFormat::PowerShellScript | PortableFileFormat::WshScript => { inspect_script(&request.path, &data) @@ -330,7 +375,11 @@ pub fn infer_format(path: &Path) -> PortableFileFormat { "msi" | "msp" => PortableFileFormat::Msi, "msix" | "appx" | "msixbundle" | "appxbundle" => PortableFileFormat::Msix, "cat" => PortableFileFormat::Catalog, - "zip" | "vsix" | "nupkg" => PortableFileFormat::Zip, + "nupkg" | "snupkg" => PortableFileFormat::NuGet, + "vsix" => PortableFileFormat::Vsix, + "manifest" | "application" | "vsto" => PortableFileFormat::ClickOnceManifest, + "appinstaller" => PortableFileFormat::AppInstaller, + "zip" => PortableFileFormat::Zip, "ps1" | "psm1" | "psd1" | "ps1xml" | "psc1" | "cdxml" | "mof" => { PortableFileFormat::PowerShellScript } @@ -497,6 +546,228 @@ fn sign_zip(request: &PortableSignRequest, output_path: &Path) -> Result<()> { std::fs::write(output_path, signed).with_context(|| format!("write {}", output_path.display())) } +fn sign_nuget(request: &PortableSignRequest, output_path: &Path) -> Result<()> { + let data = + std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; + let (signer_cert, private_key, chain) = load_signing_material(request)?; + let nuget_alg = match request.hash_algorithm { + PortableDigestAlgorithm::Sha256 => psign_opc_sign::nuget::NuGetHashAlgorithm::Sha256, + PortableDigestAlgorithm::Sha384 => psign_opc_sign::nuget::NuGetHashAlgorithm::Sha384, + PortableDigestAlgorithm::Sha512 => psign_opc_sign::nuget::NuGetHashAlgorithm::Sha512, + }; + let unsigned = psign_opc_sign::nuget::canonical_unsigned_package_bytes(Cursor::new(data)) + .with_context(|| { + format!( + "canonicalize NuGet package for signing {}", + request.path.display() + ) + })?; + let content = + psign_opc_sign::nuget::signature_content_bytes(nuget_alg, &nuget_alg.hash(&unsigned)); + // Create a CMS SignedData with id-data content type, then detach eContent + let econtent_der = der_encode_octet_string(&content)?; + let id_data = der::asn1::ObjectIdentifier::new_unwrap(pkcs7::PKCS7_ID_DATA_OID); + let pkcs7_bytes = pkcs7::create_pkcs7_signed_data_der_rsa( + id_data, + &econtent_der, + request.hash_algorithm.into(), + signer_cert, + chain, + private_key, + ) + .with_context(|| format!("create NuGet CMS signature for {}", request.path.display()))?; + // Detach eContent (NuGet signatures are detached CMS) + let mut sd = pkcs7::parse_pkcs7_signed_data_der(&pkcs7_bytes) + .context("parse generated CMS before detaching eContent")?; + sd.encap_content_info.econtent = None; + let pkcs7_detached = pkcs7::encode_pkcs7_content_info_signed_data_der(&sd)?; + let pkcs7_final = maybe_timestamp_pkcs7(request, pkcs7_detached) + .with_context(|| format!("timestamp {}", request.path.display()))?; + let mut out = Cursor::new(Vec::new()); + psign_opc_sign::nuget::embed_signature(Cursor::new(unsigned), &mut out, &pkcs7_final, false) + .with_context(|| format!("embed NuGet signature into {}", request.path.display()))?; + std::fs::write(output_path, out.into_inner()) + .with_context(|| format!("write {}", output_path.display())) +} + +fn sign_vsix(request: &PortableSignRequest, output_path: &Path) -> Result<()> { + let data = + std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; + let (signer_cert, private_key, chain) = load_signing_material(request)?; + let vsix_alg = match request.hash_algorithm { + PortableDigestAlgorithm::Sha256 => psign_opc_sign::vsix::VsixHashAlgorithm::Sha256, + PortableDigestAlgorithm::Sha384 => psign_opc_sign::vsix::VsixHashAlgorithm::Sha384, + PortableDigestAlgorithm::Sha512 => psign_opc_sign::vsix::VsixHashAlgorithm::Sha512, + }; + let signed_info = psign_opc_sign::vsix::signed_info_xml(Cursor::new(data.clone()), vsix_alg) + .with_context(|| format!("create VSIX SignedInfo XML for {}", request.path.display()))?; + let cert_der = signer_cert.to_der().context("encode signer cert DER")?; + let signature = sign_xml_signed_info_rsa(vsix_alg, &signed_info, &private_key)?; + let _chain = chain; // chain included in KeyInfo is just the signer cert for VSIX + let xml = psign_opc_sign::vsix::signature_xml_from_signed_info( + &signed_info, + &signature, + Some(&cert_der), + ) + .into_bytes(); + let mut out = Cursor::new(Vec::new()); + psign_opc_sign::vsix::embed_signature_xml(Cursor::new(data), &mut out, &xml, false) + .with_context(|| format!("embed VSIX signature XML into {}", request.path.display()))?; + std::fs::write(output_path, out.into_inner()) + .with_context(|| format!("write {}", output_path.display())) +} + +fn sign_clickonce_manifest(request: &PortableSignRequest, output_path: &Path) -> Result<()> { + let data = + std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; + let text = std::str::from_utf8(&data).with_context(|| { + format!( + "read ClickOnce manifest {} as UTF-8", + request.path.display() + ) + })?; + let (signer_cert, private_key, _chain) = load_signing_material(request)?; + let vsix_alg = match request.hash_algorithm { + PortableDigestAlgorithm::Sha256 => psign_opc_sign::vsix::VsixHashAlgorithm::Sha256, + PortableDigestAlgorithm::Sha384 => psign_opc_sign::vsix::VsixHashAlgorithm::Sha384, + PortableDigestAlgorithm::Sha512 => psign_opc_sign::vsix::VsixHashAlgorithm::Sha512, + }; + let unsigned = remove_clickonce_xml_signature(text); + let signed_info = clickonce_manifest_signed_info_xml_bytes(&unsigned, vsix_alg); + let cert_der = signer_cert.to_der().context("encode signer cert DER")?; + let signature = sign_xml_signed_info_rsa(vsix_alg, &signed_info, &private_key)?; + let signature_xml = build_clickonce_signature_xml(&signed_info, &signature, &cert_der); + let signed = insert_clickonce_signature_in_manifest(&unsigned, &signature_xml)?; + std::fs::write(output_path, signed.as_bytes()) + .with_context(|| format!("write {}", output_path.display())) +} + +fn sign_appinstaller(request: &PortableSignRequest, output_path: &Path) -> Result<()> { + let data = + std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; + let (signer_cert, private_key, chain) = load_signing_material(request)?; + // Create a detached CMS over the descriptor content + let econtent_der = der_encode_octet_string(&data)?; + let id_data = der::asn1::ObjectIdentifier::new_unwrap(pkcs7::PKCS7_ID_DATA_OID); + let pkcs7_bytes = pkcs7::create_pkcs7_signed_data_der_rsa( + id_data, + &econtent_der, + request.hash_algorithm.into(), + signer_cert, + chain, + private_key, + ) + .with_context(|| { + format!( + "create detached PKCS#7 companion signature for {}", + request.path.display() + ) + })?; + // Detach eContent + let mut sd = pkcs7::parse_pkcs7_signed_data_der(&pkcs7_bytes) + .context("parse generated CMS before detaching eContent")?; + sd.encap_content_info.econtent = None; + let pkcs7_detached = pkcs7::encode_pkcs7_content_info_signed_data_der(&sd)?; + let pkcs7_final = maybe_timestamp_pkcs7(request, pkcs7_detached) + .with_context(|| format!("timestamp {}", request.path.display()))?; + // Write the .p7 companion alongside the output descriptor + let companion_path = output_path.with_extension( + output_path + .extension() + .map(|e| format!("{}.p7", e.to_string_lossy())) + .unwrap_or_else(|| "p7".to_string()), + ); + // Copy the original descriptor to output if needed + if output_path != request.path { + std::fs::copy(&request.path, output_path).with_context(|| { + format!( + "copy {} to {}", + request.path.display(), + output_path.display() + ) + })?; + } + std::fs::write(&companion_path, pkcs7_final) + .with_context(|| format!("write companion {}", companion_path.display())) +} + +fn sign_xml_signed_info_rsa( + algorithm: psign_opc_sign::vsix::VsixHashAlgorithm, + signed_info: &[u8], + private_key: &rsa::RsaPrivateKey, +) -> Result> { + use rsa::pkcs1v15::SigningKey; + use rsa::signature::SignatureEncoding; + use rsa::signature::Signer; + + let signature = match algorithm { + psign_opc_sign::vsix::VsixHashAlgorithm::Sha256 => { + let signing_key = SigningKey::::new(private_key.clone()); + signing_key.sign(signed_info).to_vec() + } + psign_opc_sign::vsix::VsixHashAlgorithm::Sha384 => { + let signing_key = SigningKey::::new(private_key.clone()); + signing_key.sign(signed_info).to_vec() + } + psign_opc_sign::vsix::VsixHashAlgorithm::Sha512 => { + let signing_key = SigningKey::::new(private_key.clone()); + signing_key.sign(signed_info).to_vec() + } + }; + Ok(signature) +} + +/// Remove an existing XML `` element from a ClickOnce manifest. +fn remove_clickonce_xml_signature(text: &str) -> String { + // Find `") + { + let end = start + end + "".len(); + let mut out = String::with_capacity(text.len() - (end - start)); + out.push_str(&text[..start]); + out.push_str(&text[end..]); + return out; + } + text.to_owned() +} + +/// Build the SignedInfo XML for a ClickOnce manifest (enveloped signature). +fn clickonce_manifest_signed_info_xml_bytes( + unsigned_manifest_text: &str, + algorithm: psign_opc_sign::vsix::VsixHashAlgorithm, +) -> Vec { + let manifest_digest = algorithm.hash(unsigned_manifest_text.as_bytes()); + let digest_b64 = base64::engine::general_purpose::STANDARD.encode(manifest_digest); + format!( + r#"{digest_b64}"#, + algorithm.signature_uri(), + algorithm.digest_uri(), + ) + .into_bytes() +} + +fn build_clickonce_signature_xml(signed_info: &[u8], signature: &[u8], cert_der: &[u8]) -> String { + let signed_info_str = String::from_utf8_lossy(signed_info); + format!( + r#"{signed_info_str}{}{}"#, + base64::engine::general_purpose::STANDARD.encode(signature), + base64::engine::general_purpose::STANDARD.encode(cert_der) + ) +} + +fn insert_clickonce_signature_in_manifest(text: &str, signature_xml: &str) -> Result { + // Find the last closing tag of the root element and insert before it + let close_pos = text.rfind(" Result<()> { let ext = request .path @@ -537,6 +808,22 @@ fn sign_script(request: &PortableSignRequest, output_path: &Path) -> Result<()> std::fs::write(output_path, signed).with_context(|| format!("write {}", output_path.display())) } +fn has_azure_key_vault_provider(request: &PortableSignRequest) -> bool { + request.azure_key_vault_url.is_some() +} + +fn has_artifact_signing_provider(request: &PortableSignRequest) -> bool { + request.artifact_signing_endpoint.is_some() || request.artifact_signing_account_name.is_some() +} + +fn has_local_signing_material(request: &PortableSignRequest) -> bool { + request.certificate_path.is_some() + || request.certificate_der_base64.is_some() + || request.pfx_path.is_some() + || request.private_key_path.is_some() + || request.private_key_der_base64.is_some() +} + fn load_signing_material( request: &PortableSignRequest, ) -> Result<( @@ -544,6 +831,55 @@ fn load_signing_material( rsa::RsaPrivateKey, Vec, )> { + // Reject mixed local + cloud providers + let has_akv = has_azure_key_vault_provider(request); + let has_as = has_artifact_signing_provider(request); + let has_local = has_local_signing_material(request); + + if has_akv && has_as { + bail!( + "provide only one cloud signing provider (Azure Key Vault or Artifact Signing), not both" + ); + } + if has_akv && has_local { + bail!( + "provide either Azure Key Vault cloud signing or local certificate/key material, not both" + ); + } + if has_as && has_local { + bail!( + "provide either Artifact Signing cloud signing or local certificate/key material, not both" + ); + } + if has_akv { + #[cfg(feature = "azure-kv-sign")] + { + bail!( + "Azure Key Vault portable signing is not yet available through this API — use psign-tool code azure-key-vault" + ); + } + #[cfg(not(feature = "azure-kv-sign"))] + { + bail!( + "Azure Key Vault signing support is not compiled into this build (feature: azure-kv-sign)" + ); + } + } + if has_as { + #[cfg(feature = "artifact-signing-rest")] + { + bail!( + "Artifact Signing portable signing is not yet available through this API — use psign-tool code artifact-signing" + ); + } + #[cfg(not(feature = "artifact-signing-rest"))] + { + bail!( + "Artifact Signing support is not compiled into this build (feature: artifact-signing-rest)" + ); + } + } + let uses_pfx = request.pfx_path.is_some(); if uses_pfx && (request.certificate_der_base64.is_some() @@ -823,6 +1159,13 @@ fn apply_trust_if_requested( ) }) } + PortableFileFormat::NuGet + | PortableFileFormat::AppInstaller + | PortableFileFormat::Vsix + | PortableFileFormat::ClickOnceManifest => Err(anyhow::anyhow!( + "explicit trust verification is not yet available for format {:?} through the portable inspection path", + format + )), _ => Err(anyhow::anyhow!( "explicit trust verification is not implemented for format {:?}", format @@ -1074,6 +1417,160 @@ fn inspect_zip(path: &Path, data: &[u8]) -> Result { }) } +fn inspect_nuget(path: &Path, data: &[u8]) -> Result { + let has_sig = psign_opc_sign::nuget::inspect_nupkg_path(path) + .map(|info| info.signed) + .unwrap_or(false); + if !has_sig { + return Ok(base_response( + path.to_path_buf(), + PortableFileFormat::NuGet, + PortableSignatureStatus::NotSigned, + "NuGet package does not contain .signature.p7s", + )); + } + // Extract signature and parse CMS + let sig_bytes = extract_nuget_signature_p7s(data)?; + let report = inspect_authenticode_pkcs7_der(&sig_bytes).ok(); + let summary = report + .map(|r| summarize_pkcs7_reports(std::iter::once(r))) + .unwrap_or_default(); + Ok(PortableSignatureResponse { + schema_version: SCHEMA_VERSION, + path: path.to_path_buf(), + format: PortableFileFormat::NuGet, + status: PortableSignatureStatus::Valid, + status_message: + "NuGet package signature (.signature.p7s) is present; trust was not evaluated." + .to_string(), + trust_status: None, + signature_count: 1, + signer_index: summary.signer_index, + signer_certificate_der_base64: summary.signer_certificate_der_base64, + timestamper_certificate_der_base64: summary.timestamper_certificate_der_base64, + embedded_certificate_count: summary.embedded_certificate_count, + digest_algorithm: summary.digest_algorithm, + timestamp_kinds: summary.timestamp_kinds, + timestamp_signing_time: summary.timestamp_signing_time, + diagnostics: Vec::new(), + }) +} + +fn inspect_vsix_opc(path: &Path, data: &[u8]) -> Result { + let has_sig = psign_opc_sign::vsix::inspect_vsix_path(path) + .map(|info| info.has_opc_signature) + .unwrap_or(false); + if !has_sig { + return Ok(base_response( + path.to_path_buf(), + PortableFileFormat::Vsix, + PortableSignatureStatus::NotSigned, + "VSIX package does not contain an OPC digital signature", + )); + } + // Extract the signature XML and report + let sig_xml = psign_opc_sign::vsix::extract_signature_xml_path(path).unwrap_or_default(); + if sig_xml.is_empty() { + return Ok(base_response( + path.to_path_buf(), + PortableFileFormat::Vsix, + PortableSignatureStatus::NotSigned, + "VSIX package OPC signature part could not be extracted", + )); + } + // Verify reference digests + let vsix_alg = psign_opc_sign::vsix::VsixHashAlgorithm::Sha256; + let refs_ok = + psign_opc_sign::vsix::verify_signature_reference_xml(Cursor::new(data), &sig_xml, vsix_alg) + .is_ok(); + let status = if refs_ok { + PortableSignatureStatus::Valid + } else { + PortableSignatureStatus::HashMismatch + }; + let message = if refs_ok { + "VSIX OPC XMLDSig signature references are valid; trust was not evaluated." + } else { + "VSIX OPC XMLDSig signature reference digests do not match package content." + }; + Ok(base_response( + path.to_path_buf(), + PortableFileFormat::Vsix, + status, + message, + )) +} + +fn inspect_clickonce_manifest(path: &Path, data: &[u8]) -> Result { + let text = std::str::from_utf8(data).unwrap_or(""); + let has_sig = text.contains(""); + if !has_sig { + return Ok(base_response( + path.to_path_buf(), + PortableFileFormat::ClickOnceManifest, + PortableSignatureStatus::NotSigned, + "ClickOnce manifest does not contain an XMLDSig Signature element", + )); + } + Ok(base_response( + path.to_path_buf(), + PortableFileFormat::ClickOnceManifest, + PortableSignatureStatus::Valid, + "ClickOnce manifest contains an XMLDSig Signature; trust was not evaluated.", + )) +} + +fn inspect_appinstaller(path: &Path) -> Result { + // Check for companion .p7 file + let companion_path = path.with_extension( + path.extension() + .map(|e| format!("{}.p7", e.to_string_lossy())) + .unwrap_or_else(|| "p7".to_string()), + ); + if !companion_path.exists() { + return Ok(base_response( + path.to_path_buf(), + PortableFileFormat::AppInstaller, + PortableSignatureStatus::NotSigned, + "App Installer descriptor does not have a companion .p7 signature file", + )); + } + let pkcs7_bytes = std::fs::read(&companion_path) + .with_context(|| format!("read companion {}", companion_path.display()))?; + let report = inspect_authenticode_pkcs7_der(&pkcs7_bytes).ok(); + let summary = report + .map(|r| summarize_pkcs7_reports(std::iter::once(r))) + .unwrap_or_default(); + Ok(PortableSignatureResponse { + schema_version: SCHEMA_VERSION, + path: path.to_path_buf(), + format: PortableFileFormat::AppInstaller, + status: PortableSignatureStatus::Valid, + status_message: + "App Installer companion .p7 signature is present; trust was not evaluated.".to_string(), + trust_status: None, + signature_count: 1, + signer_index: summary.signer_index, + signer_certificate_der_base64: summary.signer_certificate_der_base64, + timestamper_certificate_der_base64: summary.timestamper_certificate_der_base64, + embedded_certificate_count: summary.embedded_certificate_count, + digest_algorithm: summary.digest_algorithm, + timestamp_kinds: summary.timestamp_kinds, + timestamp_signing_time: summary.timestamp_signing_time, + diagnostics: Vec::new(), + }) +} + +fn extract_nuget_signature_p7s(data: &[u8]) -> Result> { + let mut archive = ZipArchive::new(Cursor::new(data)).context("open NuGet ZIP")?; + let mut entry = archive + .by_name(psign_opc_sign::nuget::PACKAGE_SIGNATURE_FILE_NAME) + .context("read .signature.p7s")?; + let mut p7s = Vec::new(); + entry.read_to_end(&mut p7s)?; + Ok(p7s) +} + fn inspect_script(path: &Path, data: &[u8]) -> Result { let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("ps1"); match verify_script_digest_consistency(data, ext) { @@ -1453,6 +1950,14 @@ fn xml_escape_attr(value: &str) -> String { .replace('>', ">") } +fn der_encode_octet_string(data: &[u8]) -> Result> { + let octet = der::asn1::OctetString::new(data) + .map_err(|e| anyhow::anyhow!("encode OCTET STRING: {e}"))?; + octet + .to_der() + .map_err(|e| anyhow::anyhow!("encode OCTET STRING DER: {e}")) +} + fn format_powershell_signature_block(pkcs7_der: &[u8], extension: &str) -> String { let b64 = base64::engine::general_purpose::STANDARD.encode(pkcs7_der); let (begin, line_prefix, line_suffix, end) = match extension.to_ascii_lowercase().as_str() { @@ -1498,8 +2003,36 @@ mod tests { ); assert_eq!( infer_format(Path::new("package.nupkg")), + PortableFileFormat::NuGet + ); + assert_eq!( + infer_format(Path::new("symbols.snupkg")), + PortableFileFormat::NuGet + ); + assert_eq!( + infer_format(Path::new("extension.vsix")), + PortableFileFormat::Vsix + ); + assert_eq!( + infer_format(Path::new("archive.zip")), PortableFileFormat::Zip ); + assert_eq!( + infer_format(Path::new("app.manifest")), + PortableFileFormat::ClickOnceManifest + ); + assert_eq!( + infer_format(Path::new("deploy.application")), + PortableFileFormat::ClickOnceManifest + ); + assert_eq!( + infer_format(Path::new("addin.vsto")), + PortableFileFormat::ClickOnceManifest + ); + assert_eq!( + infer_format(Path::new("installer.appinstaller")), + PortableFileFormat::AppInstaller + ); assert_eq!( infer_format(Path::new("install.msi")), PortableFileFormat::Msi @@ -1564,4 +2097,102 @@ mod tests { assert_eq!(response.status, PortableSignatureStatus::Valid); assert!(response.signature_count > 0); } + + #[test] + fn rejects_mixed_azure_key_vault_and_local_material() { + let request = PortableSignRequest { + path: PathBuf::from("test.dll"), + azure_key_vault_url: Some("https://myvault.vault.azure.net".to_string()), + azure_key_vault_certificate: Some("my-cert".to_string()), + certificate_path: Some(PathBuf::from("cert.pem")), + private_key_path: Some(PathBuf::from("key.pem")), + ..default_sign_request() + }; + let err = load_signing_material(&request).unwrap_err(); + assert!( + err.to_string().contains("not both"), + "expected mutual exclusion error, got: {err}" + ); + } + + #[test] + fn rejects_mixed_azure_key_vault_and_artifact_signing() { + let request = PortableSignRequest { + path: PathBuf::from("test.dll"), + azure_key_vault_url: Some("https://myvault.vault.azure.net".to_string()), + azure_key_vault_certificate: Some("my-cert".to_string()), + artifact_signing_endpoint: Some("https://signing.example.com".to_string()), + ..default_sign_request() + }; + let err = load_signing_material(&request).unwrap_err(); + assert!( + err.to_string().contains("not both"), + "expected mutual exclusion error, got: {err}" + ); + } + + #[test] + fn rejects_azure_key_vault_without_compiled_feature() { + let request = PortableSignRequest { + path: PathBuf::from("test.dll"), + azure_key_vault_url: Some("https://myvault.vault.azure.net".to_string()), + azure_key_vault_certificate: Some("my-cert".to_string()), + ..default_sign_request() + }; + let err = load_signing_material(&request).unwrap_err(); + // Without the feature compiled, we get either "not compiled" or "not yet available" + let msg = err.to_string(); + assert!( + msg.contains("Azure Key Vault"), + "expected AKV error, got: {msg}" + ); + } + + #[test] + fn rejects_artifact_signing_without_compiled_feature() { + let request = PortableSignRequest { + path: PathBuf::from("test.dll"), + artifact_signing_endpoint: Some("https://signing.example.com".to_string()), + ..default_sign_request() + }; + let err = load_signing_material(&request).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("Artifact Signing"), + "expected AS error, got: {msg}" + ); + } + + fn default_sign_request() -> PortableSignRequest { + PortableSignRequest { + path: PathBuf::from("test.dll"), + output_path: None, + hash_algorithm: PortableDigestAlgorithm::Sha256, + certificate_path: None, + private_key_path: None, + certificate_der_base64: None, + private_key_der_base64: None, + pfx_path: None, + pfx_password: None, + chain_certificate_paths: vec![], + chain_certificates_der_base64: vec![], + timestamp_server: None, + timestamp_hash_algorithm: None, + azure_key_vault_url: None, + azure_key_vault_certificate: None, + azure_key_vault_access_token: None, + azure_key_vault_client_id: None, + azure_key_vault_client_secret: None, + azure_key_vault_tenant_id: None, + azure_key_vault_managed_identity: None, + artifact_signing_endpoint: None, + artifact_signing_account_name: None, + artifact_signing_profile_name: None, + artifact_signing_access_token: None, + artifact_signing_managed_identity: None, + artifact_signing_tenant_id: None, + artifact_signing_client_id: None, + artifact_signing_client_secret: None, + } + } } diff --git a/crates/psign-portable-ffi/Cargo.toml b/crates/psign-portable-ffi/Cargo.toml index 79c7071..2fd4c66 100644 --- a/crates/psign-portable-ffi/Cargo.toml +++ b/crates/psign-portable-ffi/Cargo.toml @@ -10,6 +10,11 @@ repository.workspace = true name = "psign_core" crate-type = ["cdylib", "rlib"] +[features] +default = [] +azure-kv-sign = ["psign-portable-core/azure-kv-sign"] +artifact-signing-rest = ["psign-portable-core/artifact-signing-rest"] + [dependencies] anyhow = "1" psign-portable-core = { path = "../psign-portable-core" } diff --git a/docs/migration-dotnet-sign.md b/docs/migration-dotnet-sign.md index 9fae54b..9ed352f 100644 --- a/docs/migration-dotnet-sign.md +++ b/docs/migration-dotnet-sign.md @@ -102,3 +102,60 @@ psign-tool portable appinstaller-sign-companion-from-signature app.appinstaller ``` Keep production recursive/nested package signing on dotnet/sign until the remaining execution gaps above are closed. + +## PowerShell module (`Devolutions.Psign`) + +The `Set-PortableSignature` and `Get-PortableSignature` cmdlets provide a PowerShell-native experience for portable signing. The module now supports package-native signing for NuGet, VSIX, ClickOnce, and App Installer formats in addition to PE/DLL and PowerShell scripts. + +### Supported formats + +| Extension | Signing method | +|-----------|---------------| +| `.dll`, `.exe`, `.sys`, `.efi`, `.winmd` | PE Authenticode (portable digest + CMS) | +| `.ps1`, `.psm1`, `.psd1`, `.ps1xml`, `.psc1`, `.cdxml`, `.mof` | PowerShell script signature block | +| `.nupkg`, `.snupkg` | NuGet package-native CMS (`.signature.p7s`) | +| `.vsix` | VSIX OPC XMLDSig | +| `.manifest`, `.application`, `.vsto` | ClickOnce enveloped XMLDSig | +| `.appinstaller` | Detached PKCS#7 companion (`.appinstaller.p7`) | +| `.msi`, `.msp` | MSI Authenticode (portable digest + CMS) | + +### Examples + +```powershell +# Sign a NuGet package with local cert/key +Set-PortableSignature -FilePath package.nupkg -CertificatePath signer.der -PrivateKeyPath signer.pkcs8 + +# Sign a VSIX with a PFX +Set-PortableSignature -FilePath extension.vsix -PfxPath signer.pfx -Password $securePassword + +# Sign a ClickOnce manifest +Set-PortableSignature -FilePath app.exe.manifest -CertificatePath signer.der -PrivateKeyPath signer.pkcs8 + +# Sign all signable files in a module directory +Set-PortableSignature -FilePath ./MyModule -CertificatePath signer.der -PrivateKeyPath signer.pkcs8 + +# Inspect signature status +Get-PortableSignature -FilePath signed.nupkg +Get-PortableSignature -FilePath signed.vsix +``` + +### Cloud provider parameters (reserved) + +The following parameters are accepted for future Azure Key Vault and Artifact Signing support: + +```powershell +# Azure Key Vault (reserved — currently returns a clear error) +Set-PortableSignature -FilePath package.nupkg ` + -AzureKeyVaultUrl "https://myvault.vault.azure.net" ` + -AzureKeyVaultCertificate "my-cert" ` + -AzureKeyVaultAccessToken $token + +# Artifact Signing / Trusted Signing (reserved — currently returns a clear error) +Set-PortableSignature -FilePath package.nupkg ` + -ArtifactSigningEndpoint "https://wus2.codesigning.azure.net" ` + -ArtifactSigningAccountName "my-account" ` + -ArtifactSigningProfileName "my-profile" ` + -ArtifactSigningAccessToken $token +``` + +These parameters validate mutual exclusion and surface clear diagnostics until the cloud provider backend is wired in. Use `psign-tool code azure-key-vault` or `psign-tool code artifact-signing` for immediate cloud signing workflows. diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs index a88e981..7edad22 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs @@ -86,6 +86,53 @@ public sealed class SetPortableSignatureCommand : PSCmdlet [Parameter] public SwitchParameter Force { get; set; } + // Azure Key Vault parameters + [Parameter] + public string? AzureKeyVaultUrl { get; set; } + + [Parameter] + public string? AzureKeyVaultCertificate { get; set; } + + [Parameter] + public string? AzureKeyVaultAccessToken { get; set; } + + [Parameter] + public string? AzureKeyVaultClientId { get; set; } + + [Parameter] + public string? AzureKeyVaultClientSecret { get; set; } + + [Parameter] + public string? AzureKeyVaultTenantId { get; set; } + + [Parameter] + public SwitchParameter AzureKeyVaultManagedIdentity { get; set; } + + // Artifact Signing / Trusted Signing parameters + [Parameter] + public string? ArtifactSigningEndpoint { get; set; } + + [Parameter] + public string? ArtifactSigningAccountName { get; set; } + + [Parameter] + public string? ArtifactSigningProfileName { get; set; } + + [Parameter] + public string? ArtifactSigningAccessToken { get; set; } + + [Parameter] + public SwitchParameter ArtifactSigningManagedIdentity { get; set; } + + [Parameter] + public string? ArtifactSigningTenantId { get; set; } + + [Parameter] + public string? ArtifactSigningClientId { get; set; } + + [Parameter] + public string? ArtifactSigningClientSecret { get; set; } + protected override void ProcessRecord() { ValidateSigningMaterial(); @@ -150,27 +197,42 @@ private void SignContent(string sourcePathOrExtension) return; } - PortableSignResponse response = PsignNative.Sign(new PortableSignRequest - { - Path = tempPath, - HashAlgorithm = HashAlgorithm, - CertificatePath = CertificatePath is null - ? null - : SessionState.Path.GetUnresolvedProviderPathFromPSPath(CertificatePath), - PrivateKeyPath = PrivateKeyPath is null - ? null - : SessionState.Path.GetUnresolvedProviderPathFromPSPath(PrivateKeyPath), - CertificateDerBase64 = GetCertificateDerBase64(), - PrivateKeyDerBase64 = GetPrivateKeyDerBase64(), - PfxPath = PfxPath is null - ? null - : SessionState.Path.GetUnresolvedProviderPathFromPSPath(PfxPath), - PfxPassword = Password is null ? null : SecureStringToString(Password), - ChainCertificatePaths = GetChainCertificatePaths(), - ChainCertificatesDerBase64 = GetChainCertificatesDerBase64(), - TimestampServer = TimestampServer, - TimestampHashAlgorithm = TimestampServer is null ? null : TimestampHashAlgorithm, - }); + PortableSignResponse response = PsignNative.Sign(new PortableSignRequest + { + Path = tempPath, + HashAlgorithm = HashAlgorithm, + CertificatePath = CertificatePath is null + ? null + : SessionState.Path.GetUnresolvedProviderPathFromPSPath(CertificatePath), + PrivateKeyPath = PrivateKeyPath is null + ? null + : SessionState.Path.GetUnresolvedProviderPathFromPSPath(PrivateKeyPath), + CertificateDerBase64 = GetCertificateDerBase64(), + PrivateKeyDerBase64 = GetPrivateKeyDerBase64(), + PfxPath = PfxPath is null + ? null + : SessionState.Path.GetUnresolvedProviderPathFromPSPath(PfxPath), + PfxPassword = Password is null ? null : SecureStringToString(Password), + ChainCertificatePaths = GetChainCertificatePaths(), + ChainCertificatesDerBase64 = GetChainCertificatesDerBase64(), + TimestampServer = TimestampServer, + TimestampHashAlgorithm = TimestampServer is null ? null : TimestampHashAlgorithm, + AzureKeyVaultUrl = AzureKeyVaultUrl, + AzureKeyVaultCertificate = AzureKeyVaultCertificate, + AzureKeyVaultAccessToken = AzureKeyVaultAccessToken, + AzureKeyVaultClientId = AzureKeyVaultClientId, + AzureKeyVaultClientSecret = AzureKeyVaultClientSecret, + AzureKeyVaultTenantId = AzureKeyVaultTenantId, + AzureKeyVaultManagedIdentity = AzureKeyVaultManagedIdentity.IsPresent ? true : null, + ArtifactSigningEndpoint = ArtifactSigningEndpoint, + ArtifactSigningAccountName = ArtifactSigningAccountName, + ArtifactSigningProfileName = ArtifactSigningProfileName, + ArtifactSigningAccessToken = ArtifactSigningAccessToken, + ArtifactSigningManagedIdentity = ArtifactSigningManagedIdentity.IsPresent ? true : null, + ArtifactSigningTenantId = ArtifactSigningTenantId, + ArtifactSigningClientId = ArtifactSigningClientId, + ArtifactSigningClientSecret = ArtifactSigningClientSecret, + }); response.Signature.SourcePathOrExtension = sourcePathOrExtension; response.Signature.Content = File.ReadAllBytes(tempPath); WriteObject(response.Signature); @@ -231,6 +293,21 @@ private void SignPath(string path) ChainCertificatesDerBase64 = GetChainCertificatesDerBase64(), TimestampServer = TimestampServer, TimestampHashAlgorithm = TimestampServer is null ? null : TimestampHashAlgorithm, + AzureKeyVaultUrl = AzureKeyVaultUrl, + AzureKeyVaultCertificate = AzureKeyVaultCertificate, + AzureKeyVaultAccessToken = AzureKeyVaultAccessToken, + AzureKeyVaultClientId = AzureKeyVaultClientId, + AzureKeyVaultClientSecret = AzureKeyVaultClientSecret, + AzureKeyVaultTenantId = AzureKeyVaultTenantId, + AzureKeyVaultManagedIdentity = AzureKeyVaultManagedIdentity.IsPresent ? true : null, + ArtifactSigningEndpoint = ArtifactSigningEndpoint, + ArtifactSigningAccountName = ArtifactSigningAccountName, + ArtifactSigningProfileName = ArtifactSigningProfileName, + ArtifactSigningAccessToken = ArtifactSigningAccessToken, + ArtifactSigningManagedIdentity = ArtifactSigningManagedIdentity.IsPresent ? true : null, + ArtifactSigningTenantId = ArtifactSigningTenantId, + ArtifactSigningClientId = ArtifactSigningClientId, + ArtifactSigningClientSecret = ArtifactSigningClientSecret, }); WriteObject(response.Signature); } @@ -298,11 +375,27 @@ private void ValidateSigningMaterial() { materialCount++; } + if (AzureKeyVaultUrl is not null) + { + if (AzureKeyVaultCertificate is null) + { + ThrowTerminatingError(new ErrorRecord( + new PSInvalidOperationException("-AzureKeyVaultCertificate is required when using -AzureKeyVaultUrl."), + "PortableSignatureAkvCertificateRequired", + ErrorCategory.InvalidArgument, + this)); + } + materialCount++; + } + if (ArtifactSigningEndpoint is not null || ArtifactSigningAccountName is not null) + { + materialCount++; + } if (materialCount != 1) { ThrowTerminatingError(new ErrorRecord( - new PSInvalidOperationException("Supply exactly one signing source: -Certificate, -CertificatePath/-PrivateKeyPath, -PfxPath, or -Thumbprint with a portable cert store."), + new PSInvalidOperationException("Supply exactly one signing source: -Certificate, -CertificatePath/-PrivateKeyPath, -PfxPath, -Thumbprint, -AzureKeyVaultUrl, or -ArtifactSigningEndpoint/-ArtifactSigningAccountName."), "PortableSignatureSigningMaterialRequired", ErrorCategory.InvalidArgument, this)); diff --git a/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs b/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs index f31681e..7b7b52a 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs @@ -78,4 +78,51 @@ internal sealed class PortableSignRequest [JsonPropertyName("timestamp_hash_algorithm")] public string? TimestampHashAlgorithm { get; init; } + + // Azure Key Vault cloud signing + [JsonPropertyName("azure_key_vault_url")] + public string? AzureKeyVaultUrl { get; init; } + + [JsonPropertyName("azure_key_vault_certificate")] + public string? AzureKeyVaultCertificate { get; init; } + + [JsonPropertyName("azure_key_vault_access_token")] + public string? AzureKeyVaultAccessToken { get; init; } + + [JsonPropertyName("azure_key_vault_client_id")] + public string? AzureKeyVaultClientId { get; init; } + + [JsonPropertyName("azure_key_vault_client_secret")] + public string? AzureKeyVaultClientSecret { get; init; } + + [JsonPropertyName("azure_key_vault_tenant_id")] + public string? AzureKeyVaultTenantId { get; init; } + + [JsonPropertyName("azure_key_vault_managed_identity")] + public bool? AzureKeyVaultManagedIdentity { get; init; } + + // Azure Artifact Signing / Trusted Signing + [JsonPropertyName("artifact_signing_endpoint")] + public string? ArtifactSigningEndpoint { get; init; } + + [JsonPropertyName("artifact_signing_account_name")] + public string? ArtifactSigningAccountName { get; init; } + + [JsonPropertyName("artifact_signing_profile_name")] + public string? ArtifactSigningProfileName { get; init; } + + [JsonPropertyName("artifact_signing_access_token")] + public string? ArtifactSigningAccessToken { get; init; } + + [JsonPropertyName("artifact_signing_managed_identity")] + public bool? ArtifactSigningManagedIdentity { get; init; } + + [JsonPropertyName("artifact_signing_tenant_id")] + public string? ArtifactSigningTenantId { get; init; } + + [JsonPropertyName("artifact_signing_client_id")] + public string? ArtifactSigningClientId { get; init; } + + [JsonPropertyName("artifact_signing_client_secret")] + public string? ArtifactSigningClientSecret { get; init; } } diff --git a/dotnet/Devolutions.Psign.PowerShell/Utilities/PortableModuleFiles.cs b/dotnet/Devolutions.Psign.PowerShell/Utilities/PortableModuleFiles.cs index a9bb172..b4c71ca 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Utilities/PortableModuleFiles.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Utilities/PortableModuleFiles.cs @@ -10,11 +10,25 @@ internal static class PortableModuleFiles ".ps1xml", }; + private static readonly HashSet SignablePackageExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".dll", + ".exe", + ".nupkg", + ".snupkg", + ".vsix", + ".manifest", + ".application", + ".vsto", + ".appinstaller", + }; + internal static IReadOnlyList Enumerate(string directory) { return Directory .EnumerateFiles(directory, "*", SearchOption.AllDirectories) - .Where(path => SignablePowerShellExtensions.Contains(Path.GetExtension(path))) + .Where(path => SignablePowerShellExtensions.Contains(Path.GetExtension(path)) + || SignablePackageExtensions.Contains(Path.GetExtension(path))) .Order(StringComparer.OrdinalIgnoreCase) .ToArray(); } From 0350d79122b0dba754af989e81ca1129c511c0ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 22 May 2026 14:58:23 -0400 Subject: [PATCH 13/14] test: expand PowerShell module coverage and bump 0.3.0 - migrate the PowerShell module test entrypoint to Pester - preserve legacy smoke coverage under Pester - add package-native Pester coverage for NuGet, SNuGet, VSIX, ClickOnce manifests, App Installer, and recursive module trees - document the expanded PowerShell module coverage - bump workspace, module, and package versions to 0.3.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- Cargo.lock | 18 +- Cargo.toml | 2 +- .../Devolutions.Psign/Devolutions.Psign.psd1 | 2 +- .../tests/Invoke-PortableSignatureTests.ps1 | 406 +---------------- .../PortableSignature.LegacySmoke.Tests.ps1 | 12 + .../tests/PortableSignature.LegacySmoke.ps1 | 413 ++++++++++++++++++ .../PortableSignature.PackageNative.Tests.ps1 | 285 ++++++++++++ README.md | 2 +- crates/psign-authenticode-trust/Cargo.toml | 2 +- crates/psign-azure-kv-rest/Cargo.toml | 2 +- crates/psign-codesigning-rest/Cargo.toml | 2 +- crates/psign-digest-cli/Cargo.toml | 2 +- crates/psign-opc-sign/Cargo.toml | 2 +- crates/psign-portable-core/Cargo.toml | 2 +- crates/psign-portable-ffi/Cargo.toml | 2 +- crates/psign-sip-digest/Cargo.toml | 2 +- docs/portable-powershell-module.md | 10 +- nuget/tool/Devolutions.Psign.Tool.csproj | 2 +- 19 files changed, 752 insertions(+), 418 deletions(-) create mode 100644 PowerShell/tests/PortableSignature.LegacySmoke.Tests.ps1 create mode 100644 PowerShell/tests/PortableSignature.LegacySmoke.ps1 create mode 100644 PowerShell/tests/PortableSignature.PackageNative.Tests.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe9eed3..919bfec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: version: - description: Release version to build/publish (for example 0.2.0) + description: Release version to build/publish (for example 0.3.0) required: true type: string publish_nuget: diff --git a/Cargo.lock b/Cargo.lock index 742aa18..23d1825 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2001,7 +2001,7 @@ dependencies = [ [[package]] name = "psign" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "assert_cmd", @@ -2037,7 +2037,7 @@ dependencies = [ [[package]] name = "psign-authenticode-trust" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "authenticode", @@ -2061,7 +2061,7 @@ dependencies = [ [[package]] name = "psign-azure-kv-rest" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "base64", @@ -2076,7 +2076,7 @@ dependencies = [ [[package]] name = "psign-codesigning-rest" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "base64", @@ -2088,7 +2088,7 @@ dependencies = [ [[package]] name = "psign-digest-cli" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "assert_cmd", @@ -2114,7 +2114,7 @@ dependencies = [ [[package]] name = "psign-opc-sign" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "base64", @@ -2124,7 +2124,7 @@ dependencies = [ [[package]] name = "psign-portable-core" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "base64", @@ -2147,7 +2147,7 @@ dependencies = [ [[package]] name = "psign-portable-ffi" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "psign-portable-core", @@ -2157,7 +2157,7 @@ dependencies = [ [[package]] name = "psign-sip-digest" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "authenticode", diff --git a/Cargo.toml b/Cargo.toml index 198fb23..663a679 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ repository = "https://github.com/Devolutions/psign" [package] name = "psign" -version = "0.2.0" +version = "0.3.0" edition = "2024" description = "Rust port of the Windows SDK signtool.exe (Authenticode sign/verify/timestamp) with portable digest helpers." license.workspace = true diff --git a/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 b/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 index 352e765..d78a56b 100644 --- a/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 +++ b/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'Devolutions.Psign.psm1' - ModuleVersion = '0.2.0' + ModuleVersion = '0.3.0' GUID = 'e6e50e4b-bf25-4ed6-a343-49f904e79f8f' Author = 'Devolutions' CompanyName = 'Devolutions' diff --git a/PowerShell/tests/Invoke-PortableSignatureTests.ps1 b/PowerShell/tests/Invoke-PortableSignatureTests.ps1 index 01816ec..f646ab8 100644 --- a/PowerShell/tests/Invoke-PortableSignatureTests.ps1 +++ b/PowerShell/tests/Invoke-PortableSignatureTests.ps1 @@ -8,404 +8,24 @@ $repo = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) $buildScript = Join-Path (Join-Path $repo 'PowerShell') 'build.ps1' & $buildScript -Configuration $Configuration -$modulePath = Join-Path (Join-Path (Join-Path $repo 'PowerShell') 'Devolutions.Psign') 'Devolutions.Psign.psd1' -Import-Module $modulePath -Force - -function Assert-SignerCertificate { - param( - [Parameter(Mandatory)] - $Signature, - [Parameter(Mandatory)] - [System.Security.Cryptography.X509Certificates.X509Certificate2] $ExpectedCertificate, - [Parameter(Mandatory)] - [string] $Label - ) - - if ($null -eq $Signature.SignerCertificate) { - throw "Expected SignerCertificate for $Label." - } - if ($Signature.SignerCertificate.Thumbprint -ne $ExpectedCertificate.Thumbprint) { - throw "Unexpected SignerCertificate thumbprint for $Label." - } - if ($Signature.EmbeddedCertificateCount -lt 1) { - throw "Expected EmbeddedCertificateCount for $Label." - } +$pester = Get-Module -ListAvailable Pester | + Sort-Object Version -Descending | + Select-Object -First 1 +if ($null -eq $pester) { + throw 'Pester 5.x is required to run the PowerShell module test suite.' } -function Start-PsignTimestampServer { - $psi = [System.Diagnostics.ProcessStartInfo]::new() - $psi.FileName = 'cargo' - foreach ($argument in @('run', '--quiet', '--bin', 'psign-server', '--', 'timestamp-server', '--max-requests', '1')) { - $psi.ArgumentList.Add($argument) - } - $psi.WorkingDirectory = $repo - $psi.RedirectStandardOutput = $true - $psi.UseShellExecute = $false - $psi.CreateNoWindow = $true - $process = [System.Diagnostics.Process]::Start($psi) - $line = $process.StandardOutput.ReadLine() - if ($line -notlike 'psign-server timestamp-server listening on *') { - try { - if (-not $process.HasExited) { - $process.Kill($true) - } - } - catch { - } - throw "Failed to start psign timestamp server. First output: $line" - } - [pscustomobject]@{ - Process = $process - Url = $line.Substring('psign-server timestamp-server listening on '.Length) - } -} +Import-Module $pester.Path -Force -$temp = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N')) -New-Item -ItemType Directory -Force -Path $temp | Out-Null +$env:PSIGN_PWSH_TEST_SKIP_BUILD = '1' +$env:PSIGN_PWSH_TEST_CONFIGURATION = $Configuration try { - $rsa = [System.Security.Cryptography.RSA]::Create(2048) - $rootRsa = [System.Security.Cryptography.RSA]::Create(2048) - $rootRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( - 'CN=psign portable root', - $rootRsa, - [System.Security.Cryptography.HashAlgorithmName]::SHA256, - [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) - $rootRequest.CertificateExtensions.Add( - [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($true, $false, 0, $true)) - $rootRequest.CertificateExtensions.Add( - [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( - [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyCertSign -bor - [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::CrlSign, - $true)) - $rootCert = $rootRequest.CreateSelfSigned( - [System.DateTimeOffset]::UtcNow.AddDays(-1), - [System.DateTimeOffset]::UtcNow.AddDays(31)) - - $request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( - 'CN=psign portable test', - $rsa, - [System.Security.Cryptography.HashAlgorithmName]::SHA256, - [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) - $request.CertificateExtensions.Add( - [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($false, $false, 0, $true)) - $request.CertificateExtensions.Add( - [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( - [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature, - $true)) - $ekuOids = [System.Security.Cryptography.OidCollection]::new() - $null = $ekuOids.Add([System.Security.Cryptography.Oid]::new('1.3.6.1.5.5.7.3.3')) - $request.CertificateExtensions.Add( - [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($ekuOids, $false)) - $issuedCert = $request.Create( - $rootCert, - [System.DateTimeOffset]::UtcNow.AddDays(-1), - [System.DateTimeOffset]::UtcNow.AddDays(30), - [byte[]](1, 2, 3, 4, 5, 6, 7, 8)) - $cert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::CopyWithPrivateKey($issuedCert, $rsa) - $certPath = Join-Path $temp 'signer.cer' - $keyPath = Join-Path $temp 'signer.key' - $pfxPath = Join-Path $temp 'signer.pfx' - $pfxPassword = ConvertTo-SecureString -String 'portable-test' -AsPlainText -Force - [System.IO.File]::WriteAllBytes($certPath, $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)) - [System.IO.File]::WriteAllText( - $keyPath, - [System.Security.Cryptography.PemEncoding]::WriteString('PRIVATE KEY', $rsa.ExportPkcs8PrivateKey())) - [System.IO.File]::WriteAllBytes($pfxPath, $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pkcs12, 'portable-test')) - $storeDir = Join-Path $temp 'cert-store' - $storeMyDir = Join-Path (Join-Path $storeDir 'CurrentUser') 'MY' - New-Item -ItemType Directory -Force -Path $storeMyDir | Out-Null - $storeCertPath = Join-Path $storeMyDir "$($cert.Thumbprint.ToUpperInvariant()).der" - $storeKeyPath = Join-Path $storeMyDir "$($cert.Thumbprint.ToUpperInvariant()).key" - Copy-Item -LiteralPath $certPath -Destination $storeCertPath - Copy-Item -LiteralPath $keyPath -Destination $storeKeyPath - $chainRsa = [System.Security.Cryptography.RSA]::Create(2048) - $chainRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( - 'CN=psign portable chain test', - $chainRsa, - [System.Security.Cryptography.HashAlgorithmName]::SHA256, - [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) - $chainCert = $chainRequest.CreateSelfSigned( - [System.DateTimeOffset]::UtcNow.AddDays(-1), - [System.DateTimeOffset]::UtcNow.AddDays(30)) - $chainCertPath = Join-Path $temp 'chain.cer' - [System.IO.File]::WriteAllBytes($chainCertPath, $chainCert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)) - - $unsigned = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'pe') 'tiny32-pe-alias.exe' - $work = Join-Path $temp 'tiny.exe' - Copy-Item $unsigned $work - - if (-not (Get-Command Get-PortableSignature -ErrorAction SilentlyContinue)) { - throw 'Get-PortableSignature was not exported.' - } - if (-not (Get-Command Set-PortableSignature -ErrorAction SilentlyContinue)) { - throw 'Set-PortableSignature was not exported.' - } - $getParameters = (Get-Command Get-PortableSignature).Parameters - foreach ($parameterName in @('FilePath', 'LiteralPath', 'SourcePathOrExtension', 'Content', 'TrustedCertificate', 'TrustedCertificatePath', 'AnchorDirectory', 'AuthRootCab', 'AsOf', 'PreferTimestampSigningTime', 'RequireValidTimestamp', 'OnlineAia', 'OnlineOcsp', 'RevocationMode')) { - if (-not $getParameters.ContainsKey($parameterName)) { - throw "Get-PortableSignature is missing expected migration parameter '$parameterName'." - } - } - $setParameters = (Get-Command Set-PortableSignature).Parameters - foreach ($parameterName in @('FilePath', 'LiteralPath', 'SourcePathOrExtension', 'Content', 'Certificate', 'CertificatePath', 'PrivateKeyPath', 'PfxPath', 'Password', 'Thumbprint', 'CertStoreDirectory', 'StoreName', 'MachineStore', 'IncludeChain', 'ChainCertificatePath', 'TimestampServer', 'TimestampHashAlgorithm', 'HashAlgorithm', 'OutputPath', 'Force')) { - if (-not $setParameters.ContainsKey($parameterName)) { - throw "Set-PortableSignature is missing expected migration parameter '$parameterName'." - } - } - - $before = Get-PortableSignature -LiteralPath $work - if ($before.Status -ne 'NotSigned') { - throw "Expected NotSigned before signing, got $($before.Status)." - } - - $signed = Set-PortableSignature -LiteralPath $work -CertificatePath $certPath -PrivateKeyPath $keyPath - if ($signed.Status -ne 'Valid') { - throw "Expected Valid after signing, got $($signed.Status): $($signed.StatusMessage)" - } - Assert-SignerCertificate -Signature $signed -ExpectedCertificate $cert -Label 'PE signing response' - - $after = Get-PortableSignature -LiteralPath $work - if ($after.Status -ne 'Valid') { - throw "Expected Valid from Get-PortableSignature after signing, got $($after.Status)." - } - Assert-SignerCertificate -Signature $after -ExpectedCertificate $cert -Label 'PE get response' - - $trustedAfter = Get-PortableSignature -LiteralPath $work -TrustedCertificate $rootCert -AsOf ([System.DateTime]::UtcNow) -RevocationMode Off - if ($trustedAfter.Status -ne 'Valid' -or $trustedAfter.TrustStatus -ne 'Valid') { - throw "Expected explicit trust verification to succeed for signed PE, got status=$($trustedAfter.Status) trust=$($trustedAfter.TrustStatus): $($trustedAfter.StatusMessage)" - } - $untrustedAfter = Get-PortableSignature -LiteralPath $work -TrustedCertificate $chainCert - if ($untrustedAfter.Status -ne 'NotTrusted' -or $untrustedAfter.TrustStatus -ne 'NotTrusted') { - throw "Expected explicit trust verification to fail with wrong anchor, got status=$($untrustedAfter.Status) trust=$($untrustedAfter.TrustStatus): $($untrustedAfter.StatusMessage)" - } - - $length = (Get-Item -LiteralPath $work).Length - Set-PortableSignature -LiteralPath $work -CertificatePath $certPath -PrivateKeyPath $keyPath -WhatIf | Out-Null - if ((Get-Item -LiteralPath $work).Length -ne $length) { - throw 'Set-PortableSignature -WhatIf mutated the file.' - } - - $readOnlyWork = Join-Path $temp 'tiny-readonly.exe' - Copy-Item $unsigned $readOnlyWork - Set-ItemProperty -LiteralPath $readOnlyWork -Name IsReadOnly -Value $true - try { - $failedWithoutForce = $false - try { - Set-PortableSignature -LiteralPath $readOnlyWork -CertificatePath $certPath -PrivateKeyPath $keyPath -ErrorAction Stop | Out-Null - } - catch { - $failedWithoutForce = $true - } - if (-not $failedWithoutForce) { - throw 'Expected Set-PortableSignature to fail on a read-only file without -Force.' - } - $forceSigned = Set-PortableSignature -LiteralPath $readOnlyWork -CertificatePath $certPath -PrivateKeyPath $keyPath -Force - if ($forceSigned.Status -ne 'Valid') { - throw "Expected Valid after read-only file signing with -Force, got $($forceSigned.Status): $($forceSigned.StatusMessage)" - } - if (-not (Get-Item -LiteralPath $readOnlyWork).IsReadOnly) { - throw 'Expected Set-PortableSignature -Force to restore the read-only attribute.' - } - } - finally { - Set-ItemProperty -LiteralPath $readOnlyWork -Name IsReadOnly -Value $false -ErrorAction SilentlyContinue - } - - $storeWork = Join-Path $temp 'tiny-store.exe' - Copy-Item $unsigned $storeWork - $storeSigned = Set-PortableSignature -LiteralPath $storeWork -Sha1 $cert.Thumbprint -CertStoreDirectory $storeDir - if ($storeSigned.Status -ne 'Valid') { - throw "Expected Valid after portable cert-store signing, got $($storeSigned.Status): $($storeSigned.StatusMessage)" - } - Assert-SignerCertificate -Signature $storeSigned -ExpectedCertificate $cert -Label 'portable cert-store signing response' - - $chainWork = Join-Path $temp 'tiny-chain.exe' - Copy-Item $unsigned $chainWork - $defaultChainWork = Join-Path $temp 'tiny-chain-default.exe' - Copy-Item $unsigned $defaultChainWork - $defaultChainSigned = Set-PortableSignature -LiteralPath $defaultChainWork -CertificatePath $certPath -PrivateKeyPath $keyPath -ChainCertificatePath $chainCertPath - if ($defaultChainSigned.EmbeddedCertificateCount -ne 1) { - throw "Expected default IncludeChain NotRoot to exclude a self-signed root certificate, got $($defaultChainSigned.EmbeddedCertificateCount) embedded certificates." - } - $chainSigned = Set-PortableSignature -LiteralPath $chainWork -CertificatePath $certPath -PrivateKeyPath $keyPath -IncludeChain All -ChainCertificatePath $chainCertPath - if ($chainSigned.EmbeddedCertificateCount -lt 2) { - throw "Expected IncludeChain All with ChainCertificatePath to embed at least 2 certificates, got $($chainSigned.EmbeddedCertificateCount)." - } - - $unsignedCab = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'cab') 'sample.cab' - $cabWork = Join-Path $temp 'sample.cab' - Copy-Item $unsignedCab $cabWork - $cabSigned = Set-PortableSignature -LiteralPath $cabWork -CertificatePath $certPath -PrivateKeyPath $keyPath - if ($cabSigned.Status -ne 'Valid') { - throw "Expected Valid after CAB signing, got $($cabSigned.Status): $($cabSigned.StatusMessage)" - } - Assert-SignerCertificate -Signature $cabSigned -ExpectedCertificate $cert -Label 'CAB signing response' - $cabAfter = Get-PortableSignature -LiteralPath $cabWork - if ($cabAfter.Status -ne 'Valid') { - throw "Expected Valid from Get-PortableSignature for signed CAB, got $($cabAfter.Status): $($cabAfter.StatusMessage)" - } - - $unsignedMsi = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'installer') 'tiny.msi' - $msiWork = Join-Path $temp 'tiny.msi' - Copy-Item $unsignedMsi $msiWork - $msiSigned = Set-PortableSignature -LiteralPath $msiWork -CertificatePath $certPath -PrivateKeyPath $keyPath - if ($msiSigned.Status -ne 'Valid') { - throw "Expected Valid after MSI signing, got $($msiSigned.Status): $($msiSigned.StatusMessage)" - } - Assert-SignerCertificate -Signature $msiSigned -ExpectedCertificate $cert -Label 'MSI signing response' - $msiAfter = Get-PortableSignature -LiteralPath $msiWork - if ($msiAfter.Status -ne 'Valid') { - throw "Expected Valid from Get-PortableSignature for signed MSI, got $($msiAfter.Status): $($msiAfter.StatusMessage)" - } - - $zipSource = Join-Path $temp 'zip-source' - New-Item -ItemType Directory -Force -Path $zipSource | Out-Null - Set-Content -LiteralPath (Join-Path $zipSource 'payload.txt') -Value 'portable zip authenticode' -Encoding UTF8 - $zipWork = Join-Path $temp 'payload.zip' - Compress-Archive -LiteralPath (Join-Path $zipSource 'payload.txt') -DestinationPath $zipWork - $zipSigned = Set-PortableSignature -LiteralPath $zipWork -CertificatePath $certPath -PrivateKeyPath $keyPath - if ($zipSigned.Status -ne 'Valid') { - throw "Expected Valid after ZIP signing, got $($zipSigned.Status): $($zipSigned.StatusMessage)" - } - Assert-SignerCertificate -Signature $zipSigned -ExpectedCertificate $cert -Label 'ZIP signing response' - $zipAfter = Get-PortableSignature -LiteralPath $zipWork - if ($zipAfter.Status -ne 'Valid') { - throw "Expected Valid from Get-PortableSignature for signed ZIP, got $($zipAfter.Status): $($zipAfter.StatusMessage)" - } - - $scriptPath = Join-Path $temp 'Invoke-Test.ps1' - Set-Content -LiteralPath $scriptPath -Value @' -param([string] $Name = "portable") -"Hello $Name" -'@ -Encoding UTF8 - $scriptSigned = Set-PortableSignature -LiteralPath $scriptPath -Certificate $cert - if ($scriptSigned.Status -ne 'Valid') { - throw "Expected Valid for signed PowerShell script, got $($scriptSigned.Status): $($scriptSigned.StatusMessage)" - } - Assert-SignerCertificate -Signature $scriptSigned -ExpectedCertificate $cert -Label 'script signing response' - $scriptAfter = Get-PortableSignature -LiteralPath $scriptPath - if ($scriptAfter.Status -ne 'Valid') { - throw "Expected Valid from Get-PortableSignature for signed script, got $($scriptAfter.Status)." - } - $trustedScript = Get-PortableSignature -LiteralPath $scriptPath -TrustedCertificate $rootCert - if ($trustedScript.Status -ne 'Valid' -or $trustedScript.TrustStatus -ne 'Valid') { - throw "Expected explicit trust verification to succeed for signed script, got status=$($trustedScript.Status) trust=$($trustedScript.TrustStatus): $($trustedScript.StatusMessage)" - } - Add-Content -LiteralPath $scriptPath -Value '# tamper' - $scriptTampered = Get-PortableSignature -LiteralPath $scriptPath - if ($scriptTampered.Status -ne 'HashMismatch') { - throw "Expected HashMismatch for tampered signed script, got $($scriptTampered.Status): $($scriptTampered.StatusMessage)" - } - - $ps1xmlPath = Join-Path $temp 'Types.ps1xml' - Set-Content -LiteralPath $ps1xmlPath -Value @' - - - Portable.Type - - -'@ -Encoding UTF8 - $ps1xmlSigned = Set-PortableSignature -LiteralPath $ps1xmlPath -Certificate $cert - if ($ps1xmlSigned.Status -ne 'Valid') { - throw "Expected Valid for signed ps1xml, got $($ps1xmlSigned.Status): $($ps1xmlSigned.StatusMessage)" - } - Assert-SignerCertificate -Signature $ps1xmlSigned -ExpectedCertificate $cert -Label 'ps1xml signing response' - $ps1xmlText = Get-Content -LiteralPath $ps1xmlPath -Raw - if ($ps1xmlText -notmatch '') { - throw 'Expected signed ps1xml to use XML Authenticode signature markers.' - } - $ps1xmlAfter = Get-PortableSignature -LiteralPath $ps1xmlPath - if ($ps1xmlAfter.Status -ne 'Valid') { - throw "Expected Valid from Get-PortableSignature for signed ps1xml, got $($ps1xmlAfter.Status): $($ps1xmlAfter.StatusMessage)" - } - Add-Content -LiteralPath $ps1xmlPath -Value '' - $ps1xmlTampered = Get-PortableSignature -LiteralPath $ps1xmlPath - if ($ps1xmlTampered.Status -ne 'HashMismatch') { - throw "Expected HashMismatch for tampered signed ps1xml, got $($ps1xmlTampered.Status): $($ps1xmlTampered.StatusMessage)" - } - - $scriptContent = [System.Text.Encoding]::UTF8.GetBytes("'content mode'") - $contentSigned = Set-PortableSignature -SourcePathOrExtension '.ps1' -Content $scriptContent -Certificate $cert - if ($contentSigned.Status -ne 'Valid') { - throw "Expected Valid for signed PowerShell script content, got $($contentSigned.Status): $($contentSigned.StatusMessage)" - } - if ($null -eq $contentSigned.Content -or $contentSigned.Content.Length -le $scriptContent.Length) { - throw 'Expected Set-PortableSignature -Content to return signed content bytes.' - } - Assert-SignerCertificate -Signature $contentSigned -ExpectedCertificate $cert -Label 'script content signing response' - $contentAfter = Get-PortableSignature -SourcePathOrExtension '.ps1' -Content $contentSigned.Content - if ($contentAfter.Status -ne 'Valid') { - throw "Expected Valid from Get-PortableSignature -Content for signed script, got $($contentAfter.Status): $($contentAfter.StatusMessage)" - } - - $timestampServer = Start-PsignTimestampServer - try { - $timestampScript = Join-Path $temp 'Timestamped.ps1' - Set-Content -LiteralPath $timestampScript -Value '"timestamped"' -Encoding UTF8 - $timestamped = Set-PortableSignature -LiteralPath $timestampScript -Certificate $cert -TimestampServer $timestampServer.Url -TimestampHashAlgorithm Sha256 - if ($timestamped.Status -ne 'Valid') { - throw "Expected Valid for timestamped script, got $($timestamped.Status): $($timestamped.StatusMessage)" - } - if ($timestamped.TimestampKinds.Count -eq 0) { - throw 'Expected timestamped script to report a timestamp kind.' - } - if ($null -eq $timestamped.TimeStamperCertificate) { - throw 'Expected timestamped script to expose TimeStamperCertificate.' - } - if (-not $timestamped.PSObject.Properties.Match('TimestampSigningTime')) { - throw 'Expected timestamped script output to include TimestampSigningTime.' - } - } - finally { - if (-not $timestampServer.Process.HasExited) { - $timestampServer.Process.Kill($true) - } - $timestampServer.Process.Dispose() - } - - $moduleDir = Join-Path $temp 'PortableModule' - $nestedDir = Join-Path $moduleDir 'Private' - New-Item -ItemType Directory -Force -Path $nestedDir | Out-Null - Set-Content -LiteralPath (Join-Path $moduleDir 'PortableModule.psm1') -Value 'function Get-PortableGreeting { "hello" }' -Encoding UTF8 - Set-Content -LiteralPath (Join-Path $moduleDir 'PortableModule.psd1') -Value "@{ RootModule = 'PortableModule.psm1'; ModuleVersion = '1.0.0'; GUID = '$([System.Guid]::NewGuid())' }" -Encoding UTF8 - Set-Content -LiteralPath (Join-Path $moduleDir 'PortableModule.Types.ps1xml') -Value '' -Encoding UTF8 - Set-Content -LiteralPath (Join-Path $nestedDir 'Helper.ps1') -Value '$script:PortableHelper = $true' -Encoding UTF8 - $moduleSigned = @(Set-PortableSignature -LiteralPath $moduleDir -CertificatePath $certPath -PrivateKeyPath $keyPath) - if ($moduleSigned.Count -ne 4) { - throw "Expected 4 signed PowerShell module files, got $($moduleSigned.Count)." - } - if (@($moduleSigned | Where-Object Status -ne 'Valid').Count -ne 0) { - throw "Expected all signed module files to be Valid, got: $($moduleSigned | ConvertTo-Json -Depth 4)" - } - foreach ($moduleSignature in $moduleSigned) { - Assert-SignerCertificate -Signature $moduleSignature -ExpectedCertificate $cert -Label "module signing response $($moduleSignature.Path)" - } - $moduleValidated = @(Get-PortableSignature -LiteralPath $moduleDir) - if ($moduleValidated.Count -ne 4) { - throw "Expected 4 validated PowerShell module files, got $($moduleValidated.Count)." - } - if (@($moduleValidated | Where-Object Status -ne 'Valid').Count -ne 0) { - throw "Expected all validated module files to be Valid, got: $($moduleValidated | ConvertTo-Json -Depth 4)" - } - - $unsignedMsix = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'msix') 'sample.msix' - $msixWork = Join-Path $temp 'sample.msix' - Copy-Item $unsignedMsix $msixWork - $msixBefore = Get-PortableSignature -LiteralPath $msixWork - if ($msixBefore.Status -notin @('NotSigned', 'Incompatible')) { - throw "Expected unsigned MSIX preflight status before signing, got $($msixBefore.Status)." - } - $msixSigned = Set-PortableSignature -LiteralPath $msixWork -PfxPath $pfxPath -Password $pfxPassword - if ($msixSigned.Status -ne 'Valid') { - throw "Expected Valid after MSIX signing, got $($msixSigned.Status): $($msixSigned.StatusMessage)" - } - Assert-SignerCertificate -Signature $msixSigned -ExpectedCertificate $cert -Label 'MSIX signing response' - $msixAfter = Get-PortableSignature -LiteralPath $msixWork - if ($msixAfter.Status -ne 'Valid') { - throw "Expected Valid from Get-PortableSignature for signed MSIX, got $($msixAfter.Status): $($msixAfter.StatusMessage)" + $result = Invoke-Pester -Path (Join-Path $PSScriptRoot '*.Tests.ps1') -PassThru + if ($result.FailedCount -gt 0) { + throw "PowerShell module tests failed: $($result.FailedCount) failed, $($result.PassedCount) passed." } } finally { - Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue - Remove-Module Devolutions.Psign -Force -ErrorAction SilentlyContinue + Remove-Item Env:PSIGN_PWSH_TEST_SKIP_BUILD -ErrorAction SilentlyContinue + Remove-Item Env:PSIGN_PWSH_TEST_CONFIGURATION -ErrorAction SilentlyContinue } diff --git a/PowerShell/tests/PortableSignature.LegacySmoke.Tests.ps1 b/PowerShell/tests/PortableSignature.LegacySmoke.Tests.ps1 new file mode 100644 index 0000000..c5da8c5 --- /dev/null +++ b/PowerShell/tests/PortableSignature.LegacySmoke.Tests.ps1 @@ -0,0 +1,12 @@ +Describe 'Portable PowerShell module legacy smoke suite' { + It 'passes the pre-existing end-to-end smoke checks' { + $configuration = if ($env:PSIGN_PWSH_TEST_CONFIGURATION) { + $env:PSIGN_PWSH_TEST_CONFIGURATION + } + else { + 'Release' + } + + & (Join-Path $PSScriptRoot 'PortableSignature.LegacySmoke.ps1') -Configuration $configuration + } +} diff --git a/PowerShell/tests/PortableSignature.LegacySmoke.ps1 b/PowerShell/tests/PortableSignature.LegacySmoke.ps1 new file mode 100644 index 0000000..9fe7e5c --- /dev/null +++ b/PowerShell/tests/PortableSignature.LegacySmoke.ps1 @@ -0,0 +1,413 @@ +param( + [string] $Configuration = 'Release' +) + +$ErrorActionPreference = 'Stop' + +$repo = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$buildScript = Join-Path (Join-Path $repo 'PowerShell') 'build.ps1' +if (-not $env:PSIGN_PWSH_TEST_SKIP_BUILD) { + & $buildScript -Configuration $Configuration +} + +$modulePath = Join-Path (Join-Path (Join-Path $repo 'PowerShell') 'Devolutions.Psign') 'Devolutions.Psign.psd1' +Import-Module $modulePath -Force + +function Assert-SignerCertificate { + param( + [Parameter(Mandatory)] + $Signature, + [Parameter(Mandatory)] + [System.Security.Cryptography.X509Certificates.X509Certificate2] $ExpectedCertificate, + [Parameter(Mandatory)] + [string] $Label + ) + + if ($null -eq $Signature.SignerCertificate) { + throw "Expected SignerCertificate for $Label." + } + if ($Signature.SignerCertificate.Thumbprint -ne $ExpectedCertificate.Thumbprint) { + throw "Unexpected SignerCertificate thumbprint for $Label." + } + if ($Signature.EmbeddedCertificateCount -lt 1) { + throw "Expected EmbeddedCertificateCount for $Label." + } +} + +function Start-PsignTimestampServer { + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $psi.FileName = 'cargo' + foreach ($argument in @('run', '--quiet', '--bin', 'psign-server', '--', 'timestamp-server', '--max-requests', '1')) { + $psi.ArgumentList.Add($argument) + } + $psi.WorkingDirectory = $repo + $psi.RedirectStandardOutput = $true + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + $process = [System.Diagnostics.Process]::Start($psi) + $line = $process.StandardOutput.ReadLine() + if ($line -notlike 'psign-server timestamp-server listening on *') { + try { + if (-not $process.HasExited) { + $process.Kill($true) + } + } + catch { + } + throw "Failed to start psign timestamp server. First output: $line" + } + [pscustomobject]@{ + Process = $process + Url = $line.Substring('psign-server timestamp-server listening on '.Length) + } +} + +$temp = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Force -Path $temp | Out-Null +try { + $rsa = [System.Security.Cryptography.RSA]::Create(2048) + $rootRsa = [System.Security.Cryptography.RSA]::Create(2048) + $rootRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=psign portable root', + $rootRsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) + $rootRequest.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($true, $false, 0, $true)) + $rootRequest.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyCertSign -bor + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::CrlSign, + $true)) + $rootCert = $rootRequest.CreateSelfSigned( + [System.DateTimeOffset]::UtcNow.AddDays(-1), + [System.DateTimeOffset]::UtcNow.AddDays(31)) + + $request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=psign portable test', + $rsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($false, $false, 0, $true)) + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature, + $true)) + $ekuOids = [System.Security.Cryptography.OidCollection]::new() + $null = $ekuOids.Add([System.Security.Cryptography.Oid]::new('1.3.6.1.5.5.7.3.3')) + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($ekuOids, $false)) + $issuedCert = $request.Create( + $rootCert, + [System.DateTimeOffset]::UtcNow.AddDays(-1), + [System.DateTimeOffset]::UtcNow.AddDays(30), + [byte[]](1, 2, 3, 4, 5, 6, 7, 8)) + $cert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::CopyWithPrivateKey($issuedCert, $rsa) + $certPath = Join-Path $temp 'signer.cer' + $keyPath = Join-Path $temp 'signer.key' + $pfxPath = Join-Path $temp 'signer.pfx' + $pfxPassword = ConvertTo-SecureString -String 'portable-test' -AsPlainText -Force + [System.IO.File]::WriteAllBytes($certPath, $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)) + [System.IO.File]::WriteAllText( + $keyPath, + [System.Security.Cryptography.PemEncoding]::WriteString('PRIVATE KEY', $rsa.ExportPkcs8PrivateKey())) + [System.IO.File]::WriteAllBytes($pfxPath, $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pkcs12, 'portable-test')) + $storeDir = Join-Path $temp 'cert-store' + $storeMyDir = Join-Path (Join-Path $storeDir 'CurrentUser') 'MY' + New-Item -ItemType Directory -Force -Path $storeMyDir | Out-Null + $storeCertPath = Join-Path $storeMyDir "$($cert.Thumbprint.ToUpperInvariant()).der" + $storeKeyPath = Join-Path $storeMyDir "$($cert.Thumbprint.ToUpperInvariant()).key" + Copy-Item -LiteralPath $certPath -Destination $storeCertPath + Copy-Item -LiteralPath $keyPath -Destination $storeKeyPath + $chainRsa = [System.Security.Cryptography.RSA]::Create(2048) + $chainRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=psign portable chain test', + $chainRsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) + $chainCert = $chainRequest.CreateSelfSigned( + [System.DateTimeOffset]::UtcNow.AddDays(-1), + [System.DateTimeOffset]::UtcNow.AddDays(30)) + $chainCertPath = Join-Path $temp 'chain.cer' + [System.IO.File]::WriteAllBytes($chainCertPath, $chainCert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)) + + $unsigned = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'pe') 'tiny32-pe-alias.exe' + $work = Join-Path $temp 'tiny.exe' + Copy-Item $unsigned $work + + if (-not (Get-Command Get-PortableSignature -ErrorAction SilentlyContinue)) { + throw 'Get-PortableSignature was not exported.' + } + if (-not (Get-Command Set-PortableSignature -ErrorAction SilentlyContinue)) { + throw 'Set-PortableSignature was not exported.' + } + $getParameters = (Get-Command Get-PortableSignature).Parameters + foreach ($parameterName in @('FilePath', 'LiteralPath', 'SourcePathOrExtension', 'Content', 'TrustedCertificate', 'TrustedCertificatePath', 'AnchorDirectory', 'AuthRootCab', 'AsOf', 'PreferTimestampSigningTime', 'RequireValidTimestamp', 'OnlineAia', 'OnlineOcsp', 'RevocationMode')) { + if (-not $getParameters.ContainsKey($parameterName)) { + throw "Get-PortableSignature is missing expected migration parameter '$parameterName'." + } + } + $setParameters = (Get-Command Set-PortableSignature).Parameters + foreach ($parameterName in @('FilePath', 'LiteralPath', 'SourcePathOrExtension', 'Content', 'Certificate', 'CertificatePath', 'PrivateKeyPath', 'PfxPath', 'Password', 'Thumbprint', 'CertStoreDirectory', 'StoreName', 'MachineStore', 'IncludeChain', 'ChainCertificatePath', 'TimestampServer', 'TimestampHashAlgorithm', 'HashAlgorithm', 'OutputPath', 'Force')) { + if (-not $setParameters.ContainsKey($parameterName)) { + throw "Set-PortableSignature is missing expected migration parameter '$parameterName'." + } + } + + $before = Get-PortableSignature -LiteralPath $work + if ($before.Status -ne 'NotSigned') { + throw "Expected NotSigned before signing, got $($before.Status)." + } + + $signed = Set-PortableSignature -LiteralPath $work -CertificatePath $certPath -PrivateKeyPath $keyPath + if ($signed.Status -ne 'Valid') { + throw "Expected Valid after signing, got $($signed.Status): $($signed.StatusMessage)" + } + Assert-SignerCertificate -Signature $signed -ExpectedCertificate $cert -Label 'PE signing response' + + $after = Get-PortableSignature -LiteralPath $work + if ($after.Status -ne 'Valid') { + throw "Expected Valid from Get-PortableSignature after signing, got $($after.Status)." + } + Assert-SignerCertificate -Signature $after -ExpectedCertificate $cert -Label 'PE get response' + + $trustedAfter = Get-PortableSignature -LiteralPath $work -TrustedCertificate $rootCert -AsOf ([System.DateTime]::UtcNow) -RevocationMode Off + if ($trustedAfter.Status -ne 'Valid' -or $trustedAfter.TrustStatus -ne 'Valid') { + throw "Expected explicit trust verification to succeed for signed PE, got status=$($trustedAfter.Status) trust=$($trustedAfter.TrustStatus): $($trustedAfter.StatusMessage)" + } + $untrustedAfter = Get-PortableSignature -LiteralPath $work -TrustedCertificate $chainCert + if ($untrustedAfter.Status -ne 'NotTrusted' -or $untrustedAfter.TrustStatus -ne 'NotTrusted') { + throw "Expected explicit trust verification to fail with wrong anchor, got status=$($untrustedAfter.Status) trust=$($untrustedAfter.TrustStatus): $($untrustedAfter.StatusMessage)" + } + + $length = (Get-Item -LiteralPath $work).Length + Set-PortableSignature -LiteralPath $work -CertificatePath $certPath -PrivateKeyPath $keyPath -WhatIf | Out-Null + if ((Get-Item -LiteralPath $work).Length -ne $length) { + throw 'Set-PortableSignature -WhatIf mutated the file.' + } + + $readOnlyWork = Join-Path $temp 'tiny-readonly.exe' + Copy-Item $unsigned $readOnlyWork + Set-ItemProperty -LiteralPath $readOnlyWork -Name IsReadOnly -Value $true + try { + $failedWithoutForce = $false + try { + Set-PortableSignature -LiteralPath $readOnlyWork -CertificatePath $certPath -PrivateKeyPath $keyPath -ErrorAction Stop | Out-Null + } + catch { + $failedWithoutForce = $true + } + if (-not $failedWithoutForce) { + throw 'Expected Set-PortableSignature to fail on a read-only file without -Force.' + } + $forceSigned = Set-PortableSignature -LiteralPath $readOnlyWork -CertificatePath $certPath -PrivateKeyPath $keyPath -Force + if ($forceSigned.Status -ne 'Valid') { + throw "Expected Valid after read-only file signing with -Force, got $($forceSigned.Status): $($forceSigned.StatusMessage)" + } + if (-not (Get-Item -LiteralPath $readOnlyWork).IsReadOnly) { + throw 'Expected Set-PortableSignature -Force to restore the read-only attribute.' + } + } + finally { + Set-ItemProperty -LiteralPath $readOnlyWork -Name IsReadOnly -Value $false -ErrorAction SilentlyContinue + } + + $storeWork = Join-Path $temp 'tiny-store.exe' + Copy-Item $unsigned $storeWork + $storeSigned = Set-PortableSignature -LiteralPath $storeWork -Sha1 $cert.Thumbprint -CertStoreDirectory $storeDir + if ($storeSigned.Status -ne 'Valid') { + throw "Expected Valid after portable cert-store signing, got $($storeSigned.Status): $($storeSigned.StatusMessage)" + } + Assert-SignerCertificate -Signature $storeSigned -ExpectedCertificate $cert -Label 'portable cert-store signing response' + + $chainWork = Join-Path $temp 'tiny-chain.exe' + Copy-Item $unsigned $chainWork + $defaultChainWork = Join-Path $temp 'tiny-chain-default.exe' + Copy-Item $unsigned $defaultChainWork + $defaultChainSigned = Set-PortableSignature -LiteralPath $defaultChainWork -CertificatePath $certPath -PrivateKeyPath $keyPath -ChainCertificatePath $chainCertPath + if ($defaultChainSigned.EmbeddedCertificateCount -ne 1) { + throw "Expected default IncludeChain NotRoot to exclude a self-signed root certificate, got $($defaultChainSigned.EmbeddedCertificateCount) embedded certificates." + } + $chainSigned = Set-PortableSignature -LiteralPath $chainWork -CertificatePath $certPath -PrivateKeyPath $keyPath -IncludeChain All -ChainCertificatePath $chainCertPath + if ($chainSigned.EmbeddedCertificateCount -lt 2) { + throw "Expected IncludeChain All with ChainCertificatePath to embed at least 2 certificates, got $($chainSigned.EmbeddedCertificateCount)." + } + + $unsignedCab = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'cab') 'sample.cab' + $cabWork = Join-Path $temp 'sample.cab' + Copy-Item $unsignedCab $cabWork + $cabSigned = Set-PortableSignature -LiteralPath $cabWork -CertificatePath $certPath -PrivateKeyPath $keyPath + if ($cabSigned.Status -ne 'Valid') { + throw "Expected Valid after CAB signing, got $($cabSigned.Status): $($cabSigned.StatusMessage)" + } + Assert-SignerCertificate -Signature $cabSigned -ExpectedCertificate $cert -Label 'CAB signing response' + $cabAfter = Get-PortableSignature -LiteralPath $cabWork + if ($cabAfter.Status -ne 'Valid') { + throw "Expected Valid from Get-PortableSignature for signed CAB, got $($cabAfter.Status): $($cabAfter.StatusMessage)" + } + + $unsignedMsi = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'installer') 'tiny.msi' + $msiWork = Join-Path $temp 'tiny.msi' + Copy-Item $unsignedMsi $msiWork + $msiSigned = Set-PortableSignature -LiteralPath $msiWork -CertificatePath $certPath -PrivateKeyPath $keyPath + if ($msiSigned.Status -ne 'Valid') { + throw "Expected Valid after MSI signing, got $($msiSigned.Status): $($msiSigned.StatusMessage)" + } + Assert-SignerCertificate -Signature $msiSigned -ExpectedCertificate $cert -Label 'MSI signing response' + $msiAfter = Get-PortableSignature -LiteralPath $msiWork + if ($msiAfter.Status -ne 'Valid') { + throw "Expected Valid from Get-PortableSignature for signed MSI, got $($msiAfter.Status): $($msiAfter.StatusMessage)" + } + + $zipSource = Join-Path $temp 'zip-source' + New-Item -ItemType Directory -Force -Path $zipSource | Out-Null + Set-Content -LiteralPath (Join-Path $zipSource 'payload.txt') -Value 'portable zip authenticode' -Encoding UTF8 + $zipWork = Join-Path $temp 'payload.zip' + Compress-Archive -LiteralPath (Join-Path $zipSource 'payload.txt') -DestinationPath $zipWork + $zipSigned = Set-PortableSignature -LiteralPath $zipWork -CertificatePath $certPath -PrivateKeyPath $keyPath + if ($zipSigned.Status -ne 'Valid') { + throw "Expected Valid after ZIP signing, got $($zipSigned.Status): $($zipSigned.StatusMessage)" + } + Assert-SignerCertificate -Signature $zipSigned -ExpectedCertificate $cert -Label 'ZIP signing response' + $zipAfter = Get-PortableSignature -LiteralPath $zipWork + if ($zipAfter.Status -ne 'Valid') { + throw "Expected Valid from Get-PortableSignature for signed ZIP, got $($zipAfter.Status): $($zipAfter.StatusMessage)" + } + + $scriptPath = Join-Path $temp 'Invoke-Test.ps1' + Set-Content -LiteralPath $scriptPath -Value @' +param([string] $Name = "portable") +"Hello $Name" +'@ -Encoding UTF8 + $scriptSigned = Set-PortableSignature -LiteralPath $scriptPath -Certificate $cert + if ($scriptSigned.Status -ne 'Valid') { + throw "Expected Valid for signed PowerShell script, got $($scriptSigned.Status): $($scriptSigned.StatusMessage)" + } + Assert-SignerCertificate -Signature $scriptSigned -ExpectedCertificate $cert -Label 'script signing response' + $scriptAfter = Get-PortableSignature -LiteralPath $scriptPath + if ($scriptAfter.Status -ne 'Valid') { + throw "Expected Valid from Get-PortableSignature for signed script, got $($scriptAfter.Status)." + } + $trustedScript = Get-PortableSignature -LiteralPath $scriptPath -TrustedCertificate $rootCert + if ($trustedScript.Status -ne 'Valid' -or $trustedScript.TrustStatus -ne 'Valid') { + throw "Expected explicit trust verification to succeed for signed script, got status=$($trustedScript.Status) trust=$($trustedScript.TrustStatus): $($trustedScript.StatusMessage)" + } + Add-Content -LiteralPath $scriptPath -Value '# tamper' + $scriptTampered = Get-PortableSignature -LiteralPath $scriptPath + if ($scriptTampered.Status -ne 'HashMismatch') { + throw "Expected HashMismatch for tampered signed script, got $($scriptTampered.Status): $($scriptTampered.StatusMessage)" + } + + $ps1xmlPath = Join-Path $temp 'Types.ps1xml' + Set-Content -LiteralPath $ps1xmlPath -Value @' + + + Portable.Type + + +'@ -Encoding UTF8 + $ps1xmlSigned = Set-PortableSignature -LiteralPath $ps1xmlPath -Certificate $cert + if ($ps1xmlSigned.Status -ne 'Valid') { + throw "Expected Valid for signed ps1xml, got $($ps1xmlSigned.Status): $($ps1xmlSigned.StatusMessage)" + } + Assert-SignerCertificate -Signature $ps1xmlSigned -ExpectedCertificate $cert -Label 'ps1xml signing response' + $ps1xmlText = Get-Content -LiteralPath $ps1xmlPath -Raw + if ($ps1xmlText -notmatch '') { + throw 'Expected signed ps1xml to use XML Authenticode signature markers.' + } + $ps1xmlAfter = Get-PortableSignature -LiteralPath $ps1xmlPath + if ($ps1xmlAfter.Status -ne 'Valid') { + throw "Expected Valid from Get-PortableSignature for signed ps1xml, got $($ps1xmlAfter.Status): $($ps1xmlAfter.StatusMessage)" + } + Add-Content -LiteralPath $ps1xmlPath -Value '' + $ps1xmlTampered = Get-PortableSignature -LiteralPath $ps1xmlPath + if ($ps1xmlTampered.Status -ne 'HashMismatch') { + throw "Expected HashMismatch for tampered signed ps1xml, got $($ps1xmlTampered.Status): $($ps1xmlTampered.StatusMessage)" + } + + $scriptContent = [System.Text.Encoding]::UTF8.GetBytes("'content mode'") + $contentSigned = Set-PortableSignature -SourcePathOrExtension '.ps1' -Content $scriptContent -Certificate $cert + if ($contentSigned.Status -ne 'Valid') { + throw "Expected Valid for signed PowerShell script content, got $($contentSigned.Status): $($contentSigned.StatusMessage)" + } + if ($null -eq $contentSigned.Content -or $contentSigned.Content.Length -le $scriptContent.Length) { + throw 'Expected Set-PortableSignature -Content to return signed content bytes.' + } + Assert-SignerCertificate -Signature $contentSigned -ExpectedCertificate $cert -Label 'script content signing response' + $contentAfter = Get-PortableSignature -SourcePathOrExtension '.ps1' -Content $contentSigned.Content + if ($contentAfter.Status -ne 'Valid') { + throw "Expected Valid from Get-PortableSignature -Content for signed script, got $($contentAfter.Status): $($contentAfter.StatusMessage)" + } + + $timestampServer = Start-PsignTimestampServer + try { + $timestampScript = Join-Path $temp 'Timestamped.ps1' + Set-Content -LiteralPath $timestampScript -Value '"timestamped"' -Encoding UTF8 + $timestamped = Set-PortableSignature -LiteralPath $timestampScript -Certificate $cert -TimestampServer $timestampServer.Url -TimestampHashAlgorithm Sha256 + if ($timestamped.Status -ne 'Valid') { + throw "Expected Valid for timestamped script, got $($timestamped.Status): $($timestamped.StatusMessage)" + } + if ($timestamped.TimestampKinds.Count -eq 0) { + throw 'Expected timestamped script to report a timestamp kind.' + } + if ($null -eq $timestamped.TimeStamperCertificate) { + throw 'Expected timestamped script to expose TimeStamperCertificate.' + } + if (-not $timestamped.PSObject.Properties.Match('TimestampSigningTime')) { + throw 'Expected timestamped script output to include TimestampSigningTime.' + } + } + finally { + if (-not $timestampServer.Process.HasExited) { + $timestampServer.Process.Kill($true) + } + $timestampServer.Process.Dispose() + } + + $moduleDir = Join-Path $temp 'PortableModule' + $nestedDir = Join-Path $moduleDir 'Private' + New-Item -ItemType Directory -Force -Path $nestedDir | Out-Null + Set-Content -LiteralPath (Join-Path $moduleDir 'PortableModule.psm1') -Value 'function Get-PortableGreeting { "hello" }' -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $moduleDir 'PortableModule.psd1') -Value "@{ RootModule = 'PortableModule.psm1'; ModuleVersion = '1.0.0'; GUID = '$([System.Guid]::NewGuid())' }" -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $moduleDir 'PortableModule.Types.ps1xml') -Value '' -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $nestedDir 'Helper.ps1') -Value '$script:PortableHelper = $true' -Encoding UTF8 + $moduleSigned = @(Set-PortableSignature -LiteralPath $moduleDir -CertificatePath $certPath -PrivateKeyPath $keyPath) + if ($moduleSigned.Count -ne 4) { + throw "Expected 4 signed PowerShell module files, got $($moduleSigned.Count)." + } + if (@($moduleSigned | Where-Object Status -ne 'Valid').Count -ne 0) { + throw "Expected all signed module files to be Valid, got: $($moduleSigned | ConvertTo-Json -Depth 4)" + } + foreach ($moduleSignature in $moduleSigned) { + Assert-SignerCertificate -Signature $moduleSignature -ExpectedCertificate $cert -Label "module signing response $($moduleSignature.Path)" + } + $moduleValidated = @(Get-PortableSignature -LiteralPath $moduleDir) + if ($moduleValidated.Count -ne 4) { + throw "Expected 4 validated PowerShell module files, got $($moduleValidated.Count)." + } + if (@($moduleValidated | Where-Object Status -ne 'Valid').Count -ne 0) { + throw "Expected all validated module files to be Valid, got: $($moduleValidated | ConvertTo-Json -Depth 4)" + } + + $unsignedMsix = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'msix') 'sample.msix' + $msixWork = Join-Path $temp 'sample.msix' + Copy-Item $unsignedMsix $msixWork + $msixBefore = Get-PortableSignature -LiteralPath $msixWork + if ($msixBefore.Status -notin @('NotSigned', 'Incompatible')) { + throw "Expected unsigned MSIX preflight status before signing, got $($msixBefore.Status)." + } + $msixSigned = Set-PortableSignature -LiteralPath $msixWork -PfxPath $pfxPath -Password $pfxPassword + if ($msixSigned.Status -ne 'Valid') { + throw "Expected Valid after MSIX signing, got $($msixSigned.Status): $($msixSigned.StatusMessage)" + } + Assert-SignerCertificate -Signature $msixSigned -ExpectedCertificate $cert -Label 'MSIX signing response' + $msixAfter = Get-PortableSignature -LiteralPath $msixWork + if ($msixAfter.Status -ne 'Valid') { + throw "Expected Valid from Get-PortableSignature for signed MSIX, got $($msixAfter.Status): $($msixAfter.StatusMessage)" + } +} +finally { + Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue + Remove-Module Devolutions.Psign -Force -ErrorAction SilentlyContinue +} diff --git a/PowerShell/tests/PortableSignature.PackageNative.Tests.ps1 b/PowerShell/tests/PortableSignature.PackageNative.Tests.ps1 new file mode 100644 index 0000000..b7e0558 --- /dev/null +++ b/PowerShell/tests/PortableSignature.PackageNative.Tests.ps1 @@ -0,0 +1,285 @@ +Set-StrictMode -Version Latest + +function script:Ensure-PortableSignatureModule { + if (-not (Get-Command Set-PortableSignature -ErrorAction SilentlyContinue)) { + $repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $modulePath = Join-Path (Join-Path $repoRoot 'PowerShell\Devolutions.Psign') 'Devolutions.Psign.psd1' + Import-Module $modulePath -Force + } +} + +function script:New-PortableSigningMaterial { + param( + [Parameter(Mandatory)] + [string] $BasePath + ) + + $rsa = [System.Security.Cryptography.RSA]::Create(2048) + $rootRsa = [System.Security.Cryptography.RSA]::Create(2048) + $rootRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=psign portable root', + $rootRsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) + $rootRequest.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($true, $false, 0, $true)) + $rootRequest.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyCertSign -bor + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::CrlSign, + $true)) + $rootCert = $rootRequest.CreateSelfSigned( + [System.DateTimeOffset]::UtcNow.AddDays(-1), + [System.DateTimeOffset]::UtcNow.AddDays(31)) + + $request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=psign portable test', + $rsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($false, $false, 0, $true)) + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature, + $true)) + $ekuOids = [System.Security.Cryptography.OidCollection]::new() + $null = $ekuOids.Add([System.Security.Cryptography.Oid]::new('1.3.6.1.5.5.7.3.3')) + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($ekuOids, $false)) + $issuedCert = $request.Create( + $rootCert, + [System.DateTimeOffset]::UtcNow.AddDays(-1), + [System.DateTimeOffset]::UtcNow.AddDays(30), + [byte[]](1, 2, 3, 4, 5, 6, 7, 8)) + $cert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::CopyWithPrivateKey($issuedCert, $rsa) + + $certPath = Join-Path $BasePath 'signer.cer' + $keyPath = Join-Path $BasePath 'signer.key' + [System.IO.File]::WriteAllBytes($certPath, $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)) + [System.IO.File]::WriteAllText( + $keyPath, + [System.Security.Cryptography.PemEncoding]::WriteString('PRIVATE KEY', $rsa.ExportPkcs8PrivateKey())) + + [pscustomobject]@{ + Certificate = $cert + RootCertificate = $rootCert + CertificatePath = $certPath + PrivateKeyPath = $keyPath + } +} + +function script:Get-ZipEntryNames { + param( + [Parameter(Mandatory)] + [string] $Path + ) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($Path) + try { + return @($archive.Entries | ForEach-Object FullName) + } + finally { + $archive.Dispose() + } +} + +function script:New-ClickOnceDocument { + param( + [Parameter(Mandatory)] + [string] $Path, + [Parameter(Mandatory)] + [string] $RootName + ) + + $xml = @" + +<$RootName> + + +"@ + Set-Content -LiteralPath $Path -Value $xml -Encoding UTF8 +} + +Describe 'Portable PowerShell package-native coverage' { + BeforeAll { + Ensure-PortableSignatureModule + $script:RepoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $script:ModulePath = Join-Path (Join-Path $script:RepoRoot 'PowerShell\Devolutions.Psign') 'Devolutions.Psign.psd1' + $script:TempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Force -Path $script:TempRoot | Out-Null + $script:Signing = New-PortableSigningMaterial -BasePath $script:TempRoot + } + + AfterAll { + if ($script:TempRoot) { + Remove-Item -LiteralPath $script:TempRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + Context 'Set-PortableSignature validation' { + It 'requires -AzureKeyVaultCertificate when -AzureKeyVaultUrl is used' { + $errorRecord = $null + try { + Set-PortableSignature -LiteralPath 'placeholder.exe' -AzureKeyVaultUrl 'https://vault.example' -ErrorAction Stop | Out-Null + } + catch { + $errorRecord = $_ + } + + $errorRecord | Should -Not -BeNullOrEmpty + $errorRecord.FullyQualifiedErrorId | Should -Be 'PortableSignatureAkvCertificateRequired,Devolutions.Psign.PowerShell.Cmdlets.SetPortableSignatureCommand' + } + + It 'rejects mixed local and Azure Key Vault signing sources' { + $errorRecord = $null + try { + Set-PortableSignature -LiteralPath 'placeholder.exe' ` + -CertificatePath 'signer.cer' ` + -PrivateKeyPath 'signer.key' ` + -AzureKeyVaultUrl 'https://vault.example' ` + -AzureKeyVaultCertificate 'signer' ` + -ErrorAction Stop | Out-Null + } + catch { + $errorRecord = $_ + } + + $errorRecord | Should -Not -BeNullOrEmpty + $errorRecord.FullyQualifiedErrorId | Should -Be 'PortableSignatureSigningMaterialRequired,Devolutions.Psign.PowerShell.Cmdlets.SetPortableSignatureCommand' + } + + It 'rejects -OutputPath with -Content' { + $errorRecord = $null + try { + Set-PortableSignature -SourcePathOrExtension '.ps1' ` + -Content ([System.Text.Encoding]::UTF8.GetBytes('"hello"')) ` + -CertificatePath $script:Signing.CertificatePath ` + -PrivateKeyPath $script:Signing.PrivateKeyPath ` + -OutputPath 'out.ps1' ` + -ErrorAction Stop | Out-Null + } + catch { + $errorRecord = $_ + } + + $errorRecord | Should -Not -BeNullOrEmpty + $errorRecord.FullyQualifiedErrorId | Should -Be 'PortableSignatureContentOutputPathUnsupported,Devolutions.Psign.PowerShell.Cmdlets.SetPortableSignatureCommand' + } + } + + Context 'Set-PortableSignature package-native formats' { + It 'signs and inspects NuGet packages' { + $work = Join-Path $script:TempRoot 'sample.nupkg' + Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'tests\fixtures\package-signing\unsigned\sample.nupkg') -Destination $work -Force + + $signed = Set-PortableSignature -LiteralPath $work -CertificatePath $script:Signing.CertificatePath -PrivateKeyPath $script:Signing.PrivateKeyPath + $signed.Format | Should -Be 'NuGet' + $signed.Status | Should -Be 'Valid' + (Get-ZipEntryNames -Path $work) | Should -Contain '.signature.p7s' + + $inspected = Get-PortableSignature -LiteralPath $work + $inspected.Format | Should -Be 'NuGet' + $inspected.Status | Should -Be 'Valid' + } + + It 'signs and inspects symbol NuGet packages' { + $work = Join-Path $script:TempRoot 'sample.snupkg' + Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'tests\fixtures\package-signing\unsigned\sample.snupkg') -Destination $work -Force + + $signed = Set-PortableSignature -LiteralPath $work -CertificatePath $script:Signing.CertificatePath -PrivateKeyPath $script:Signing.PrivateKeyPath + $signed.Format | Should -Be 'NuGet' + $signed.Status | Should -Be 'Valid' + (Get-ZipEntryNames -Path $work) | Should -Contain '.signature.p7s' + } + + It 'signs and inspects VSIX packages' { + $work = Join-Path $script:TempRoot 'sample.vsix' + Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'tests\fixtures\package-signing\unsigned\sample.vsix') -Destination $work -Force + + $signed = Set-PortableSignature -LiteralPath $work -CertificatePath $script:Signing.CertificatePath -PrivateKeyPath $script:Signing.PrivateKeyPath + $signed.Format | Should -Be 'Vsix' + $signed.Status | Should -Be 'Valid' + @(Get-ZipEntryNames -Path $work | Where-Object { $_ -like 'package/services/digital-signature/*' }).Count | Should -BeGreaterThan 0 + + $inspected = Get-PortableSignature -LiteralPath $work + $inspected.Format | Should -Be 'Vsix' + $inspected.Status | Should -Be 'Valid' + } + + It 'signs and inspects ClickOnce XML manifests for .manifest, .application, and .vsto' -TestCases @( + @{ FileName = 'sample.manifest'; RootName = 'assembly' } + @{ FileName = 'sample.application'; RootName = 'deployment' } + @{ FileName = 'sample.vsto'; RootName = 'deployment' } + ) { + param($FileName, $RootName) + + $work = Join-Path $script:TempRoot $FileName + New-ClickOnceDocument -Path $work -RootName $RootName + + $signed = Set-PortableSignature -LiteralPath $work -CertificatePath $script:Signing.CertificatePath -PrivateKeyPath $script:Signing.PrivateKeyPath + $signed.Format | Should -Be 'ClickOnceManifest' + $signed.Status | Should -Be 'Valid' + (Get-Content -LiteralPath $work -Raw) | Should -Match '' + + $inspected = Get-PortableSignature -LiteralPath $work + $inspected.Format | Should -Be 'ClickOnceManifest' + $inspected.Status | Should -Be 'Valid' + } + + It 'signs App Installer descriptors and writes the detached companion' { + $work = Join-Path $script:TempRoot 'sample.appinstaller' + Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'tests\fixtures\generated-unsigned\appinstaller\sample.appinstaller') -Destination $work -Force + + $signed = Set-PortableSignature -LiteralPath $work -CertificatePath $script:Signing.CertificatePath -PrivateKeyPath $script:Signing.PrivateKeyPath + $signed.Format | Should -Be 'AppInstaller' + $signed.Status | Should -Be 'Valid' + + $companion = "$work.p7" + Test-Path -LiteralPath $companion | Should -BeTrue + + $inspected = Get-PortableSignature -LiteralPath $work + $inspected.Format | Should -Be 'AppInstaller' + $inspected.Status | Should -Be 'Valid' + } + } + + Context 'Directory recursion covers newly signable PowerShell module neighbors' { + It 'recurses into package-native and ClickOnce files in a module tree' { + $moduleRoot = Join-Path $script:TempRoot 'PortableModuleWithPackages' + $privateRoot = Join-Path $moduleRoot 'Private' + New-Item -ItemType Directory -Force -Path $privateRoot | Out-Null + + Set-Content -LiteralPath (Join-Path $moduleRoot 'PortableModule.psm1') -Value 'function Get-PortableGreeting { "hello" }' -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $moduleRoot 'PortableModule.psd1') -Value "@{ RootModule = 'PortableModule.psm1'; ModuleVersion = '1.0.0'; GUID = '$([System.Guid]::NewGuid())' }" -Encoding UTF8 + Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'tests\fixtures\package-signing\unsigned\sample.nupkg') -Destination (Join-Path $moduleRoot 'sample.nupkg') + Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'tests\fixtures\package-signing\unsigned\sample.snupkg') -Destination (Join-Path $moduleRoot 'sample.snupkg') + Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'tests\fixtures\package-signing\unsigned\sample.vsix') -Destination (Join-Path $moduleRoot 'sample.vsix') + Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'tests\fixtures\generated-unsigned\appinstaller\sample.appinstaller') -Destination (Join-Path $moduleRoot 'sample.appinstaller') + New-ClickOnceDocument -Path (Join-Path $moduleRoot 'sample.manifest') -RootName 'assembly' + New-ClickOnceDocument -Path (Join-Path $moduleRoot 'sample.application') -RootName 'deployment' + New-ClickOnceDocument -Path (Join-Path $privateRoot 'sample.vsto') -RootName 'deployment' + Set-Content -LiteralPath (Join-Path $moduleRoot 'ignored.txt') -Value 'ignore me' -Encoding UTF8 + + $signed = @(Set-PortableSignature -LiteralPath $moduleRoot -CertificatePath $script:Signing.CertificatePath -PrivateKeyPath $script:Signing.PrivateKeyPath) + $formats = @($signed | ForEach-Object Format) + $formats | Should -Contain 'PowerShellScript' + $formats | Should -Contain 'NuGet' + $formats | Should -Contain 'Vsix' + $formats | Should -Contain 'ClickOnceManifest' + $formats | Should -Contain 'AppInstaller' + + $signedPaths = @($signed | ForEach-Object Path) + $signedPaths | Should -Not -Contain (Join-Path $moduleRoot 'ignored.txt') + Test-Path -LiteralPath (Join-Path $moduleRoot 'sample.appinstaller.p7') | Should -BeTrue + + $validated = @(Get-PortableSignature -LiteralPath $moduleRoot) + @($validated | Where-Object Status -ne 'Valid').Count | Should -Be 0 + @($validated | ForEach-Object Format) | Should -Contain 'NuGet' + @($validated | ForEach-Object Format) | Should -Contain 'Vsix' + @($validated | ForEach-Object Format) | Should -Contain 'ClickOnceManifest' + @($validated | ForEach-Object Format) | Should -Contain 'AppInstaller' + } + } +} diff --git a/README.md b/README.md index 5d0f304..b6dc30d 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ dotnet tool run psign-tool -- --help Create local dotnet tool packages from prebuilt release artifacts: ```powershell -pwsh ./nuget/pack-psign-dotnet-tool.ps1 -Version 0.2.0 -ArtifactsRoot ./dist -OutputDir ./dist/nuget +pwsh ./nuget/pack-psign-dotnet-tool.ps1 -Version 0.3.0 -ArtifactsRoot ./dist -OutputDir ./dist/nuget ``` The package is built from native `psign-tool` artifacts for `win-x64`, `win-arm64`, `linux-x64`, `linux-arm64`, `osx-x64`, and `osx-arm64`, plus an `any` fallback package for unsupported runtimes. diff --git a/crates/psign-authenticode-trust/Cargo.toml b/crates/psign-authenticode-trust/Cargo.toml index 63ef21d..dd55db5 100644 --- a/crates/psign-authenticode-trust/Cargo.toml +++ b/crates/psign-authenticode-trust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-authenticode-trust" -version = "0.2.0" +version = "0.3.0" edition = "2024" description = "Portable Authenticode PKCS#7 trust verification (anchors, chain, EKU) using picky-rs" license.workspace = true diff --git a/crates/psign-azure-kv-rest/Cargo.toml b/crates/psign-azure-kv-rest/Cargo.toml index ec3f273..b2b7513 100644 --- a/crates/psign-azure-kv-rest/Cargo.toml +++ b/crates/psign-azure-kv-rest/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-azure-kv-rest" -version = "0.2.0" +version = "0.3.0" edition = "2024" description = "Azure Key Vault certificate metadata + keys/sign REST (portable, blocking HTTP)" license.workspace = true diff --git a/crates/psign-codesigning-rest/Cargo.toml b/crates/psign-codesigning-rest/Cargo.toml index a828320..3a5f1c0 100644 --- a/crates/psign-codesigning-rest/Cargo.toml +++ b/crates/psign-codesigning-rest/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-codesigning-rest" -version = "0.2.0" +version = "0.3.0" edition = "2024" description = "Azure Code Signing data-plane CertificateProfileOperations Sign LRO (portable, blocking HTTP)" license.workspace = true diff --git a/crates/psign-digest-cli/Cargo.toml b/crates/psign-digest-cli/Cargo.toml index 2d42c16..6411fe1 100644 --- a/crates/psign-digest-cli/Cargo.toml +++ b/crates/psign-digest-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-digest-cli" -version = "0.2.0" +version = "0.3.0" edition = "2024" description = "Linux/macOS-friendly CLI over portable Authenticode SIP digests (psign-sip-digest)" license.workspace = true diff --git a/crates/psign-opc-sign/Cargo.toml b/crates/psign-opc-sign/Cargo.toml index 8a6186a..3a25659 100644 --- a/crates/psign-opc-sign/Cargo.toml +++ b/crates/psign-opc-sign/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-opc-sign" -version = "0.2.0" +version = "0.3.0" edition = "2024" description = "Portable OPC, VSIX, and NuGet package signing primitives" license.workspace = true diff --git a/crates/psign-portable-core/Cargo.toml b/crates/psign-portable-core/Cargo.toml index 36be861..330442f 100644 --- a/crates/psign-portable-core/Cargo.toml +++ b/crates/psign-portable-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-portable-core" -version = "0.2.0" +version = "0.3.0" edition = "2024" description = "Reusable portable Authenticode signing and inspection APIs for psign" license.workspace = true diff --git a/crates/psign-portable-ffi/Cargo.toml b/crates/psign-portable-ffi/Cargo.toml index 2fd4c66..7f7529c 100644 --- a/crates/psign-portable-ffi/Cargo.toml +++ b/crates/psign-portable-ffi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-portable-ffi" -version = "0.2.0" +version = "0.3.0" edition = "2024" description = "C ABI shared library for psign portable Authenticode operations" license.workspace = true diff --git a/crates/psign-sip-digest/Cargo.toml b/crates/psign-sip-digest/Cargo.toml index 15bbdee..6c448fd 100644 --- a/crates/psign-sip-digest/Cargo.toml +++ b/crates/psign-sip-digest/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-sip-digest" -version = "0.2.0" +version = "0.3.0" edition = "2024" description = "Portable Authenticode SIP digest recomputation (PE, CAB, MSI, MSIX, scripts, …) without Win32" license.workspace = true diff --git a/docs/portable-powershell-module.md b/docs/portable-powershell-module.md index 2355b8b..5c75f37 100644 --- a/docs/portable-powershell-module.md +++ b/docs/portable-powershell-module.md @@ -7,7 +7,7 @@ - `Set-PortableSignature` - `Get-PortableSignature` -Both cmdlets accept `-FilePath` and `-LiteralPath`. When the input is a directory, the module treats it as a PowerShell module tree and recursively processes `.ps1`, `.psm1`, `.psd1`, and `.ps1xml` files. +Both cmdlets accept `-FilePath` and `-LiteralPath`. When the input is a directory, the module treats it as a PowerShell module tree and recursively processes PowerShell files plus newly supported portable signing neighbors such as `.dll`, `.exe`, `.nupkg`, `.snupkg`, `.vsix`, `.manifest`, `.application`, `.vsto`, and `.appinstaller`. Both cmdlets also support the built-in Authenticode content parameter shape: @@ -50,17 +50,21 @@ Trust verification is offline by default. `-OnlineAia` enables issuer retrieval, ## Supported portable formats -The current module tests cover signing and validation through the PowerShell surface for: +The current PowerShell test suite covers signing and validation through the module surface for: - PE files - CAB archives - MSI/MSP installers - Devolutions ZIP Authenticode packages +- NuGet and symbol NuGet packages (`.nupkg`, `.snupkg`) +- VSIX packages +- ClickOnce manifests (`.manifest`, `.application`, `.vsto`) +- App Installer descriptors with detached `.p7` companions - PowerShell scripts, including `.ps1xml` XML marker signatures - PowerShell module directories - MSIX/AppX packages -Signature inspection validates portable digest binding and signature structure. Explicit trust verification is currently implemented for PE, CAB, MSI/MSP, Devolutions ZIP Authenticode, and PowerShell script signatures. +The suite now runs under **Pester 5**, while preserving the existing end-to-end smoke coverage. Signature inspection validates portable digest binding and signature structure. Explicit trust verification is currently implemented for PE, CAB, MSI/MSP, Devolutions ZIP Authenticode, and PowerShell script signatures. ## Build, test, and package diff --git a/nuget/tool/Devolutions.Psign.Tool.csproj b/nuget/tool/Devolutions.Psign.Tool.csproj index 1a2c981..10cd4f3 100644 --- a/nuget/tool/Devolutions.Psign.Tool.csproj +++ b/nuget/tool/Devolutions.Psign.Tool.csproj @@ -8,7 +8,7 @@ psign-tool Devolutions.Psign.Tool - 0.2.0 + 0.3.0 Devolutions RID-specific dotnet tool wrapper around prebuilt psign-tool native executables. README.md From 93a25fe763fbe8cae716cb5871c7590f59f11ef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 22 May 2026 15:03:56 -0400 Subject: [PATCH 14/14] fix: satisfy strict code clippy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/code.rs | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/code.rs b/src/code.rs index d8daa54..86d898e 100644 --- a/src/code.rs +++ b/src/code.rs @@ -933,21 +933,21 @@ fn sign_nested_package_entries( compression: zip::CompressionMethod::Stored, }); } - CodeFormat::ClickOnceApplication | CodeFormat::Vsto | CodeFormat::Manifest => { - if !(skip_signed && clickonce_manifest_has_signature(&bytes)) { - entry_updates.push(ZipEntryUpdate { - name, - bytes: sign_clickonce_manifest_bytes( - &bytes, - &nested_label, - signer, - vsix_digest, - timestamp_url, - timestamp_digest, - )?, - compression, - }); - } + CodeFormat::ClickOnceApplication | CodeFormat::Vsto | CodeFormat::Manifest + if !(skip_signed && clickonce_manifest_has_signature(&bytes)) => + { + entry_updates.push(ZipEntryUpdate { + name, + bytes: sign_clickonce_manifest_bytes( + &bytes, + &nested_label, + signer, + vsix_digest, + timestamp_url, + timestamp_digest, + )?, + compression, + }); } CodeFormat::Deploy => entry_updates.push(ZipEntryUpdate { name, @@ -1654,9 +1654,9 @@ struct CodeSigner { enum CodeSignerBackend { Local(CodeSignerPaths), #[cfg(feature = "azure-kv-sign")] - AzureKeyVault(CodeAzureKeyVaultSigner), + AzureKeyVault(Box), #[cfg(feature = "artifact-signing-rest")] - ArtifactSigning(CodeArtifactSigningSigner), + ArtifactSigning(Box), } struct CodeSignerPaths { @@ -2136,12 +2136,12 @@ fn resolve_code_signer_azure_key_vault(args: &CodeArgs) -> Result { )); } Ok(CodeSigner { - backend: CodeSignerBackend::AzureKeyVault(CodeAzureKeyVaultSigner { + backend: CodeSignerBackend::AzureKeyVault(Box::new(CodeAzureKeyVaultSigner { http, token, certificate, cert_der, - }), + })), }) } @@ -2163,7 +2163,7 @@ fn resolve_code_signer_artifact_signing(args: &CodeArgs) -> Result { )); } Ok(CodeSigner { - backend: CodeSignerBackend::ArtifactSigning(CodeArtifactSigningSigner { + backend: CodeSignerBackend::ArtifactSigning(Box::new(CodeArtifactSigningSigner { metadata: args.artifact_signing_metadata.clone(), region: args.artifact_signing_region.clone(), endpoint: args.artifact_signing_endpoint.clone(), @@ -2183,7 +2183,7 @@ fn resolve_code_signer_artifact_signing(args: &CodeArgs) -> Result { client_secret: args.artifact_signing_client_secret.clone(), authority: args.artifact_signing_authority.clone(), endpoint_base_url: args.artifact_signing_endpoint_base_url.clone(), - }), + })), }) }