diff --git a/.github/workflows/dotnet-tool.yml b/.github/workflows/dotnet-tool.yml index bf5008d..d2fc9e0 100644 --- a/.github/workflows/dotnet-tool.yml +++ b/.github/workflows/dotnet-tool.yml @@ -80,7 +80,20 @@ jobs: env: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc run: | - cargo build --locked --release -p psign --bin psign-tool --target "${{ matrix.target }}" + $target = '${{ matrix.target }}' + $cargoArgs = @( + 'build', + '--locked', + '--release', + '-p', + 'psign', + '--bin', + 'psign-tool', + '--target', + $target + ) + + cargo @cargoArgs - name: Verify Windows version info if: contains(matrix.target, 'windows-msvc') diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 979ac17..98a4e1f 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.1.0) + description: Release version to build/publish (for example 0.2.0) required: true type: string publish_nuget: @@ -217,7 +217,20 @@ jobs: env: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc run: | - cargo build --locked --release -p psign --bin psign-tool --target "${{ matrix.target }}" + $target = '${{ matrix.target }}' + $cargoArgs = @( + 'build', + '--locked', + '--release', + '-p', + 'psign', + '--bin', + 'psign-tool', + '--target', + $target + ) + + cargo @cargoArgs - name: Verify Windows version info if: contains(matrix.target, 'windows-msvc') @@ -285,7 +298,7 @@ jobs: sign_windows: name: Sign and package Windows artifacts - runs-on: windows-2022 + runs-on: ubuntu-latest needs: - preflight - build @@ -304,6 +317,12 @@ jobs: name: psign-tool-windows-arm64.zip-unsigned-bin path: work/win-arm64 + - name: Download Linux x64 psign-tool + uses: actions/download-artifact@v8 + with: + name: psign-tool-linux-x64.zip + path: work/linux-x64 + - name: Resolve signing mode id: signing_mode shell: pwsh @@ -345,20 +364,34 @@ jobs: "should_sign=$($shouldSign.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append Write-Host "Windows signing dry-run mode: $dryRun" - Write-Host "Windows binaries will be signed: $shouldSign" + Write-Host "Windows binaries will be signed with Linux psign-tool: $shouldSign" - - name: Install AzureSignTool + - name: Add Linux psign-tool to PATH if: steps.signing_mode.outputs.should_sign == 'true' shell: pwsh run: | - $toolPath = Join-Path $env:USERPROFILE '.dotnet\tools\AzureSignTool.exe' - if (Test-Path -Path $toolPath) { - dotnet tool update --global AzureSignTool + $toolDir = Join-Path $env:RUNNER_TEMP 'psign-tool-self-sign' + if (Test-Path -Path $toolDir) { + Remove-Item -Path $toolDir -Recurse -Force } - else { - dotnet tool install --global AzureSignTool + + New-Item -Path $toolDir -ItemType Directory -Force | Out-Null + + $toolArchivePath = 'work/linux-x64/psign-tool-linux-x64.zip' + if (-not (Test-Path -Path $toolArchivePath)) { + throw "Built Linux psign-tool archive was not found at $toolArchivePath" + } + + Expand-Archive -Path $toolArchivePath -DestinationPath $toolDir -Force + $sourceToolPath = Join-Path $toolDir 'psign-tool' + if (-not (Test-Path -Path $sourceToolPath)) { + throw "Built Linux psign-tool executable was not found at $sourceToolPath" } + chmod +x $sourceToolPath + $toolDir | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + & $sourceToolPath --version + - name: Code sign Windows executables if: steps.signing_mode.outputs.should_sign == 'true' shell: pwsh @@ -370,28 +403,38 @@ jobs: CODE_SIGNING_CERTIFICATE_NAME: ${{ secrets.CODE_SIGNING_CERTIFICATE_NAME }} CODE_SIGNING_TIMESTAMP_SERVER: ${{ vars.CODE_SIGNING_TIMESTAMP_SERVER }} run: | - $toolPath = Join-Path $env:USERPROFILE '.dotnet\tools\AzureSignTool.exe' - - & $toolPath sign ` - -kvt "$env:AZURE_TENANT_ID" ` - -kvu "$env:CODE_SIGNING_KEYVAULT_URL" ` - -kvi "$env:CODE_SIGNING_CLIENT_ID" ` - -kvs "$env:CODE_SIGNING_CLIENT_SECRET" ` - -kvc "$env:CODE_SIGNING_CERTIFICATE_NAME" ` - -tr "$env:CODE_SIGNING_TIMESTAMP_SERVER" ` - -v ` + psign-tool --mode portable --verbose sign ` + --azure-key-vault-tenant-id "$env:AZURE_TENANT_ID" ` + --azure-key-vault-url "$env:CODE_SIGNING_KEYVAULT_URL" ` + --azure-key-vault-client-id "$env:CODE_SIGNING_CLIENT_ID" ` + --azure-key-vault-client-secret "$env:CODE_SIGNING_CLIENT_SECRET" ` + --azure-key-vault-certificate "$env:CODE_SIGNING_CERTIFICATE_NAME" ` + --timestamp-url "$env:CODE_SIGNING_TIMESTAMP_SERVER" ` + --timestamp-digest sha256 ` + --digest sha256 ` + --exit-codes azure ` "work/win-x64/psign-tool.exe" - & $toolPath sign ` - -kvt "$env:AZURE_TENANT_ID" ` - -kvu "$env:CODE_SIGNING_KEYVAULT_URL" ` - -kvi "$env:CODE_SIGNING_CLIENT_ID" ` - -kvs "$env:CODE_SIGNING_CLIENT_SECRET" ` - -kvc "$env:CODE_SIGNING_CERTIFICATE_NAME" ` - -tr "$env:CODE_SIGNING_TIMESTAMP_SERVER" ` - -v ` + if ($LASTEXITCODE -ne 0) { + throw "psign-tool signing failed for win-x64 with exit code $LASTEXITCODE" + } + + psign-tool --mode portable --verbose sign ` + --azure-key-vault-tenant-id "$env:AZURE_TENANT_ID" ` + --azure-key-vault-url "$env:CODE_SIGNING_KEYVAULT_URL" ` + --azure-key-vault-client-id "$env:CODE_SIGNING_CLIENT_ID" ` + --azure-key-vault-client-secret "$env:CODE_SIGNING_CLIENT_SECRET" ` + --azure-key-vault-certificate "$env:CODE_SIGNING_CERTIFICATE_NAME" ` + --timestamp-url "$env:CODE_SIGNING_TIMESTAMP_SERVER" ` + --timestamp-digest sha256 ` + --digest sha256 ` + --exit-codes azure ` "work/win-arm64/psign-tool.exe" + if ($LASTEXITCODE -ne 0) { + throw "psign-tool signing failed for win-arm64 with exit code $LASTEXITCODE" + } + - name: Package Windows artifacts shell: pwsh run: | diff --git a/Cargo.lock b/Cargo.lock index 2d042e3..18dd3f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2001,7 +2001,7 @@ dependencies = [ [[package]] name = "psign" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "assert_cmd", @@ -2036,7 +2036,7 @@ dependencies = [ [[package]] name = "psign-authenticode-trust" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "authenticode", @@ -2059,7 +2059,7 @@ dependencies = [ [[package]] name = "psign-azure-kv-rest" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "base64", @@ -2074,7 +2074,7 @@ dependencies = [ [[package]] name = "psign-codesigning-rest" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "base64", @@ -2086,7 +2086,7 @@ dependencies = [ [[package]] name = "psign-digest-cli" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "assert_cmd", @@ -2103,6 +2103,7 @@ dependencies = [ "rsa 0.9.10", "serde", "serde_json", + "sha1 0.10.6", "sha2 0.10.9", "tempfile", "x509-cert", @@ -2111,7 +2112,7 @@ dependencies = [ [[package]] name = "psign-opc-sign" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "sha2 0.10.9", @@ -2120,7 +2121,7 @@ dependencies = [ [[package]] name = "psign-sip-digest" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "authenticode", diff --git a/Cargo.toml b/Cargo.toml index ca1b7e8..da5d46c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ repository = "https://github.com/Devolutions/psign" [package] name = "psign" -version = "0.1.0" +version = "0.2.0" edition = "2024" description = "Rust port of the Windows SDK signtool.exe (Authenticode sign/verify/timestamp) with portable digest helpers." license.workspace = true @@ -34,7 +34,12 @@ readme = "README.md" repository.workspace = true [features] -default = [] +default = [ + "azure-kv-sign", + "artifact-signing-rest", + "timestamp-http", + "timestamp-server", +] ## Azure Key Vault signing (`AuthenticatorDigestSign` callback + REST); enables Azure-shaped CLI flags on `sign`. azure-kv-sign = [ "dep:psign-azure-kv-rest", diff --git a/README.md b/README.md index 36423bb..0638787 100644 --- a/README.md +++ b/README.md @@ -28,28 +28,44 @@ Canonical repository: . cargo build ``` -At the repo root, **`cargo build`** targets **`default-members`**, including the unified **`psign-tool`** executable from `src\main.rs` plus the portable digest / trust / package / REST crates. On Windows, **`cargo build -p psign --bin psign-tool`** remains the explicit way to build only that executable. Optional Cargo features: **`azure-kv-sign`** (Key Vault digest callback), **`artifact-signing-rest`** (**`artifact-signing-submit`** LRO against **`*.codesigning.azure.net`**), **`timestamp-http`** (portable RFC3161 HTTP POST), and **`timestamp-server`** (local RFC3161 test server). +At the repo root, **`cargo build`** targets **`default-members`**, including the unified **`psign-tool`** executable from `src\main.rs` plus the portable digest / trust / package / REST crates. On Windows, **`cargo build -p psign --bin psign-tool`** remains the explicit way to build only that executable. Default Cargo features include **`azure-kv-sign`** (Key Vault digest callback), **`artifact-signing-rest`** (**`artifact-signing-submit`** LRO against **`*.codesigning.azure.net`**), **`timestamp-http`** (portable RFC3161 HTTP POST), and **`timestamp-server`** (local RFC3161 test server); use **`--no-default-features`** for a minimal build. -## Dotnet tool package (.NET 10+) +## Dotnet tool package from NuGet.org (.NET 10+) -`psign-tool` can be distributed as a RID-specific dotnet tool package: +`psign-tool` is published as the RID-specific +[`Devolutions.Psign.Tool`](https://www.nuget.org/packages/Devolutions.Psign.Tool) +dotnet tool package: ```powershell dotnet tool install -g Devolutions.Psign.Tool psign-tool --help ``` -One-shot execution: +Update an existing global install: + +```powershell +dotnet tool update -g Devolutions.Psign.Tool +``` + +One-shot execution from NuGet.org: ```powershell dotnet tool exec Devolutions.Psign.Tool -- --help dnx Devolutions.Psign.Tool --help ``` +For repository-local tool manifests, omit `-g`: + +```powershell +dotnet new tool-manifest +dotnet tool install Devolutions.Psign.Tool +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.1.0 -ArtifactsRoot ./dist -OutputDir ./dist/nuget +pwsh ./nuget/pack-psign-dotnet-tool.ps1 -Version 0.2.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 bc71fd3..c7df9ae 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.1.0" +version = "0.2.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 55e31b5..ec3f273 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.1.0" +version = "0.2.0" edition = "2024" description = "Azure Key Vault certificate metadata + keys/sign REST (portable, blocking HTTP)" license.workspace = true diff --git a/crates/psign-azure-kv-rest/src/lib.rs b/crates/psign-azure-kv-rest/src/lib.rs index 54c987f..bebfc3a 100644 --- a/crates/psign-azure-kv-rest/src/lib.rs +++ b/crates/psign-azure-kv-rest/src/lib.rs @@ -217,6 +217,12 @@ pub fn kv_jws_alg(kind: KvPublicKeyKind, hash: KvHashAlg) -> Result { } } +fn kv_base64url_decode(value: &str) -> Result> { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(value.trim()) + .context("signature base64url decode") +} + #[derive(Deserialize)] struct KeyVaultSignResponse { value: String, @@ -231,7 +237,7 @@ pub fn kv_sign_digest( ) -> Result> { let body = serde_json::json!({ "alg": jws_alg, - "value": base64::engine::general_purpose::STANDARD.encode(digest), + "value": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest), }); let rsp = http .post(sign_url) @@ -247,9 +253,7 @@ pub fn kv_sign_digest( )); } let parsed: KeyVaultSignResponse = rsp.json().context("Key Vault sign JSON")?; - base64::engine::general_purpose::STANDARD - .decode(parsed.value.trim()) - .context("signature base64 decode") + kv_base64url_decode(&parsed.value) } /// Resolve **`kid`**, infer JWS alg from certificate **`cer`**, POST **`sign`**. @@ -347,4 +351,12 @@ mod tests { "ES512" ); } + + #[test] + fn key_vault_signature_value_uses_base64url_without_padding() { + let raw = [0xff, 0xee, 0xdd, 0xcc, 0xbb]; + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw); + assert_eq!(encoded, "_-7dzLs"); + assert_eq!(kv_base64url_decode(&encoded).unwrap(), raw); + } } diff --git a/crates/psign-azure-kv-rest/tests/kv_rest_mock.rs b/crates/psign-azure-kv-rest/tests/kv_rest_mock.rs index 71eac56..79ef3aa 100644 --- a/crates/psign-azure-kv-rest/tests/kv_rest_mock.rs +++ b/crates/psign-azure-kv-rest/tests/kv_rest_mock.rs @@ -39,7 +39,7 @@ fn fetch_certificate_and_sign_digest_against_mock_kv() { .create(); let sig_bytes = vec![0xdeu8, 0xad, 0xbe, 0xef]; - let sig_b64 = base64::engine::general_purpose::STANDARD.encode(&sig_bytes); + let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&sig_bytes); let _m_sign = server .mock( diff --git a/crates/psign-codesigning-rest/Cargo.toml b/crates/psign-codesigning-rest/Cargo.toml index 62ae7f7..a828320 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.1.0" +version = "0.2.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 cdfb4de..9d2b52c 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.1.0" +version = "0.2.0" edition = "2024" description = "Linux/macOS-friendly CLI over portable Authenticode SIP digests (psign-sip-digest)" license.workspace = true @@ -11,7 +11,7 @@ autobins = false default = [] ## Azure Code Signing `:sign` LRO on Linux/macOS (same API as `psign-tool artifact-signing-submit`). artifact-signing-rest = ["dep:psign-codesigning-rest"] -## Azure Key Vault `keys/sign` on Linux/macOS (digest file → signature bytes / base64); embedding still requires Windows. +## Azure Key Vault `keys/sign` on Linux/macOS (digest file → signature bytes / base64, plus portable PE signing). azure-kv-sign-portable = ["dep:psign-azure-kv-rest", "dep:base64", "dep:reqwest"] ## RFC 3161 TSA HTTP POST (`rfc3161-timestamp-http-post`) using Rustls (same optional `reqwest` crate as KV portable). timestamp-http = ["dep:reqwest"] @@ -32,6 +32,8 @@ psign-codesigning-rest = { path = "../psign-codesigning-rest", optional = true } psign-azure-kv-rest = { path = "../psign-azure-kv-rest", optional = true } 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" [dev-dependencies] assert_cmd = "2" diff --git a/crates/psign-digest-cli/src/main.rs b/crates/psign-digest-cli/src/main.rs index 2f6fcd6..17b389c 100644 --- a/crates/psign-digest-cli/src/main.rs +++ b/crates/psign-digest-cli/src/main.rs @@ -17,7 +17,7 @@ use psign_authenticode_trust::{ #[cfg(feature = "azure-kv-sign-portable")] use psign_azure_kv_rest::{ KvAuthParams, KvHashAlg, acquire_kv_access_token, fetch_kv_certificate, - kv_sign_digest_from_certificate, + kv_decode_cer_b64, kv_sign_digest_from_certificate, }; #[cfg(feature = "artifact-signing-rest")] use psign_codesigning_rest::{ @@ -48,6 +48,8 @@ use psign_sip_digest::timestamp::{ use psign_sip_digest::verify_pe; use psign_sip_digest::verify_script_digest_consistency; use serde::Deserialize; +use sha1::Sha1; +use sha2::{Digest as _, Sha256, Sha384, Sha512}; use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; @@ -180,6 +182,15 @@ fn hash_alg_timestamp_oid(alg: HashAlg) -> &'static str { } } +fn digest_bytes_for_hash_alg(alg: HashAlg, input: &[u8]) -> Vec { + match alg { + HashAlg::Sha1 => Sha1::digest(input).to_vec(), + HashAlg::Sha256 => Sha256::digest(input).to_vec(), + HashAlg::Sha384 => Sha384::digest(input).to_vec(), + HashAlg::Sha512 => Sha512::digest(input).to_vec(), + } +} + fn parse_hex_digest_fixed(s: &str, byte_len: usize) -> Result> { let t = s.trim(); let hex = t @@ -415,6 +426,87 @@ fn run_rfc3161_timestamp_http_post( 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 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); @@ -585,23 +677,51 @@ enum Command { /// 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 and SHA-2 digests; timestamp embedding and non-PE formats remain separate backlog. + /// 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: PathBuf, + cert: Option, /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. #[arg(long, value_name = "PATH")] - key: PathBuf, + 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, /// Output signed PE path. #[arg(long, value_name = "PATH")] output: PathBuf, @@ -949,6 +1069,17 @@ impl From for pkcs7::AuthenticodeSigningDigest { } } +#[cfg(feature = "azure-kv-sign-portable")] +impl From for KvHashAlg { + fn from(value: PortableSignDigest) -> Self { + match value { + PortableSignDigest::Sha256 => Self::Sha256, + PortableSignDigest::Sha384 => Self::Sha384, + PortableSignDigest::Sha512 => Self::Sha512, + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] enum DigestEncoding { Hex, @@ -1245,27 +1376,21 @@ impl From for KvHashAlg { } #[cfg(feature = "azure-kv-sign-portable")] -fn validate_kv_portable_auth(args: &AzureKvSignDigestPortableArgs) -> Result<()> { - let has_sp = args - .azure_key_vault_client_secret - .as_ref() - .map(|s| !s.trim().is_empty()) - == Some(true); - let has_tenant = args - .azure_key_vault_tenant_id - .as_ref() - .map(|s| !s.trim().is_empty()) - == Some(true); - let has_client = args - .azure_key_vault_client_id - .as_ref() - .map(|s| !s.trim().is_empty()) - == Some(true); - let has_token = args - .azure_key_vault_access_token - .as_ref() - .map(|s| !s.trim().is_empty()) - == Some(true); +#[derive(Clone, Copy)] +struct KvPortableAuthInputs<'a> { + access_token: Option<&'a str>, + managed_identity: bool, + tenant_id: Option<&'a str>, + client_id: Option<&'a str>, + client_secret: Option<&'a str>, +} + +#[cfg(feature = "azure-kv-sign-portable")] +fn validate_kv_portable_auth_inputs(args: KvPortableAuthInputs<'_>) -> Result<()> { + let has_sp = args.client_secret.map(|s| !s.trim().is_empty()) == Some(true); + let has_tenant = args.tenant_id.map(|s| !s.trim().is_empty()) == Some(true); + let has_client = args.client_id.map(|s| !s.trim().is_empty()) == Some(true); + let has_token = args.access_token.map(|s| !s.trim().is_empty()) == Some(true); let sp_count = has_sp as u8 + has_tenant as u8 + has_client as u8; if sp_count != 0 && sp_count != 3 { @@ -1274,17 +1399,17 @@ fn validate_kv_portable_auth(args: &AzureKvSignDigestPortableArgs) -> Result<()> )); } - if has_token && (args.azure_key_vault_managed_identity || sp_count == 3) { + if has_token && (args.managed_identity || sp_count == 3) { return Err(anyhow!( "use either --azure-key-vault-accesstoken or managed identity / client credentials, not multiple" )); } - if args.azure_key_vault_managed_identity && (has_token || sp_count == 3) { + if args.managed_identity && (has_token || sp_count == 3) { return Err(anyhow!( "--azure-key-vault-managed-identity cannot be combined with access tokens or client secrets" )); } - if !has_token && !args.azure_key_vault_managed_identity && sp_count != 3 { + if !has_token && !args.managed_identity && sp_count != 3 { return Err(anyhow!( "choose authentication: --azure-key-vault-accesstoken, --azure-key-vault-managed-identity, or client id/secret/tenant" )); @@ -1292,6 +1417,17 @@ fn validate_kv_portable_auth(args: &AzureKvSignDigestPortableArgs) -> Result<()> Ok(()) } +#[cfg(feature = "azure-kv-sign-portable")] +fn validate_kv_portable_auth(args: &AzureKvSignDigestPortableArgs) -> Result<()> { + validate_kv_portable_auth_inputs(KvPortableAuthInputs { + access_token: args.azure_key_vault_access_token.as_deref(), + managed_identity: args.azure_key_vault_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(), + }) +} + #[cfg(feature = "azure-kv-sign-portable")] fn run_portable_azure_kv_sign_digest(args: AzureKvSignDigestPortableArgs) -> Result<()> { use std::time::Duration; @@ -1337,6 +1473,107 @@ fn run_portable_azure_kv_sign_digest(args: AzureKvSignDigestPortableArgs) -> Res Ok(()) } +#[cfg(feature = "azure-kv-sign-portable")] +struct SignPeAzureKvOptions<'a> { + vault_url: Option<&'a str>, + certificate: Option<&'a str>, + certificate_version: Option<&'a str>, + access_token: Option<&'a str>, + managed_identity: bool, + tenant_id: Option<&'a str>, + client_id: Option<&'a str>, + client_secret: Option<&'a str>, + authority: Option<&'a str>, +} + +#[cfg(feature = "azure-kv-sign-portable")] +fn create_pe_authenticode_pkcs7_der_azure_kv( + pe: &[u8], + digest: PortableSignDigest, + chain_certs: Vec, + args: SignPeAzureKvOptions<'_>, +) -> Result> { + use std::time::Duration; + + let vault_url = args + .vault_url + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow!("portable sign-pe Azure Key Vault signing requires --azure-key-vault-url"))?; + let certificate = args + .certificate + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + anyhow!( + "portable sign-pe Azure Key Vault signing requires --azure-key-vault-certificate" + ) + })?; + validate_kv_portable_auth_inputs(KvPortableAuthInputs { + access_token: args.access_token, + managed_identity: args.managed_identity, + tenant_id: args.tenant_id, + client_id: args.client_id, + client_secret: args.client_secret, + })?; + + let http = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(300)) + .build() + .map_err(|e| anyhow!("HTTP client: {e}"))?; + let auth = KvAuthParams { + access_token: args.access_token, + managed_identity: args.managed_identity, + tenant_id: args.tenant_id, + client_id: args.client_id, + client_secret: args.client_secret, + authority: args.authority, + }; + let token = acquire_kv_access_token(&auth)?; + let kv_cert = fetch_kv_certificate( + &http, + vault_url, + certificate, + args.certificate_version + .map(str::trim) + .filter(|s| !s.is_empty()), + &token, + )?; + let signer_cert_der = kv_decode_cer_b64(&kv_cert.cer)?; + let signer_cert = + rdp::parse_certificate(&signer_cert_der).context("parse Key Vault signer certificate")?; + 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 digest_algorithm: pkcs7::AuthenticodeSigningDigest = digest.into(); + let pe_digest = pe_authenticode_digest(pe, digest_algorithm.pe_hash_kind())?; + let indirect = pkcs7::pe_spc_indirect_data(digest_algorithm, &pe_digest)?; + let signer_prehash = + pkcs7::authenticode_remote_rsa_signed_attrs_digest(&indirect, digest_algorithm)?; + let signature = kv_sign_digest_from_certificate( + &http, + &token, + &kv_cert, + KvHashAlg::from(digest), + &signer_prehash, + )?; + + pkcs7::create_authenticode_pkcs7_der_with_rsa_signature( + indirect, + digest_algorithm, + signer_cert, + chain, + &signature, + ) +} + #[derive(Debug, Deserialize)] #[allow(non_snake_case)] struct ArtifactSigningMetadataDoc { @@ -1803,38 +2040,154 @@ where key, chain_certs, digest, + timestamp_url, + timestamp_digest, + azure_key_vault_url, + azure_key_vault_certificate, + azure_key_vault_certificate_version, + azure_key_vault_access_token, + azure_key_vault_managed_identity, + azure_key_vault_tenant_id, + azure_key_vault_client_id, + azure_key_vault_client_secret, + azure_authority, output, } => { let pe = std::fs::read(&path).with_context(|| format!("read {}", 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 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 has_local = cert.is_some() || key.is_some(); + let has_kv = azure_key_vault_url + .as_deref() + .is_some_and(|s| !s.trim().is_empty()) + || azure_key_vault_certificate + .as_deref() + .is_some_and(|s| !s.trim().is_empty()) + || azure_key_vault_certificate_version + .as_deref() + .is_some_and(|s| !s.trim().is_empty()) + || azure_key_vault_access_token + .as_deref() + .is_some_and(|s| !s.trim().is_empty()) + || azure_key_vault_managed_identity + || azure_key_vault_tenant_id + .as_deref() + .is_some_and(|s| !s.trim().is_empty()) + || azure_key_vault_client_id + .as_deref() + .is_some_and(|s| !s.trim().is_empty()) + || azure_key_vault_client_secret + .as_deref() + .is_some_and(|s| !s.trim().is_empty()) + || azure_authority + .as_deref() + .is_some_and(|s| !s.trim().is_empty()); + if has_local && has_kv { + return Err(anyhow!( + "portable sign-pe accepts either --cert/--key or --azure-key-vault-* options, not both" + )); } - let pkcs7 = pkcs7::create_pe_authenticode_pkcs7_der_rsa( - &pe, - digest.into(), - signer_cert, - chain, - private_key, - ) - .with_context(|| { - format!( - "create portable Authenticode signature for {}", - path.display() + let mut pkcs7 = if has_kv { + #[cfg(feature = "azure-kv-sign-portable")] + { + create_pe_authenticode_pkcs7_der_azure_kv( + &pe, + digest, + chain_certs, + SignPeAzureKvOptions { + vault_url: azure_key_vault_url.as_deref(), + certificate: azure_key_vault_certificate.as_deref(), + certificate_version: azure_key_vault_certificate_version.as_deref(), + access_token: azure_key_vault_access_token.as_deref(), + managed_identity: azure_key_vault_managed_identity, + tenant_id: azure_key_vault_tenant_id.as_deref(), + client_id: azure_key_vault_client_id.as_deref(), + client_secret: azure_key_vault_client_secret.as_deref(), + authority: azure_authority.as_deref(), + }, + ) + .with_context(|| { + format!( + "create portable Azure Key Vault Authenticode signature for {}", + path.display() + ) + })? + } + #[cfg(not(feature = "azure-kv-sign-portable"))] + { + return Err(anyhow!( + "portable sign-pe Azure Key Vault support requires the azure-kv-sign-portable feature" + )); + } + } else { + let (cert, key) = match (cert, key) { + (Some(cert), Some(key)) => (cert, key), + _ => { + return Err(anyhow!( + "portable sign-pe requires either --cert and --key, or --azure-key-vault-url and --azure-key-vault-certificate" + )); + } + }; + 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()) + })?); + } + pkcs7::create_pe_authenticode_pkcs7_der_rsa( + &pe, + digest.into(), + signer_cert, + chain, + private_key, ) - })?; + .with_context(|| { + format!( + "create portable Authenticode signature for {}", + path.display() + ) + })? + }; + match (timestamp_url, timestamp_digest) { + (Some(url), Some(timestamp_digest)) => { + #[cfg(feature = "timestamp-http")] + { + pkcs7 = timestamp_pkcs7_der_rfc3161(&pkcs7, &url, timestamp_digest) + .with_context(|| { + format!( + "RFC3161 timestamp portable Authenticode signature for {}", + path.display() + ) + })?; + } + #[cfg(not(feature = "timestamp-http"))] + { + let _ = (url, timestamp_digest); + return Err(anyhow!( + "portable sign-pe RFC3161 timestamping requires the timestamp-http feature" + )); + } + } + (Some(_), None) => { + return Err(anyhow!( + "portable sign-pe requires --timestamp-digest with --timestamp-url" + )); + } + (None, Some(_)) => { + return Err(anyhow!( + "portable sign-pe requires --timestamp-url with --timestamp-digest" + )); + } + (None, None) => {} + } let signed = pe_embed::pe_append_authenticode_pkcs7_certificate(pe, &pkcs7) .with_context(|| format!("embed Authenticode signature in {}", path.display()))?; std::fs::write(&output, signed).with_context(|| format!("write {}", output.display()))?; diff --git a/crates/psign-opc-sign/Cargo.toml b/crates/psign-opc-sign/Cargo.toml index 4a5159f..c742ba7 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.1.0" +version = "0.2.0" edition = "2024" description = "Portable OPC, VSIX, and NuGet package signing primitives" license.workspace = true diff --git a/crates/psign-sip-digest/Cargo.toml b/crates/psign-sip-digest/Cargo.toml index 7a66eed..15bbdee 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.1.0" +version = "0.2.0" edition = "2024" description = "Portable Authenticode SIP digest recomputation (PE, CAB, MSI, MSIX, scripts, …) without Win32" license.workspace = true diff --git a/docs/ci-parity.md b/docs/ci-parity.md index 214acf2..0418dd4 100644 --- a/docs/ci-parity.md +++ b/docs/ci-parity.md @@ -36,10 +36,10 @@ For local no-admin online certificate checks, use [`scripts/run-local-online-cer `psign-server` also provides local Azure-shaped endpoints for CI-safe signing tests: -- **`azure-key-vault-server`** serves Key Vault-compatible certificate metadata and **`keys/sign`** responses. Automated coverage uses **`psign-tool portable azure-key-vault-sign-digest`** with a local bearer token and does not call Azure. +- **`azure-key-vault-server`** serves Key Vault-compatible certificate metadata and **`keys/sign`** responses. Automated coverage uses **`psign-tool portable azure-key-vault-sign-digest`**, **`psign-tool portable sign-pe`**, and **`psign-tool --mode portable sign`** with a local bearer token and does not call Azure. - **`artifact-signing-server`** serves the Azure Code Signing / Trusted Signing **`:sign`** data-plane plus pollable LRO responses. Automated coverage uses both **`psign-tool portable artifact-signing-submit`** and the Windows **`psign-tool artifact-signing-submit`** command through the hidden local endpoint override. -Full Windows PE signing through the Azure Key Vault **`SignerSignEx3`** callback still requires a local provider binding before it can be enabled as a non-ignored CI test; the local server already covers the REST digest-signing contract used by that path. +Windows PE signing through the Azure Key Vault **`SignerSignEx3`** callback and portable PE signing both have local server coverage; the remaining production-only gap is real Azure identity / vault integration. For a **baseline** report matching the static tier (verify/remove scenarios only, no optional blocks), clear process env vars whose names start with `PSIGN_`, then run `scripts/run-parity-diff.ps1`. Expect **21** scenarios, `missingScenarioCount: 0`, and `semanticMismatchCount: 0` (UTF-16 `@rsp` remains `documented_native_utf16_rsp_gap`, not a semantic failure). diff --git a/docs/gap-analysis-signing-platforms.md b/docs/gap-analysis-signing-platforms.md index e16a918..a208f2c 100644 --- a/docs/gap-analysis-signing-platforms.md +++ b/docs/gap-analysis-signing-platforms.md @@ -83,13 +83,13 @@ The committed corpus already includes generated unsigned and signed vectors for | Goal | Today | Gap | |------|--------|-----| | **Drop-in Linux replacement for `signtool.exe` sign/verify** | Not supported | Signing and WinTrust-backed verify require Windows CryptAPI/SIP (`SignerSignEx3`, `WinVerifyTrust`). | -| **Drop-in Linux replacement for AzureSignTool** | Partial | **`azure-key-vault-sign-digest`** on **`psign-tool portable`** (**`--features azure-kv-sign-portable`**) performs the Key Vault **`keys/sign`** step (**digest file → signature**). Use **`pe-digest --encoding raw`** for the **PE image** hash file; use **`pe-signer-rs256-prehash --encoding raw`** (optional **`--signer-index`** for the *N*th **`SignerInfo`** inside the selected PKCS#7 row) when you need the **CMS authenticated-attribute** **SHA-256** prehash (**32** octets) for **`RS256`** on an **existing embedded PKCS#7** (see [`migration-azuresigntool.md`](migration-azuresigntool.md)). **Embedding** Authenticode still requires **`psign-tool`** (`SignerSignEx3`) or a portable **`SignedData`** rebuild. Full **`sign`** with KV callback remains Windows (**`--features azure-kv-sign`**). | +| **Drop-in Linux replacement for AzureSignTool** | Partial | **`psign-tool portable sign-pe --azure-key-vault-* --timestamp-url ...`** and **`psign-tool --mode portable sign --azure-key-vault-* --timestamp-url ...`** can build timestamped PE Authenticode signatures with Key Vault RSA signing. **`azure-key-vault-sign-digest`** remains available for lower-level **`keys/sign`** workflows. Gaps: non-PE remote-sign embedding still requires Windows mode or future portable signer support. | | **Drop-in Linux replacement for Artifact Signing (dlib / REST)** | Partial | **`artifact-signing-submit`** (**`--features artifact-signing-rest`**) runs on **Linux/macOS** via **`psign-tool portable`** or on Windows via **`psign-tool`** — same **`:sign`** LRO (**hash → JSON**). **Embedding** PKCS#7 still requires **`SignerSignEx3`** + dlib or future portable CMS/embed. **`psign-tool portable`** validates **`--dmdf`** JSON without network. | | **Linux verify + digest parity for many Authenticode formats** | Supported | **`psign-tool portable`** covers PE, CAB, MSI, ESD/WIM, cleartext MSIX, catalog, scripts; **`trust-verify-*`** adds anchor-based CMS trust (see [`authenticode-trust-stack.md`](authenticode-trust-stack.md)). | | **Maximum Windows-mode Authenticode subject formats** | Windows mode delegates most SIP-registered subjects to OS providers | Remaining gaps are first-class CLI affordances, parity fixtures, generic SIP remove, catalog authoring/member policy, Office/VBA ergonomics, extension SIP coverage, and standalone `.p7x` handling. | | **Maximum portable-mode Authenticode subject formats** | Portable mode covers digest/trust for PE, CAB, MSI, ESD/WIM, cleartext MSIX, catalogs, scripts, and detached PKCS#7; local signing for PE/CAB/MSI/generic catalogs is explicitly scoped | Portable gaps include MSIX signing/embed, non-PE timestamp mutation, WinTrust/CryptoAPI policy, encrypted MSIX, extension SIPs, Office/VBA, standalone `.p7x`, and package-specific ecosystems. | -**Practical Linux path today:** Use **`psign-tool portable`** for **digest computation**, **local signing** of PE/CAB/MSI/generic catalogs, **Key Vault `keys/sign`** on digest files (**`azure-key-vault-sign-digest`** with **`--features azure-kv-sign-portable`**), **`:sign` REST** (**`artifact-signing-submit`** with **`--features artifact-signing-rest`**), **inspect**, and **verify/trust** across supported formats. Broader native-shaped signing and unsupported SIP embedders still require **`psign-tool`** / **`SignerSignEx3`** (or native **`signtool.exe`**). Cookbook: [`linux-signing-pipelines.md`](linux-signing-pipelines.md). +**Practical Linux path today:** Use **`psign-tool portable`** for **digest computation**, **local signing** of PE/CAB/MSI/generic catalogs, **Key Vault PE signing** (`portable sign-pe` or `--mode portable sign`), **Key Vault `keys/sign`** on digest files (**`azure-key-vault-sign-digest`** with **`--features azure-kv-sign-portable`**), **`:sign` REST** (**`artifact-signing-submit`** with **`--features artifact-signing-rest`**), **inspect**, and **verify/trust** across supported formats. Broader native-shaped signing and unsupported SIP embedders still require **`psign-tool`** / **`SignerSignEx3`** (or native **`signtool.exe`**). Cookbook: [`linux-signing-pipelines.md`](linux-signing-pipelines.md). **Long-term Linux signing** (if required): extend the portable **CMS `SignerInfo` production** (inside **`SignedData`**) + **format-specific embedding** beyond the current PE/CAB/MSI/catalog subset to MSIX `ContentTypes` / manifest glue and other package-native formats, then combine with **remote signing** (KV REST, Artifact Signing `:sign` LRO). [`pkcs7.rs`](crates/psign-sip-digest/src/pkcs7.rs) holds parse/replace helpers, **`signed_data_replace_first_signer_info`**, **`encode_pkcs7_content_info_signed_data_der`**, **RSA PKCS#1 RS256** prehash ↔ **`SignerInfo.signature`** parity tests (`rsa_pkcs1v15_signed_attrs_verify`), and **`signer_info_sha256_digest_over_signed_attrs`** (documented KV **`RS256`** input shape); [`pe_embed.rs`](crates/psign-sip-digest/src/pe_embed.rs) can **wrap PKCS#7**, **append** rows (including after signer splice experiments), and **recompute `CheckSum`**. **`psign-tool portable pe-signer-rs256-prehash`** surfaces the **32-byte** prehash for Linux KV workflows; MSIX signing/embed and non-PE timestamp mutation remain backlog (see [`rust-sip-gaps.md`](rust-sip-gaps.md)). @@ -121,11 +121,11 @@ The committed corpus already includes generated unsigned and signed vectors for | AzureSignTool concept | `psign-tool` | `psign-tool portable` | |-----------------------|-------------------|---------------------| -| KV URL, cert name, auth (MI / SP / token) | Yes (`--features azure-kv-sign`) | **`azure-key-vault-sign-digest`** (`--features azure-kv-sign-portable`) — digest file only | +| KV URL, cert name, auth (MI / SP / token) | Yes (`--features azure-kv-sign`) | PE signing via **`portable sign-pe`** / **`--mode portable sign`**; digest-only helper via **`azure-key-vault-sign-digest`** | | Batch / parallelism / exit HRESULTs | Mapped (`--input-file-list`, `--exit-codes azuresigntool`, …) | N/A | | ECDSA keys | Supported on KV path (alg derived from cert) | Same JWS algs (**ES256**/…) inferred from certificate **`cer`** | -**Gap:** Broad native-shaped signing is still **Windows + SIP** for production compatibility. Portable local-key signing now exists for PE, unsigned single-volume CAB, and MSI/MSP; portable KV signs an **opaque digest blob** — use the correct digest for your pipeline (**image** vs **CMS signer** prehash; **`pe-signer-rs256-prehash`** for the latter on PE). Wiring KV-returned **`encryptedDigest`** into **`SignedData`** is supported by helpers/tests, but end-to-end portable remote signing and MSIX/package-specific embedding remain future work. +**Gap:** Broad native-shaped signing is still **Windows + SIP** for production compatibility. Portable local-key signing now exists for PE, unsigned single-volume CAB, and MSI/MSP; portable KV PE signing can build and embed Authenticode directly. Digest-only KV helpers remain useful when you need a raw remote signature, but use the correct digest for your pipeline (**image** vs **CMS signer** prehash; **`pe-signer-rs256-prehash`** for the latter on PE). Portable Key Vault signing for CAB/MSI/catalog and MSIX/package-specific embedding remain future work. Details: [`migration-azuresigntool.md`](migration-azuresigntool.md). @@ -163,17 +163,17 @@ Portable support is intentionally split by lifecycle stage. This keeps Linux/mac | Digest computation | Routed through `verify` only when it can infer a supported subject format | `pe-digest`, `cab-digest`, and format-specific `verify-*` commands | Supported for PE/WinMD, CAB, MSI/MSP, WIM/ESD, cleartext MSIX/AppX, catalogs, and scripts | | PKCS#7 inspection / extraction | `inspect-signature` routes to `inspect-authenticode` | `inspect-authenticode`, `extract-pe-pkcs7`, `extract-cab-pkcs7`, `extract-msi-pkcs7`, `list-pe-pkcs7` | Supported diagnostics; no trust decision by itself | | Explicit-anchor trust verification | `verify` routes only when portable trust inputs are present and the inferred format has a trust command | `trust-verify-pe`, `trust-verify-cab`, `trust-verify-msi`, `trust-verify-esd`, `trust-verify-catalog`, `trust-verify-detached` | Supported with explicit anchors and bounded online AIA/OCSP/CRL; not OS store policy | -| Remote hash signing | Not exposed through top-level `sign` | `artifact-signing-submit`, `azure-key-vault-sign-digest`, signer prehash commands | Supported as digest-in/signature-out helpers; no subject embed | +| Remote hash/signing | PE Key Vault signing through top-level `sign`; other remote helpers are not routed | `sign-pe --azure-key-vault-*`, `artifact-signing-submit`, `azure-key-vault-sign-digest`, signer prehash commands | PE Key Vault signing embeds Authenticode; other remote helpers are digest-in/signature-out only | | 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 | Top-level `timestamp` returns an explicit portable-not-implemented error | `timestamp-pe-rfc3161` attaches a granted RFC3161 `timeStampToken` to PE `SignedData`; request/response helpers can prepare or inspect TSA traffic | PE token embedding supported; non-PE timestamp embedding and native-shaped 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; `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 | | 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 | The compatibility rule is: **portable mode may prove digest/CMS consistency and explicit-anchor trust, but it must not silently emulate Windows policy.** When a user asks for a Windows-only lifecycle stage, the CLI should fail with an explicit unsupported/not-implemented message and point to the closest portable helper. -**Remote signing steps:** With **`--features azure-kv-sign-portable`**, **`azure-key-vault-sign-digest`** performs Azure Key Vault **`keys/sign`** on a **raw digest file** (same REST shape as AzureSignTool’s remote step). **`pe-signer-rs256-prehash`**, **`cab-signer-rs256-prehash`**, **`msi-signer-rs256-prehash`**, and **`catalog-signer-rs256-prehash`** (**`--encoding raw`**) emit the **32-byte** **`RS256`** input over **`SignerInfo.signedAttrs`** (distinct from subject-layout digests and from **`verify-catalog`**’s CTL **`eContent`** / PKCS#9 checks). With **`--features artifact-signing-rest`**, **`artifact-signing-submit`** calls Trusted Signing **`:sign`**. The PE/CAB/MSI CMS core can now inject externally produced RSA/SHA-2 signature bytes, but the packaged CLI still exposes remote signing as digest/signature helpers; MSIX embedding and broad native-shaped remote-sign routing remain future portable embedder work. +**Remote signing steps:** With **`--features azure-kv-sign-portable`**, **`sign-pe --azure-key-vault-*`** performs full PE Authenticode signing with Key Vault RSA signatures, while **`azure-key-vault-sign-digest`** performs Azure Key Vault **`keys/sign`** on a **raw digest file** for lower-level workflows. **`pe-signer-rs256-prehash`**, **`cab-signer-rs256-prehash`**, **`msi-signer-rs256-prehash`**, and **`catalog-signer-rs256-prehash`** (**`--encoding raw`**) emit the **32-byte** **`RS256`** input over **`SignerInfo.signedAttrs`** (distinct from subject-layout digests and from **`verify-catalog`**’s CTL **`eContent`** / PKCS#9 checks). With **`--features artifact-signing-rest`**, **`artifact-signing-submit`** calls Trusted Signing **`:sign`**. The PE/CAB/MSI CMS core can inject externally produced RSA/SHA-2 signature bytes; CAB/MSI/catalog remote-sign CLI routing, MSIX embedding, and broad native-shaped remote-sign routing remain future portable embedder work. **RFC 3161 TSA helpers:** **`rfc3161-timestamp-req`** builds **`TimeStampReq`** DER from **`--digest-hex`** / **`--digest-file`** (message-imprint preimage; optional **`--nonce`**, **`--cert-req`**) for **`curl`** / OpenSSL **`ts`** against a timestamp URL. **`rfc3161-timestamp-resp-inspect`** prints **`pki_status`** / **`pki_status_int`** (raw status INTEGER) / **`granted`** / token length, **`time_stamp_token_prefix_hex`** (first **16** octets of the raw **`timeStampToken`** TLV, or **`-`** when absent — handy for **`ContentInfo`** / CMS shape checks), **`status_strings_json`** (**`PKIFreeText`**), **`fail_info_tlv_hex`**, and **`fail_info_flags_json`** (RFC 2510 Appendix A **`PKIFailureInfo`** bit names through **`badPOP`**, then **`bit_N`**; **`null`** when the **`BIT STRING`** body is not decodable). Parseable CMS **`id-ct-TSTInfo`** tokens also surface structural **`tst_info_*`** diagnostics: policy OID, message-imprint digest OID/hash, serial, **`genTime`**, and nonce. Optional **`rfc3161-timestamp-http-post`** (**`--features timestamp-http`**) performs the HTTPS POST without **`curl`**. **`timestamp-pe-rfc3161`** can then attach a raw **`timeStampToken`** or granted **`TimeStampResp`** token to an existing PE Authenticode `SignerInfo` as the Microsoft RFC3161 unsigned attribute. This still does not clone every **`SignerTimeStampEx3`** policy branch or timestamp non-PE subjects. diff --git a/docs/linux-signing-pipelines.md b/docs/linux-signing-pipelines.md index 31838f9..8a6da90 100644 --- a/docs/linux-signing-pipelines.md +++ b/docs/linux-signing-pipelines.md @@ -1,6 +1,6 @@ # Linux signing pipelines (what works today) -**`psign-tool portable`** on Linux/macOS can now sign PE, unsigned single-volume CAB, MSI/MSP, generic catalogs, and RDP files with local RSA/SHA-2 keys. It still does not provide a broad native-compatible `sign` verb, MSIX signing/embed, OS catalog database policy, or WinTrust policy emulation (see [`rust-sip-gaps.md`](rust-sip-gaps.md)). This page describes **practical portable**, **hybrid**, and **verify-only** flows. +**`psign-tool portable`** on Linux/macOS can now sign PE with local RSA/SHA-2 keys or Azure Key Vault RSA signing, and can sign unsigned single-volume CAB, MSI/MSP, generic catalogs, and RDP files with local RSA/SHA-2 keys. It still does not provide a broad native-compatible `sign` verb, MSIX signing/embed, OS catalog database policy, or WinTrust policy emulation (see [`rust-sip-gaps.md`](rust-sip-gaps.md)). This page describes **practical portable**, **hybrid**, and **verify-only** flows. For tool-by-tool gaps vs **`signtool.exe`**, AzureSignTool, and Artifact Signing, see [`gap-analysis-signing-platforms.md`](gap-analysis-signing-platforms.md). On Windows, for writable copies of native signing binaries outside protected install paths, see [`writable-signing-binaries.md`](writable-signing-binaries.md). @@ -29,6 +29,36 @@ psign-tool portable sign-catalog --cert cert.der --key key.pk8 --output files.ca `sign-catalog` authors generic CTL member entries and signs the catalog PKCS#7. Pair it with `verify-catalog` and `verify-catalog-member --catalog files.cat file1.exe`; driver/INF policy and OS catalog database lookup remain Windows-only. +## 1.2 Portable PE signing with Azure Key Vault + +With **`--features azure-kv-sign-portable`**, PE/WinMD signing can use Azure Key Vault for the RSA signature while building and embedding Authenticode CMS locally: + +```bash +psign-tool portable sign-pe ./MyApp.exe \ + --azure-key-vault-url https://myvault.vault.azure.net \ + --azure-key-vault-certificate my-cert \ + --azure-key-vault-managed-identity \ + --timestamp-url http://timestamp.digicert.com \ + --timestamp-digest sha256 \ + --digest sha256 \ + --output ./MyApp.signed.exe +``` + +The PE subset of the native-shaped verb is also available for in-place signing: + +```bash +psign-tool --mode portable sign \ + --azure-key-vault-url https://myvault.vault.azure.net \ + --azure-key-vault-certificate my-cert \ + --azure-key-vault-managed-identity \ + --timestamp-url http://timestamp.digicert.com \ + --timestamp-digest sha256 \ + --digest sha256 \ + ./MyApp.exe +``` + +Portable Key Vault PE signing supports SHA-256/SHA-384/SHA-512, optional chain certificates (`--chain-cert` on `portable sign-pe`, `--ac` on `--mode portable sign`), and RFC3161 sign-time timestamping through `--timestamp-url` plus `--timestamp-digest`. `timestamp-pe-rfc3161` remains available as a separate mutation step when you already have a timestamp token or granted response. + ## 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. @@ -77,9 +107,9 @@ Optional debug: **`SIGNTOOL_PORTABLE_DEBUG=1`**. Details: [`migration-artifact-signing.md`](migration-artifact-signing.md). -## 3. AzureSignTool — Key Vault digest sign on Linux +## 3. AzureSignTool — Key Vault signing on Linux -**Partial.** Use **`pe-digest` / `cab-digest`** (**`--encoding raw`**) for **subject layout** digests when that matches your tool mode, or the **CMS authenticated-attribute** prehash family when you mirror **`CryptMsg`** / **`SignerSignEx3`** signing over **`signedAttrs`**: +For full portable PE signing, prefer **`portable sign-pe --azure-key-vault-*`** or **`--mode portable sign --azure-key-vault-*`** from section 1.2. For lower-level digest-only integrations, use **`pe-digest` / `cab-digest`** (**`--encoding raw`**) for **subject layout** digests when that matches your tool mode, or the **CMS authenticated-attribute** prehash family when you mirror **`CryptMsg`** / **`SignerSignEx3`** signing over **`signedAttrs`**: | Subject | Prehash for KV **`RS256`** (`--encoding raw`, 32 bytes) | Same bytes via extract + generic PKCS#7 | |---------|------------------------------------------------------------|-------------------------------------------| @@ -90,7 +120,7 @@ Details: [`migration-artifact-signing.md`](migration-artifact-signing.md). 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. -**Embed** PKCS#7 on Windows with **`psign-tool`** (`--features azure-kv-sign`) or native **`signtool.exe`**. +PE embedding is 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-azuresigntool.md b/docs/migration-azuresigntool.md index 4d0e97e..b7c2568 100644 --- a/docs/migration-azuresigntool.md +++ b/docs/migration-azuresigntool.md @@ -1,6 +1,6 @@ # Migrating from AzureSignTool -This project can replace **AzureSignTool** for Windows signing when built with **`--features azure-kv-sign`**. **`psign-tool portable`** covers digest checks, verification, and (with **`--features azure-kv-sign-portable`**) the Key Vault **`keys/sign`** step on **digest files** — not full **`sign`** / embed (that stays **`psign-tool`**). +This project can replace **AzureSignTool** for Windows signing when built with **`--features azure-kv-sign`**. **`psign-tool portable`** covers digest checks, verification, and (with **`--features azure-kv-sign-portable`**) Key Vault **`keys/sign`** on digest files plus PE Authenticode signing through **`portable sign-pe`** or the PE subset of **`--mode portable sign`**. Windows mode remains the broader native-shaped signing path. **Azure Artifact Signing (Trusted Signing)** via Microsoft’s decoupled **`Azure.CodeSigning.Dlib.dll`** is **not** the Key Vault path: use **`--dlib`** / **`--trusted-signing-dlib-root`** with **`--dmdf`** only (never mixed with **`--azure-key-vault-url`**). See [`migration-artifact-signing.md`](migration-artifact-signing.md). PowerShell OpenAuthenticode overlap (inspect JSON, REST submit, EKU prefix selection) is summarized in [`psa-interoperability.md`](psa-interoperability.md). @@ -21,7 +21,7 @@ psign-tool.exe sign ^ --azure-key-vault-managed-identity ^ --timestamp-url http://timestamp.digicert.com ^ --timestamp-digest sha256 ^ - --file-digest sha256 ^ + --digest sha256 ^ -ifl files.txt ^ path\to\*.dll ^ other.exe @@ -73,9 +73,39 @@ The old helper executable name is no longer emitted; use **`psign-tool sign --ex Default **`signtool`** exit codes remain **`0` / `1` / `2`**. +### Linux / CI: portable PE signing with Key Vault + +For Windows PE artifacts on Linux/macOS, use the portable PE signer. This assembles Authenticode CMS locally, asks Key Vault to sign the CMS authenticated-attribute digest, embeds the resulting PKCS#7 in the PE certificate table, and recomputes the PE checksum: + +```bash +psign-tool portable sign-pe ./MyApp.exe \ + --azure-key-vault-url https://myvault.vault.azure.net \ + --azure-key-vault-certificate my-cert \ + --azure-key-vault-managed-identity \ + --timestamp-url http://timestamp.digicert.com \ + --timestamp-digest sha256 \ + --digest sha256 \ + --output ./MyApp.signed.exe +``` + +The native-shaped PE subset also works in-place: + +```bash +psign-tool --mode portable sign \ + --azure-key-vault-url https://myvault.vault.azure.net \ + --azure-key-vault-certificate my-cert \ + --azure-key-vault-managed-identity \ + --timestamp-url http://timestamp.digicert.com \ + --timestamp-digest sha256 \ + --digest sha256 \ + ./MyApp.exe +``` + +The portable path currently supports PE/WinMD, SHA-2 digests, Key Vault signer certificates, optional **`--ac` / `--chain-cert`** certificates, and RFC3161 sign-time timestamping. Use **`psign-tool portable timestamp-pe-rfc3161`** as a second portable step only when you already have a timestamp token/response. + ### Linux / CI: Key Vault **`keys/sign`** on a raw digest -Build **`psign-tool portable`** with **`--features azure-kv-sign-portable`**. Produce **`digest.bin`** with **`pe-digest`** / **`cab-digest`** (**`--encoding raw`**), then: +For lower-level integrations, produce **`digest.bin`** with **`pe-digest`** / **`cab-digest`** (**`--encoding raw`**), then: ```bash psign-tool portable azure-key-vault-sign-digest \ @@ -86,11 +116,11 @@ psign-tool portable azure-key-vault-sign-digest \ --azure-key-vault-managed-identity ``` -Stdout prints **standard base64** signature bytes (no PEM). **`--signature-output PATH`** writes **raw** signature. **ECDSA** certificates use **ES256** / **ES384** / **ES512** automatically (same as **`psign-tool`** KV path). Embedding into a PE/CAB still requires Windows **`SignerSignEx3`** (this repo) or a **future portable CMS `SignedData` builder** that consumes these signature octets. +Stdout prints **standard base64** signature bytes (no PEM). **`--signature-output PATH`** writes **raw** signature. **ECDSA** certificates use **ES256** / **ES384** / **ES512** automatically for the digest-only helper. Portable PE signing currently requires an RSA Key Vault certificate because Authenticode CMS emission uses RSA PKCS#1 v1.5. **CMS signer digest vs subject (file layout) digest:** **`pe-digest`**, **`cab-digest`**, and the MSI installer fingerprint path behind **`verify-msi`** hash **subject layout** — **not** the **32-byte** **`RS256`** input over **`SignerInfo.signedAttrs`**. AzureSignTool’s **`CryptMsg`** path signs **authenticated attributes**; for **RSA SHA-256** the Key Vault **`RS256`** **`value`** is **SHA-256** over that attribute **`SET`** — on PE use **`pe-signer-rs256-prehash --encoding raw`** (**`--index`** = PKCS#7 row, **`--signer-index`** = **`SignerInfo`**); on signed **`.cab`** use **`cab-signer-rs256-prehash`** (or **`extract-cab-pkcs7`** then **`pkcs7-signer-rs256-prehash`**); on **`.msi`** use **`msi-signer-rs256-prehash`** (or **`extract-msi-pkcs7`** then **`pkcs7-signer-rs256-prehash`**); on raw PKCS#7 **`.cat`** bodies use **`catalog-signer-rs256-prehash`** (same bytes as **`pkcs7-signer-rs256-prehash`**). If PKCS#7 is already in a file, use **`pkcs7-signer-rs256-prehash --signer-index N --encoding raw`**. Library parity is tested in **`psign-sip-digest`** (`rsa_pkcs1v15_signed_attrs_verify`). -**Experimental (Linux PE layout only):** **`psign-tool portable append-pe-pkcs7`** appends PKCS#7 DER as a new **`WIN_CERTIFICATE`** row and recomputes **`CheckSum`**. Use **`pe-checksum --strict`** on the output to gate ImageHlp-style checksum parity. This **does not** assemble PKCS#7 from KV signature bytes — it is for tooling / prototypes until portable **`SignedData`** encode lands. +**Experimental (Linux PE layout only):** **`psign-tool portable append-pe-pkcs7`** appends prebuilt PKCS#7 DER as a new **`WIN_CERTIFICATE`** row and recomputes **`CheckSum`**. Use **`pe-checksum --strict`** on the output to gate ImageHlp-style checksum parity. ## Verification with **`psign-tool portable`** @@ -116,6 +146,4 @@ Use the appropriate portable subcommands for your format (`verify-pe`, catalog c ## Integration testing -Automated CI uses **`psign-server azure-key-vault-server`** as a local Key Vault replacement for digest signing. The non-ignored E2E test starts the server, calls **`psign-tool portable azure-key-vault-sign-digest`** with **`--azure-key-vault-url http://127.0.0.1:...`** and a dummy access token, then checks the returned RSA signature bytes. This exercises the same REST certificate lookup and **`keys/sign`** client used by the Windows signing path without contacting Azure. - -Full Windows PE signing through **`psign-tool sign --azure-key-vault-url ...`** is tracked separately because **`SignerSignEx3`** currently requires a local provider binding before a Key Vault-only certificate can be used for embedded signing. Until that is enabled as a non-ignored local test, use real-vault/manual validation for full AzureSignTool replacement signing and compare **`psign-tool verify`** plus **`psign-tool portable verify-pe`** results with a known-good AzureSignTool-signed artifact. +Automated CI uses **`psign-server azure-key-vault-server`** as a local Key Vault replacement. Non-ignored E2E tests cover both **`psign-tool portable azure-key-vault-sign-digest`** and full portable PE signing through **`portable sign-pe`** / **`--mode portable sign`** with a dummy access token, then verify the embedded Authenticode signature without contacting Azure. diff --git a/docs/psign-cli-matrix.json b/docs/psign-cli-matrix.json index f0ff28b..a32ef76 100644 --- a/docs/psign-cli-matrix.json +++ b/docs/psign-cli-matrix.json @@ -244,7 +244,7 @@ {"name": "list-pe-pkcs7", "maps_to_native_concept": "Enumerate embedded PKCS#7 rows (byte_len per index)"}, {"name": "inspect-pe-spc-indirect", "maps_to_native_concept": "JSON for SpcIndirectDataContent / digest in embedded PE PKCS#7"}, {"name": "append-pe-pkcs7", "maps_to_native_concept": "Experimental: append PKCS#7 row + recompute PE CheckSum — not SignerSignEx3"}, - {"name": "sign-pe", "maps_to_native_concept": "Portable PE Authenticode signing: create RSA/SHA-2 SignedData, append WIN_CERTIFICATE, recompute CheckSum; no WinTrust policy or timestamp embedding"}, + {"name": "sign-pe", "maps_to_native_concept": "Portable PE Authenticode signing: create RSA/SHA-2 SignedData with local key or Azure Key Vault RSA signing, optionally attach RFC3161 timestamp, append WIN_CERTIFICATE, recompute CheckSum; no WinTrust policy"}, {"name": "sign-cab", "maps_to_native_concept": "Portable unsigned single-volume CAB Authenticode signing: insert reserve header, create RSA/SHA-2 SignedData, append tail PKCS#7"}, {"name": "sign-msi", "maps_to_native_concept": "Portable MSI/MSP Authenticode signing: create RSA/SHA-2 SignedData and write the DigitalSignature OLE stream"}, {"name": "sign-catalog", "maps_to_native_concept": "Portable generic catalog signing: author CTL member entries, create RSA/SHA-2 SignedData over Microsoft CTL eContent; no catalog database or driver/INF policy"}, diff --git a/docs/roadmap-authenticode-linux.md b/docs/roadmap-authenticode-linux.md index 2f2d5f0..8543188 100644 --- a/docs/roadmap-authenticode-linux.md +++ b/docs/roadmap-authenticode-linux.md @@ -32,7 +32,7 @@ Portable mode is staged by lifecycle capability rather than by native `signtool. | Remote hash signing | Artifact Signing `:sign`, Key Vault `keys/sign`, and Authenticode signer prehash helpers | Returns signatures over supplied digests only; no Authenticode embed | | Local signing | Portable RDP, PE `sign-pe`, unsigned single-volume CAB `sign-cab`, MSI/MSP `sign-msi`, and generic catalog `sign-catalog` with RSA/SHA-2 | Cleartext MSIX Authenticode signing and CAB replacement/multivolume signing remain backlog | | CMS creation / embedding | PE, CAB, MSI, and catalog `SignedData` creation; remote RSA signature injection helpers; PE `WIN_CERTIFICATE`, CAB reserve-header/tail PKCS#7, MSI `DigitalSignature` stream embedding, and CTL `eContent` authoring | MSIX production embedding remains backlog | -| Timestamping | RFC3161 request/response construction, POST, inspection helpers, and PE `timestamp-pe-rfc3161` token embedding | Non-PE timestamp embedding and full `SignerTimeStampEx3` policy parity remain backlog | +| Timestamping | RFC3161 request/response construction, POST, inspection helpers, PE sign-time timestamping through `sign-pe --timestamp-url --timestamp-digest`, and PE `timestamp-pe-rfc3161` token embedding | Non-PE timestamp embedding and full `SignerTimeStampEx3` policy parity remain backlog | | Mutation / removal | None for Authenticode subjects | Requires format-specific embedders before safe remove/update support | | Catalog workflows | Generic catalog `sign-catalog`, CMS/catalog consistency checks, and explicit `verify-catalog-member --catalog file.cat subject` for MakeCat-style or psign-authored catalogs | No OS catalog database search, driver package policy, INF metadata, or MakeCat byte-for-byte output | diff --git a/docs/rust-sip-gaps.md b/docs/rust-sip-gaps.md index 05356db..9174742 100644 --- a/docs/rust-sip-gaps.md +++ b/docs/rust-sip-gaps.md @@ -42,7 +42,7 @@ VSIX and NuGet package signatures should not be modeled as Rust SIP gaps. VSIX u |------|--------| | PE/CAB/MSI **PKCS#7 encode** + format embed entirely in Rust | **Implemented for PE RSA/SHA-2:** **`psign-tool portable sign-pe`** computes the PE Authenticode digest, creates Authenticode **`SignedData`** from scratch, wraps it in a **`WIN_CERTIFICATE`**, appends it, and recomputes **`CheckSum`**. **Implemented for unsigned single-volume CAB RSA/SHA-2:** **`psign-tool portable sign-cab`** inserts the CAB Authenticode reserve header, creates CAB **`SpcIndirectDataContent`**, appends tail PKCS#7, and verifies through **`verify-cab`**. **Implemented for MSI/MSP RSA/SHA-2:** **`psign-tool portable sign-msi`** creates MSI **`SpcSigInfo`** indirect data and writes the root **`\u{5}DigitalSignature`** stream. **`pe_embed`** still exposes **`wrap_pkcs7_der_authenticode_win_certificate`** + **`pe_append_authenticode_pkcs7_certificate`** for lower-level PE flows. **`pkcs7.rs`** now includes PE, CAB, and MSI **`SpcIndirectDataContent`** construction, local RSA/SHA-256/384/512 CMS signing, and remote RSA signature injection helpers in addition to PKCS#9 **`messageDigest`** extract/replace, authenticated-attribute **`SET OF Attribute`** DER, **`signer_info_sha256_digest_over_signed_attrs`**, **`signer_info_clone_with_signed_attrs`** / **`signer_info_clone_with_signature_octets`**, **`signed_data_replace_signer_info_at`**, and **`signed_data_replace_first_signer_info`**. Remaining gaps: ECDSA attribute-sign rules, optional attr tweaks beyond PKCS#9 **`messageDigest`**, broad top-level `sign` routing, CAB replacement/multivolume cases, `MsiDigitalSignatureEx` authoring, MSIX production embedding, and non-PE timestamp mutation. | | **MSIX/Appx `CryptSIPDllCreateIndirectData`** | **`AppxSipCreateIndirectData`** / **`AppxBundleSipCreateIndirectData`** build the **APPX `SpcIndirectData`** blob at sign time; **`msix_digest`** only **verifies** recomputed AX\* vs PKCS#7 — see [`windows-signing-components.md`](windows-signing-components.md) (**AppxSip.dll**) and [`rust-sip-spec-refs.md`](rust-sip-spec-refs.md). | -| **RFC3161** timestamp construction in Rust | **Partial:** `crates/psign-sip-digest/src/timestamp.rs` — **`build_timestamp_request_bytes`** encodes **DER** **`TimeStampReq`** (version 1 + **`MessageImprint`** + optional **`nonce`** / **`certReq`**) for SHA-1 / SHA-256 / SHA-384 / SHA-512; **`parse_time_stamp_resp_der`** reads **`PKIStatusInfo.status`**, optional **`statusString`**, optional **`failInfo`**, plus optional raw **`timeStampToken`**; **`parse_time_stamp_token_tst_info`** structurally extracts CMS **`id-ct-TSTInfo`** policy OID, message-imprint digest OID/hash, serial, **`genTime`**, and nonce. **`psign-tool portable`** exposes **`rfc3161-timestamp-req`**, **`rfc3161-timestamp-resp-inspect`**, optional **`rfc3161-timestamp-http-post`** with **`--features timestamp-http`**, and **`timestamp-pe-rfc3161`** to attach a raw token or granted **`TimeStampResp`** token to a PE `SignerInfo` unsigned attribute. Portable trust uses cryptographic RFC3161 validation when both **`--prefer-timestamp-signing-time`** and **`--require-valid-timestamp`** are set: nested token **`MessageImprint`** over primary **`SignerInfo.signature`**, timestamp CMS **`messageDigest`**, RSA/SHA-256 timestamp signature, TSA `timeStamping` EKU, and explicit-anchor TSA chain. PKCS#9 **`signing-time`** still works only for non-required instant selection. Remaining gaps: delegated/non-RSA TSA support, automatic sign-time TSA POST integration, non-PE timestamp mutation, and full Windows **`CryptVerifyTimeStampSignature`** parity. | +| **RFC3161** timestamp construction in Rust | **Partial:** `crates/psign-sip-digest/src/timestamp.rs` — **`build_timestamp_request_bytes`** encodes **DER** **`TimeStampReq`** (version 1 + **`MessageImprint`** + optional **`nonce`** / **`certReq`**) for SHA-1 / SHA-256 / SHA-384 / SHA-512; **`parse_time_stamp_resp_der`** reads **`PKIStatusInfo.status`**, optional **`statusString`**, optional **`failInfo`**, plus optional raw **`timeStampToken`**; **`parse_time_stamp_token_tst_info`** structurally extracts CMS **`id-ct-TSTInfo`** policy OID, message-imprint digest OID/hash, serial, **`genTime`**, and nonce. **`psign-tool portable`** exposes **`rfc3161-timestamp-req`**, **`rfc3161-timestamp-resp-inspect`**, optional **`rfc3161-timestamp-http-post`** with **`--features timestamp-http`**, **`sign-pe --timestamp-url --timestamp-digest`** for PE sign-time timestamping, and **`timestamp-pe-rfc3161`** to attach a raw token or granted **`TimeStampResp`** token to a PE `SignerInfo` unsigned attribute. Portable trust uses cryptographic RFC3161 validation when both **`--prefer-timestamp-signing-time`** and **`--require-valid-timestamp`** are set: nested token **`MessageImprint`** over primary **`SignerInfo.signature`**, timestamp CMS **`messageDigest`**, RSA/SHA-256 timestamp signature, TSA `timeStamping` EKU, and explicit-anchor TSA chain. PKCS#9 **`signing-time`** still works only for non-required instant selection. Remaining gaps: delegated/non-RSA TSA support, non-PE timestamp mutation, and full Windows **`CryptVerifyTimeStampSignature`** parity. | | **`/ph`** **page hashes** (`SPC_PE_IMAGE_PAGE_HASHES`) | Portable **CMS extract** + **payload peel** + **flat `(offset,digest)*` parse** + **experimental contiguous file-offset verify** (`page_hashes`, CLI **`pe-has-page-hashes`** / **`pe-page-hash-info`** / **`verify-pe-page-hashes`**). Differs from **`WinVerifyTrust`** where checksum / security-directory handling diverges — native **`verify --verify-page-hashes`** remains the strict `/ph` reference. | | **MSIX/Appx `SignerSignEx3` signing** (`psign sign` on `.msix`) | **`APPX_SIP_CLIENT_DATA`** + **`SIGNER_SIGN_EX2_PARAMS`** as **`pSipData`** for all cleartext **`MsixFamily`** paths (embedded and **`/dlib`** decoupled) so **`AppxSip.dll`** receives **`SIP_SUBJECTINFO.pClientData`**. CI may still record **`documented_rust_msix_sign_ex3_gap`** when native succeeds but Rust fails (**`CRYPT_E_NO_PROVIDER`** `0x80092006`, publisher / manifest mismatches, etc.). **`CreateFileW`** subject handle + **`--debug`** diagnostics remain; **`pCryptoPolicy`** is still **`NULL`** — see [**SignerSignEx3 / SIP glue**](rust-sip-spec-refs.md#signersignex3-and-sip-glue). **Publisher-vs-signer binding** is enforced in **`AppxSip.dll`** (manifest vs PKCS#7 signer), not in **`msix_digest`** — see [`windows-signing-components.md`](windows-signing-components.md). | | **PowerShell-class script SIP line discipline** | **`pwrshsip.dll`** decompilation shows extension dispatch for `.ps1`, `.ps1xml`, `.psc1`, `.psd1`, `.psm1`, `.cdxml`, and `.mof`; BOM / `RtlIsTextUnicode` format probing; UTF-16 line-oriented hashing; marker families `# SIG #`, XML comments, and `/* SIG # */`; base64 extraction through `CryptStringToBinaryW`. The Rust `ps_script` module mirrors markers and BOM-aware UTF-16 stripping, but remains a heuristic consistency checker rather than a byte-for-byte clone of every line-reader and malformed-block error. | @@ -70,7 +70,7 @@ Prioritize based on whether you need **offline signing** without `mssign32` or * ## Next milestones (suggested order) 1. **PE page-hash segments** — Align **contiguous verify** with **`WinVerifyTrust`** exclusions (checksum field, security dir pointer, certificate table) and add a fixture signed **with** `/ph` for regression tests (Linux CI via `psign-tool portable` / `psign-sip-digest`). -2. **Rust PKCS#7 encode + `WIN_CERTIFICATE` embed** — Outer **`ContentInfo`** re-encode from **`SignedData`** exists (**`encode_pkcs7_content_info_signed_data_der`**); **RSA** **`RS256`** authenticated-attribute digest ↔ **`SignerInfo.signature`** parity is tested (**`rsa_pkcs1v15_signed_attrs_verify`**); **`pe-signer-rs256-prehash`** + KV **`keys/sign`** + **`signer_info_clone_with_signature_octets`** + **`signed_data_replace_first_signer_info`** compose the remote half when mutating an existing PKCS#7. Still need **`SignerInfo`** / **`SignedData`** assembly **from scratch**, **ECDSA**, timestamps, and full **`unsigned→signed`** parity; intersects split-digest (`/dg`, `/ds`) backlog. +2. **Rust PKCS#7 encode + `WIN_CERTIFICATE` embed** — Outer **`ContentInfo`** re-encode from **`SignedData`** exists (**`encode_pkcs7_content_info_signed_data_der`**); **RSA** **`RS256`** authenticated-attribute digest ↔ **`SignerInfo.signature`** parity is tested (**`rsa_pkcs1v15_signed_attrs_verify`**); **`sign-pe`** creates PE Authenticode **`SignedData`** from scratch for local RSA and Key Vault signing, with optional RFC3161 sign-time timestamps. Still need **ECDSA**, broader attribute knobs, non-PE remote-sign CLI routing, and full split-digest (`/dg`, `/ds`) parity. 3. **Encrypted MSIX (`EappxSip*`)** — Requires Windows encrypted-package crypto or constrained FFI; not a ZIP-only digest. 4. **VBA / `mso.dll`** — Only viable near-term via **`VBE7`** FFI or accepting permanent OS delegation. 5. **`signtool.exe` CLI backlog** — Sealing, biometric/enclave policy GUIDs, PKCS#7 product modes — see `cli-parity-backlog.md`. diff --git a/nuget/tool/Devolutions.Psign.Tool.csproj b/nuget/tool/Devolutions.Psign.Tool.csproj index 55571b6..1a2c981 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.1.0 + 0.2.0 Devolutions RID-specific dotnet tool wrapper around prebuilt psign-tool native executables. README.md diff --git a/scripts/bump-version.ps1 b/scripts/bump-version.ps1 new file mode 100644 index 0000000..f8c239f --- /dev/null +++ b/scripts/bump-version.ps1 @@ -0,0 +1,145 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Version, + + [switch]$SkipCargoLock +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") + +if ($Version.StartsWith("v")) { + $Version = $Version.Substring(1) +} + +if ($Version -notmatch '^\d+\.\d+\.\d+([-.][0-9A-Za-z.-]+)?$') { + throw "Invalid version format: $Version (expected 1.2.3 or 1.2.3-suffix)." +} + +function Set-FileText { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$Text + ) + + [System.IO.File]::WriteAllText($Path, $Text, [System.Text.UTF8Encoding]::new($false)) +} + +function Update-RequiredRegex { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$Pattern, + + [Parameter(Mandatory = $true)] + [scriptblock]$Replacement, + + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $text = [System.IO.File]::ReadAllText($Path) + $regex = [regex]::new($Pattern) + $script:replaceCount = 0 + $updated = $regex.Replace( + $text, + [System.Text.RegularExpressions.MatchEvaluator] { + param($match) + $script:replaceCount++ + & $Replacement $match + }, + 1 + ) + $count = $script:replaceCount + $script:replaceCount = 0 + + if ($count -ne 1) { + throw "Expected exactly one $Description match in $Path; found $count." + } + + if ($updated -ne $text) { + Set-FileText -Path $Path -Text $updated + Write-Host "Updated $Description in $Path" + } + else { + Write-Host "$Description already set in $Path" + } +} + +function Update-CargoPackageVersion { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + Update-RequiredRegex ` + -Path $Path ` + -Pattern '(?ms)(^\[package\].*?^version\s*=\s*")([^"]+)(")' ` + -Description "Cargo package version" ` + -Replacement { + param($match) + "$($match.Groups[1].Value)$Version$($match.Groups[3].Value)" + } +} + +$cargoManifests = @((Join-Path $repoRoot "Cargo.toml")) +$cratesRoot = Join-Path $repoRoot "crates" +if (Test-Path -LiteralPath $cratesRoot) { + $cargoManifests += Get-ChildItem -LiteralPath $cratesRoot -Directory | + ForEach-Object { Join-Path $_.FullName "Cargo.toml" } | + Where-Object { Test-Path -LiteralPath $_ } +} + +foreach ($manifest in $cargoManifests) { + Update-CargoPackageVersion -Path $manifest +} + +$toolProject = Join-Path $repoRoot "nuget\tool\Devolutions.Psign.Tool.csproj" +Update-RequiredRegex ` + -Path $toolProject ` + -Pattern '(?m)^(\s*)([^<]+)()' ` + -Description "NuGet tool fallback version" ` + -Replacement { + param($match) + "$($match.Groups[1].Value)$Version$($match.Groups[3].Value)" + } + +$readmePath = Join-Path $repoRoot "README.md" +Update-RequiredRegex ` + -Path $readmePath ` + -Pattern '(pack-psign-dotnet-tool\.ps1 -Version )\S+' ` + -Description "README pack example version" ` + -Replacement { + param($match) + "$($match.Groups[1].Value)$Version" + } + +$releaseWorkflow = Join-Path $repoRoot ".github\workflows\release.yml" +Update-RequiredRegex ` + -Path $releaseWorkflow ` + -Pattern '(description: Release version to build/publish \(for example )\d+\.\d+\.\d+([-.][0-9A-Za-z.-]+)?(\))' ` + -Description "release workflow example version" ` + -Replacement { + param($match) + "$($match.Groups[1].Value)$Version$($match.Groups[3].Value)" + } + +if (-not $SkipCargoLock) { + Push-Location $repoRoot + try { + cargo metadata --format-version 1 --quiet | Out-Null + } + finally { + Pop-Location + } + Write-Host "Refreshed Cargo.lock" +} + +Write-Host "Version bumped to $Version" diff --git a/src/bin/psign-server.rs b/src/bin/psign-server.rs index 57d531d..0c8f74d 100644 --- a/src/bin/psign-server.rs +++ b/src/bin/psign-server.rs @@ -935,7 +935,7 @@ fn handle_azure_key_vault_client( .get("value") .and_then(Value::as_str) .ok_or_else(|| anyhow!("Key Vault sign body missing value"))?; - let digest = base64::engine::general_purpose::STANDARD + let digest = base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(digest_b64.trim()) .context("decode Key Vault sign digest")?; let signature = authority.identity.sign_digest(alg, &digest)?; @@ -945,7 +945,7 @@ fn handle_azure_key_vault_client( "OK", &serde_json::json!({ "kid": format!("{}keys/{}/versions/{}", authority.base_url, authority.key_name, authority.version), - "value": base64::engine::general_purpose::STANDARD.encode(signature), + "value": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature), }), ) } diff --git a/src/portable_sign.rs b/src/portable_sign.rs index 22d8fe0..9d09919 100644 --- a/src/portable_sign.rs +++ b/src/portable_sign.rs @@ -1,9 +1,13 @@ use crate::CommandOutput; use crate::cli::{DigestAlgorithm, GlobalOpts, SignArgs}; use anyhow::{Context, Result, anyhow}; +use std::ffi::OsString; use std::path::{Path, PathBuf}; pub fn sign_file(args: &SignArgs, _global: &GlobalOpts) -> Result { + if azure_key_vault_requested(args) { + return sign_file_azure_key_vault(args); + } validate_supported_options(args)?; if args.files.is_empty() { return Err(anyhow!("portable sign requires at least one file")); @@ -39,6 +43,32 @@ pub fn sign_file(args: &SignArgs, _global: &GlobalOpts) -> Result Ok(CommandOutput::with_exit(combined, success_exit_code(args))) } +fn sign_file_azure_key_vault(args: &SignArgs) -> Result { + validate_azure_key_vault_supported_options(args)?; + if args.files.is_empty() { + return Err(anyhow!( + "portable Azure Key Vault sign requires at least one file" + )); + } + + let mut combined = String::new(); + for (idx, target) in args.files.iter().enumerate() { + if idx > 0 { + combined.push('\n'); + } + sign_one_target_azure_key_vault(target, args) + .with_context(|| format!("portable Azure Key Vault sign '{}'", target.display()))?; + combined.push_str(&format!( + "Signed: {}\nazure_key_vault_certificate={}\n", + target.display(), + args.azure_key_vault_certificate + .as_deref() + .unwrap_or("") + )); + } + Ok(CommandOutput::with_exit(combined, success_exit_code(args))) +} + fn validate_supported_options(args: &SignArgs) -> Result<()> { if args.digest != DigestAlgorithm::Sha256 { return Err(anyhow!( @@ -140,6 +170,91 @@ fn validate_supported_options(args: &SignArgs) -> Result<()> { Ok(()) } +fn validate_azure_key_vault_supported_options(args: &SignArgs) -> Result<()> { + match args.digest { + DigestAlgorithm::Sha256 | DigestAlgorithm::Sha384 | DigestAlgorithm::Sha512 => {} + DigestAlgorithm::Sha1 | DigestAlgorithm::CertHash => { + return Err(anyhow!( + "portable Azure Key Vault sign supports only --fd SHA256, SHA384, or SHA512, got {}", + args.digest.as_signtool_name() + )); + } + } + reject_path_option("--f/--pfx", &args.pfx)?; + reject_string_option("--p/--password", &args.password)?; + reject_bool_option("--a/--auto-select", args.auto_select)?; + reject_string_option("--n/--subject-name", &args.subject_name)?; + reject_string_option("--i/--issuer-name", &args.issuer_name)?; + reject_string_option("--sha1", &args.cert_sha1)?; + reject_string_option("--csp", &args.csp)?; + reject_string_option("--kc/--key-container", &args.key_container)?; + reject_bool_option("--sm/--machine-store", args.machine_store)?; + reject_option("--s/--store", args.store_name != "MY")?; + reject_path_option("--cert-store-dir", &args.cert_store_dir)?; + reject_bool_option("--as/--append-signature", args.append_signature)?; + reject_bool_option("--ph/--page-hashes", args.page_hashes)?; + reject_bool_option("--nph/--no-page-hashes", args.no_page_hashes)?; + reject_path_option("--dlib", &args.dlib)?; + reject_path_option( + "--trusted-signing-dlib-root", + &args.trusted_signing_dlib_root, + )?; + reject_path_option("--dmdf", &args.dmdf)?; + 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" + )); + } + if args.timestamp_url.is_none() && args.timestamp_digest.is_some() { + return Err(anyhow!( + "portable Azure Key Vault sign requires --tr/--timestamp-url with --td/--timestamp-digest" + )); + } + reject_string_option("--t/--legacy-timestamp-url", &args.legacy_timestamp_url)?; + reject_string_option("--tseal/--seal-timestamp-url", &args.seal_timestamp_url)?; + reject_string_option("--d/--description", &args.description)?; + reject_string_option("--du/--description-url", &args.description_url)?; + reject_string_option("--r/--root-subject-name", &args.root_subject_name)?; + reject_string_option("--u/--eku-oid", &args.eku_oid)?; + reject_bool_option( + "--uw/--eku-windows-system-component", + args.eku_windows_system_component, + )?; + reject_string_option( + "--signing-cert-eku-prefix", + &args.signing_cert_eku_oid_prefix, + )?; + reject_path_option("--dg/--digest-generate", &args.digest_generate)?; + reject_bool_option("--ds/--digest-sign-only", args.digest_sign_only)?; + reject_path_option("--di/--digest-ingest", &args.digest_ingest)?; + reject_bool_option("--dxml/--digest-xml", args.digest_xml)?; + reject_path_option("--p7/--pkcs7-output-dir", &args.pkcs7_output_dir)?; + reject_string_option("--p7co/--pkcs7-content-oid", &args.pkcs7_content_oid)?; + reject_option( + "--p7ce/--pkcs7-content-embedding", + args.pkcs7_content_embedding.is_some(), + )?; + reject_string_option("--certificate-template", &args.certificate_template)?; + reject_option("--sa/--sign-auth", !args.sign_auth_pairs.is_empty())?; + reject_bool_option("--fdchw", args.warn_fd_digest_vs_cert_signature_hash)?; + reject_bool_option("--tdchw", args.warn_td_digest_vs_cert_signature_hash)?; + reject_bool_option("--rmc", args.relaxed_pe_marker_check)?; + reject_bool_option("--seal", args.add_sealing_signature)?; + reject_bool_option("--itos", args.intent_to_seal)?; + reject_bool_option("--force", args.force_seal_or_resign)?; + reject_bool_option("--nosealwarn", args.sign_no_seal_warn)?; + reject_bool_option("--noenclavewarn", args.sign_no_enclave_warn)?; + reject_option("--rust-sip", args.rust_sip.is_some())?; + reject_path_option("--input-file-list", &args.sign_input_file_list)?; + reject_bool_option("--continue-on-error", args.continue_on_error)?; + reject_bool_option("--skip-signed", args.skip_signed)?; + reject_option( + "--max-degree-of-parallelism", + args.max_degree_parallelism.is_some(), + )?; + Ok(()) +} + fn sign_one_target( target: &Path, identity: &crate::cert_store::SigningIdentity, @@ -166,6 +281,164 @@ fn sign_one_target( ) } +fn sign_one_target_azure_key_vault(target: &Path, args: &SignArgs) -> Result<()> { + let ext = target + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + if !matches!( + ext.as_str(), + "exe" | "dll" | "sys" | "ocx" | "efi" | "winmd" + ) { + return Err(anyhow!( + "portable Azure Key Vault signing is currently implemented only for PE/WinMD targets; got {}", + target.display() + )); + } + + let tmp = temporary_output_path(target); + let result = run_portable_sign_pe_azure_key_vault(target, &tmp, args) + .and_then(|_| { + std::fs::copy(&tmp, target) + .with_context(|| format!("replace '{}' with signed output", target.display()))?; + Ok(()) + }) + .and_then(|_| { + std::fs::remove_file(&tmp) + .with_context(|| format!("remove temporary output '{}'", tmp.display())) + }); + if result.is_err() { + let _ = std::fs::remove_file(&tmp); + } + result +} + +fn run_portable_sign_pe_azure_key_vault( + target: &Path, + output: &Path, + args: &SignArgs, +) -> Result<()> { + let mut argv = Vec::new(); + argv.push(OsString::from("psign-tool")); + argv.push(OsString::from("sign-pe")); + argv.push(target.as_os_str().to_os_string()); + argv.push(OsString::from("--digest")); + argv.push(OsString::from(portable_digest_name(args.digest)?)); + for chain_cert in &args.additional_certs { + argv.push(OsString::from("--chain-cert")); + argv.push(chain_cert.as_os_str().to_os_string()); + } + push_option(&mut argv, "--timestamp-url", &args.timestamp_url); + if let Some(timestamp_digest) = args.timestamp_digest { + argv.push(OsString::from("--timestamp-digest")); + argv.push(OsString::from(timestamp_digest_name(timestamp_digest)?)); + } + push_option( + &mut argv, + "--azure-key-vault-url", + &args.azure_key_vault_url, + ); + push_option( + &mut argv, + "--azure-key-vault-certificate", + &args.azure_key_vault_certificate, + ); + push_option( + &mut argv, + "--azure-key-vault-certificate-version", + &args.azure_key_vault_certificate_version, + ); + push_option( + &mut argv, + "--azure-key-vault-accesstoken", + &args.azure_key_vault_access_token, + ); + if args.azure_key_vault_managed_identity { + argv.push(OsString::from("--azure-key-vault-managed-identity")); + } + push_option( + &mut argv, + "--azure-key-vault-tenant-id", + &args.azure_key_vault_tenant_id, + ); + push_option( + &mut argv, + "--azure-key-vault-client-id", + &args.azure_key_vault_client_id, + ); + push_option( + &mut argv, + "--azure-key-vault-client-secret", + &args.azure_key_vault_client_secret, + ); + push_option(&mut argv, "--azure-authority", &args.azure_authority); + argv.push(OsString::from("--output")); + argv.push(output.as_os_str().to_os_string()); + + std::thread::Builder::new() + .name("psign-portable-sign-pe".to_string()) + .stack_size(8 * 1024 * 1024) + .spawn(move || psign_digest_cli::run_from(argv)) + .map_err(|e| anyhow!("spawn portable sign-pe runner: {e}"))? + .join() + .map_err(|_| anyhow!("portable sign-pe runner panicked"))? +} + +fn portable_digest_name(digest: DigestAlgorithm) -> Result<&'static str> { + match digest { + DigestAlgorithm::Sha256 => Ok("sha256"), + DigestAlgorithm::Sha384 => Ok("sha384"), + DigestAlgorithm::Sha512 => Ok("sha512"), + DigestAlgorithm::Sha1 | DigestAlgorithm::CertHash => Err(anyhow!( + "portable Azure Key Vault sign supports only SHA-2 file digests" + )), + } +} + +fn timestamp_digest_name(digest: DigestAlgorithm) -> Result<&'static str> { + match digest { + DigestAlgorithm::Sha1 => Ok("sha1"), + DigestAlgorithm::Sha256 => Ok("sha256"), + DigestAlgorithm::Sha384 => Ok("sha384"), + DigestAlgorithm::Sha512 => Ok("sha512"), + DigestAlgorithm::CertHash => Err(anyhow!( + "portable Azure Key Vault timestamping supports only explicit hash algorithms" + )), + } +} + +fn push_option(argv: &mut Vec, name: &str, value: &Option) { + if let Some(value) = value.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + argv.push(OsString::from(name)); + argv.push(OsString::from(value)); + } +} + +fn temporary_output_path(target: &Path) -> PathBuf { + let file_name = target + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("signed-output"); + target.with_file_name(format!("{}.psign-{}.tmp", file_name, std::process::id())) +} + +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 + || text_present(&args.azure_authority) +} + +fn text_present(value: &Option) -> bool { + value.as_deref().is_some_and(|s| !s.trim().is_empty()) +} + fn success_exit_code(args: &SignArgs) -> i32 { match args.exit_codes { Some(crate::cli::SignExitCodes::Azuresigntool) => 0, @@ -176,7 +449,7 @@ fn success_exit_code(args: &SignArgs) -> i32 { fn reject_option(name: &str, present: bool) -> Result<()> { if present { return Err(anyhow!( - "portable sign does not support {name}; supported subset is --sha1, --store/--s, --machine-store/--sm, --cert-store-dir, --fd SHA256, and PE/WinMD file paths" + "portable sign does not support {name}; supported subsets are local store PE/WinMD signing (--sha1, --store/--s, --machine-store/--sm, --cert-store-dir, --fd SHA256) and Azure Key Vault PE/WinMD signing (--azure-key-vault-*, --fd SHA256/SHA384/SHA512)" )); } Ok(()) diff --git a/src/win/azure_kv_sign.rs b/src/win/azure_kv_sign.rs index f3210df..e301095 100644 --- a/src/win/azure_kv_sign.rs +++ b/src/win/azure_kv_sign.rs @@ -3,7 +3,7 @@ use crate::cli::{DigestAlgorithm, GlobalOpts, SignArgs}; use crate::win::sealing::validate_sign_constraints_paths; use crate::win::sign_core::{ - adopt_cert, authenticode_sign_embedded, encoding, infer_digest_for_cert, + DigestSignInfoParam, adopt_cert, authenticode_sign_embedded, encoding, infer_digest_for_cert, merge_additional_cert_file, open_memory_cert_store, validate_cert_constraints, }; use anyhow::{Result, anyhow}; @@ -12,22 +12,19 @@ use psign_azure_kv_rest::{ kv_decode_cer_b64, kv_jws_alg, kv_public_key_kind_from_cer_der, kv_sign_digest, kv_sign_url_from_kid, }; -use std::cell::RefCell; use std::mem::size_of; +use std::sync::{Mutex, OnceLock}; use windows::Win32::Foundation::{E_FAIL, S_OK}; use windows::Win32::Security::Cryptography::ALG_ID; use windows::Win32::Security::Cryptography::{ CALG_SHA_256, CALG_SHA_384, CALG_SHA_512, CERT_CONTEXT, CERT_STORE_ADD_REPLACE_EXISTING, CRYPT_INTEGER_BLOB, CertAddCertificateContextToStore, CertCreateCertificateContext, - CertFreeCertificateContext, SIGNER_DIGEST_SIGN_INFO, SIGNER_DIGEST_SIGN_INFO_0, + CertFreeCertificateContext, PFN_AUTHENTICODE_DIGEST_SIGN, }; -use windows::Win32::System::Memory::{LMEM_FIXED, LocalAlloc}; +use windows::Win32::System::Memory::{GetProcessHeap, HEAP_FLAGS, HeapAlloc}; use windows::core::HRESULT; -thread_local! { - static KV_HTTP: RefCell> = const { RefCell::new(None) }; -} - +#[derive(Clone)] struct KvCallbackState { client: reqwest::blocking::Client, token: String, @@ -35,6 +32,18 @@ struct KvCallbackState { key_kind: KvPublicKeyKind, } +fn kv_callback_state() -> &'static Mutex> { + static STATE: OnceLock>> = OnceLock::new(); + STATE.get_or_init(|| Mutex::new(None)) +} + +#[repr(C)] +struct SignerDigestSignInfoV1 { + cb_size: u32, + pfn_authenticode_digest_sign: PFN_AUTHENTICODE_DIGEST_SIGN, + p_metadata_blob: *mut CRYPT_INTEGER_BLOB, +} + fn auth_params_from_sign_args(args: &SignArgs) -> KvAuthParams<'_> { KvAuthParams { access_token: args.azure_key_vault_access_token.as_deref(), @@ -60,6 +69,49 @@ fn algid_to_kv_hash(algid: ALG_ID) -> Result { } } +fn sign_digest_with_key_vault(algid_hash: ALG_ID, digest: &[u8]) -> Result> { + let state = { + let guard = kv_callback_state() + .lock() + .map_err(|_| anyhow!("Azure Key Vault signing callback state mutex was poisoned"))?; + guard + .as_ref() + .cloned() + .ok_or_else(|| anyhow!("Azure Key Vault signing callback state was not installed"))? + }; + let hash = algid_to_kv_hash(algid_hash)?; + let alg = kv_jws_alg(state.key_kind, hash)?; + kv_sign_digest( + &state.client, + &state.token, + &state.sign_url, + alg.as_str(), + digest, + ) +} + +unsafe fn write_heap_signature_blob( + sig: &[u8], + p_signature_blob: *mut CRYPT_INTEGER_BLOB, +) -> HRESULT { + if p_signature_blob.is_null() || sig.is_empty() { + return E_FAIL; + } + let Ok(heap) = (unsafe { GetProcessHeap() }) else { + return E_FAIL; + }; + let raw = unsafe { HeapAlloc(heap, HEAP_FLAGS(0), sig.len()) }; + if raw.is_null() { + return E_FAIL; + } + unsafe { + std::ptr::copy_nonoverlapping(sig.as_ptr(), raw as *mut u8, sig.len()); + (*p_signature_blob).cbData = sig.len() as u32; + (*p_signature_blob).pbData = raw as *mut u8; + } + S_OK +} + unsafe extern "system" fn azure_kv_digest_callback( _p_cert: *const CERT_CONTEXT, _p_metadata: *const CRYPT_INTEGER_BLOB, @@ -73,49 +125,16 @@ unsafe extern "system" fn azure_kv_digest_callback( } let digest = unsafe { std::slice::from_raw_parts(pb_digest, cb_digest as usize) }; - let outcome = KV_HTTP.with(|slot| { - let borrowed = slot.borrow(); - let Some(state) = borrowed.as_ref() else { - return Err(anyhow!( - "Azure KV signing thread-local state was not installed" - )); - }; - let hash = match algid_to_kv_hash(algid_hash) { - Ok(h) => h, - Err(_) => return Err(anyhow!("unsupported digest / key kind for KV")), - }; - let alg = match kv_jws_alg(state.key_kind, hash) { - Ok(a) => a, - Err(_) => return Err(anyhow!("unsupported digest / key kind for KV")), - }; - kv_sign_digest( - &state.client, - &state.token, - &state.sign_url, - alg.as_str(), - digest, - ) - }); + let outcome = sign_digest_with_key_vault(algid_hash, digest); let sig = match outcome { Ok(b) => b, - Err(_) => return E_FAIL, - }; - - // SAFETY: `LocalAlloc` matches native Authenticode expectations for out-of-band digest callbacks. - let raw = match unsafe { LocalAlloc(LMEM_FIXED, sig.len()) } { - Ok(h) => h, - Err(_) => return E_FAIL, + Err(e) => { + eprintln!("Azure Key Vault digest callback failed: {e:#}"); + return E_FAIL; + } }; - if raw.is_invalid() { - return E_FAIL; - } - unsafe { - std::ptr::copy_nonoverlapping(sig.as_ptr(), raw.0 as *mut u8, sig.len()); - (*p_signature_blob).cbData = sig.len() as u32; - (*p_signature_blob).pbData = raw.0 as *mut u8; - } - S_OK + unsafe { write_heap_signature_blob(&sig, p_signature_blob) } } pub(crate) fn validate_azure_kv_mutex(args: &SignArgs) -> Result<()> { @@ -299,31 +318,24 @@ pub(crate) fn sign_with_azure_key_vault( other => other, }; - let mut empty_blob = CRYPT_INTEGER_BLOB { - cbData: 0, - pbData: std::ptr::null_mut(), + let digest_info = SignerDigestSignInfoV1 { + cb_size: size_of::() as u32, + pfn_authenticode_digest_sign: Some(azure_kv_digest_callback), + p_metadata_blob: std::ptr::null_mut(), }; - let mut anon = SIGNER_DIGEST_SIGN_INFO_0::default(); - anon.pfnAuthenticodeDigestSign = Some(azure_kv_digest_callback); - let digest_info = SIGNER_DIGEST_SIGN_INFO { - cbSize: size_of::() as u32, - dwDigestSignChoice: 1, - Anonymous: anon, - pMetadataBlob: std::ptr::addr_of_mut!(empty_blob), - dwReserved: 0, - dwReserved2: 0, - dwReserved3: 0, - }; - let digest_ptr = std::ptr::addr_of!(digest_info); + let digest_param = DigestSignInfoParam::basic(std::ptr::addr_of!(digest_info)); - KV_HTTP.with(|slot| { - *slot.borrow_mut() = Some(KvCallbackState { + { + let mut state = kv_callback_state() + .lock() + .map_err(|_| anyhow!("Azure Key Vault signing callback state mutex was poisoned"))?; + *state = Some(KvCallbackState { client: http, token, sign_url, key_kind, }); - }); + } let report = authenticode_sign_embedded( args, @@ -333,16 +345,16 @@ pub(crate) fn sign_with_azure_key_vault( &signing, resolved_digest, None, - Some(digest_ptr), + Some(digest_param), "azure-key-vault", "MEMORY", None, None, ); - KV_HTTP.with(|slot| { - slot.borrow_mut().take(); - }); + if let Ok(mut state) = kv_callback_state().lock() { + state.take(); + } report } diff --git a/src/win/sign_core.rs b/src/win/sign_core.rs index 303fa85..36fa059 100644 --- a/src/win/sign_core.rs +++ b/src/win/sign_core.rs @@ -237,6 +237,35 @@ fn digest_oid(d: DigestAlgorithm) -> &'static str { } } +const SPC_DIGEST_SIGN_FLAG: SIGNER_SIGN_FLAGS = SIGNER_SIGN_FLAGS(0x400); +const SPC_DIGEST_SIGN_EX_FLAG: SIGNER_SIGN_FLAGS = SIGNER_SIGN_FLAGS(0x4000); + +pub(crate) struct DigestSignInfoParam { + ptr: *const SIGNER_DIGEST_SIGN_INFO, + flags: SIGNER_SIGN_FLAGS, +} + +impl DigestSignInfoParam { + pub(crate) fn basic(info: *const T) -> Self { + Self { + ptr: info.cast(), + flags: SPC_DIGEST_SIGN_FLAG, + } + } + + fn from_windows_digest_info(info: &SIGNER_DIGEST_SIGN_INFO) -> Self { + let flags = if info.dwDigestSignChoice >= 3 { + SPC_DIGEST_SIGN_EX_FLAG + } else { + SPC_DIGEST_SIGN_FLAG + }; + Self { + ptr: std::ptr::addr_of!(*info), + flags, + } + } +} + /// Path to `Azure.CodeSigning.Dlib.dll` under an extracted **Microsoft.ArtifactSigning.Client**-style layout. pub(crate) fn artifact_signing_dlib_path(root: &std::path::Path) -> std::path::PathBuf { let arch = if cfg!(target_pointer_width = "64") { @@ -856,7 +885,7 @@ pub(crate) fn authenticode_sign_embedded( cert: &CertContext, resolved_digest: DigestAlgorithm, provider_ptr: Option<*const SIGNER_PROVIDER_INFO>, - digest_ptr: Option<*const SIGNER_DIGEST_SIGN_INFO>, + digest_info: Option, mode_report: &'static str, store_report_name: &str, free_library_target: Option, @@ -950,6 +979,18 @@ pub(crate) fn authenticode_sign_embedded( if args.append_signature { flags |= SIG_APPEND; } + let digest_ptr = digest_info.map(|d| { + flags |= d.flags; + d.ptr + }); + if let Some(digest_ptr) = digest_ptr + && global.debug + { + eprintln!( + "[psign debug] SignerSignEx3 digest sign flags=0x{:x} ptr={digest_ptr:p}", + flags.0 + ); + } let mut signer_context: *mut SIGNER_CONTEXT = std::ptr::null_mut(); @@ -1208,9 +1249,9 @@ pub fn sign_with_mssign32( .ok_or_else(|| anyhow!("internal error: decoupled mode without --dmdf"))?; decoupled_runtime = Some(load_decoupled_digest_info(&dlib, dmdf)?); } - let digest_ptr = decoupled_runtime - .as_ref() - .map(|(_, digest_info, _, _, _)| digest_info as *const SIGNER_DIGEST_SIGN_INFO); + let digest_param = decoupled_runtime.as_ref().map(|(_, digest_info, _, _, _)| { + DigestSignInfoParam::from_windows_digest_info(digest_info) + }); let free_library_target = decoupled_runtime.as_ref().map(|(m, _, _, _, _)| *m); let decoupled_report = decoupled_runtime .as_ref() @@ -1224,7 +1265,7 @@ pub fn sign_with_mssign32( &cert, resolved_digest, provider_ptr, - digest_ptr, + digest_param, if decoupled { "decoupled-rust-core" } else { diff --git a/tests/cli_pe_digest.rs b/tests/cli_pe_digest.rs index 3eb4d1a..3b546a9 100644 --- a/tests/cli_pe_digest.rs +++ b/tests/cli_pe_digest.rs @@ -2987,6 +2987,136 @@ fn psign_server_azure_key_vault_signs_digest_for_portable_cli() { ); } +#[cfg(all(feature = "timestamp-server", feature = "azure-kv-sign"))] +#[test] +fn psign_server_azure_key_vault_signs_pe_with_portable_cli() { + let dir = tempfile::tempdir().unwrap(); + let out_pe = dir.path().join("tiny32.kv-portable-signed.exe"); + + let (mut guard, url, certificate) = spawn_psign_azure_key_vault_server(2); + let mut cmd = portable_cmd(); + cmd.arg("sign-pe") + .arg(tiny32_unsigned_fixture()) + .arg("--azure-key-vault-url") + .arg(&url) + .arg("--azure-key-vault-certificate") + .arg(&certificate) + .arg("--azure-key-vault-accesstoken") + .arg("test-token") + .arg("--output") + .arg(&out_pe); + cmd.assert() + .success() + .stdout(predicate::str::contains("sign-pe: ok")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut verify = portable_cmd(); + verify.arg("verify-pe").arg(&out_pe); + verify.assert().success(); + + let signed = std::fs::read(&out_pe).expect("read signed PE"); + let pkcs7 = verify_pe::pe_nth_pkcs7_signed_data_der(&signed, 0).expect("extract PKCS#7"); + let sd = pkcs7::parse_pkcs7_signed_data_der(&pkcs7).expect("parse SignedData"); + let indirect = pkcs7::signed_data_spc_indirect_message_digest_octets(&sd).expect("indirect"); + pkcs7::verify_signed_data_authenticode_indirect_digest_and_rsa_sha256_pkcs1v15_signature( + &sd, 0, &indirect, + ) + .expect("portable Azure Key Vault Authenticode RSA signature verifies"); +} + +#[cfg(all(feature = "timestamp-server", feature = "azure-kv-sign"))] +#[test] +fn mode_portable_sign_uses_azure_key_vault_for_pe() { + let dir = tempfile::tempdir().unwrap(); + let pe_path = dir.path().join("tiny32.kv-mode-portable-signed.exe"); + std::fs::copy(tiny32_unsigned_fixture(), &pe_path).expect("copy unsigned PE"); + + let (mut guard, url, certificate) = spawn_psign_azure_key_vault_server(2); + let mut cmd = Command::cargo_bin("psign-tool").unwrap(); + cmd.arg("--mode") + .arg("portable") + .arg("sign") + .arg("--digest") + .arg("sha256") + .arg("--azure-key-vault-url") + .arg(&url) + .arg("--azure-key-vault-certificate") + .arg(&certificate) + .arg("--azure-key-vault-accesstoken") + .arg("test-token") + .arg(&pe_path); + cmd.assert() + .success() + .stdout(predicate::str::contains("Signed:")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut verify = portable_cmd(); + verify.arg("verify-pe").arg(&pe_path); + verify.assert().success(); +} + +#[cfg(all( + feature = "timestamp-server", + feature = "timestamp-http", + feature = "azure-kv-sign" +))] +#[test] +fn mode_portable_sign_uses_azure_key_vault_and_rfc3161_timestamp_for_pe() { + let dir = tempfile::tempdir().unwrap(); + let pe_path = dir.path().join("tiny32.kv-mode-portable-timestamped.exe"); + std::fs::copy(tiny32_unsigned_fixture(), &pe_path).expect("copy unsigned PE"); + + let (mut guard, url, certificate) = spawn_psign_azure_key_vault_server(2); + let (mut timestamp_guard, timestamp_url) = spawn_psign_server(&[]); + let mut cmd = Command::cargo_bin("psign-tool").unwrap(); + cmd.arg("--mode") + .arg("portable") + .arg("sign") + .arg("--digest") + .arg("sha256") + .arg("--timestamp-url") + .arg(×tamp_url) + .arg("--timestamp-digest") + .arg("sha256") + .arg("--azure-key-vault-url") + .arg(&url) + .arg("--azure-key-vault-certificate") + .arg(&certificate) + .arg("--azure-key-vault-accesstoken") + .arg("test-token") + .arg(&pe_path); + cmd.assert() + .success() + .stdout(predicate::str::contains("Signed:")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + let timestamp_status = timestamp_guard.0.wait().expect("timestamp server exit"); + assert!( + timestamp_status.success(), + "timestamp server failed with {timestamp_status}" + ); + + let mut verify = portable_cmd(); + verify.arg("verify-pe").arg(&pe_path); + verify.assert().success(); + + let mut inspect = portable_cmd(); + inspect + .arg("inspect-authenticode") + .arg(&pe_path) + .arg("--input") + .arg("pe"); + 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")); +} + #[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] #[test] fn psign_server_artifact_signing_submit_serves_portable_cli() { @@ -3031,19 +3161,28 @@ fn psign_server_artifact_signing_submit_serves_portable_cli() { assert_eq!(sig.len(), 256, "RSA-2048 signature length"); } -#[cfg(all(windows, feature = "timestamp-server", feature = "azure-kv-sign"))] +#[cfg(all( + windows, + feature = "timestamp-server", + feature = "timestamp-http", + feature = "azure-kv-sign" +))] #[test] -#[ignore = "current Azure KV SignerSignEx3 path requires a local provider binding before full PE signing can be automated"] fn psign_server_azure_key_vault_signs_pe_with_windows_cli() { let dir = tempfile::tempdir().unwrap(); let pe_path = dir.path().join("tiny32.kv-signed.exe"); std::fs::copy(tiny32_unsigned_fixture(), &pe_path).expect("copy unsigned PE"); let (mut guard, url, certificate) = spawn_psign_azure_key_vault_server(2); + let (mut timestamp_guard, timestamp_url) = spawn_psign_server(&[]); let mut cmd = Command::cargo_bin("psign-tool").unwrap(); cmd.arg("sign") .arg("--digest") .arg("sha256") + .arg("--timestamp-url") + .arg(×tamp_url) + .arg("--timestamp-digest") + .arg("sha256") .arg("--azure-key-vault-url") .arg(&url) .arg("--azure-key-vault-certificate") @@ -3054,6 +3193,11 @@ fn psign_server_azure_key_vault_signs_pe_with_windows_cli() { cmd.assert().success(); let status = guard.0.wait().expect("server exit"); assert!(status.success(), "server failed with {status}"); + let timestamp_status = timestamp_guard.0.wait().expect("timestamp server exit"); + assert!( + timestamp_status.success(), + "timestamp server failed with {timestamp_status}" + ); let mut verify = portable_cmd(); verify.arg("verify-pe").arg(&pe_path);