From c444a30869f2c9d7652f56b57ef04a29e7029779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Tue, 26 May 2026 10:49:27 -0400 Subject: [PATCH 1/2] Add portable file catalog cmdlets Add fully portable PowerShell cmdlets for creating and testing file catalogs, backed by psign portable Rust APIs and FFI. Bump package metadata to 0.5.0 for the release. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- Cargo.lock | 18 +- Cargo.toml | 2 +- .../Devolutions.Psign/Devolutions.Psign.psd1 | 4 +- .../Devolutions.Psign/Devolutions.Psign.psm1 | 7 + .../tests/PsignFileCatalog.Cmdlets.Tests.ps1 | 91 +++ README.md | 2 +- crates/psign-authenticode-trust/Cargo.toml | 2 +- crates/psign-azure-kv-rest/Cargo.toml | 2 +- crates/psign-codesigning-rest/Cargo.toml | 2 +- crates/psign-digest-cli/Cargo.toml | 2 +- crates/psign-opc-sign/Cargo.toml | 2 +- crates/psign-portable-core/Cargo.toml | 2 +- crates/psign-portable-core/src/lib.rs | 647 +++++++++++++++++- crates/psign-portable-ffi/Cargo.toml | 2 +- crates/psign-portable-ffi/src/lib.rs | 82 ++- crates/psign-sip-digest/Cargo.toml | 2 +- crates/psign-sip-digest/src/catalog_digest.rs | 172 ++++- crates/psign-sip-digest/src/pkcs7.rs | 31 + .../Cmdlets/NewPsignFileCatalogCommand.cs | 63 ++ .../Cmdlets/TestPsignFileCatalogCommand.cs | 122 ++++ .../Models/PortableCatalog.cs | 92 +++ .../Models/PortableRequests.cs | 54 ++ .../Native/PsignNative.cs | 16 + nuget/tool/Devolutions.Psign.Tool.csproj | 2 +- 25 files changed, 1363 insertions(+), 60 deletions(-) create mode 100644 PowerShell/tests/PsignFileCatalog.Cmdlets.Tests.ps1 create mode 100644 dotnet/Devolutions.Psign.PowerShell/Cmdlets/NewPsignFileCatalogCommand.cs create mode 100644 dotnet/Devolutions.Psign.PowerShell/Cmdlets/TestPsignFileCatalogCommand.cs create mode 100644 dotnet/Devolutions.Psign.PowerShell/Models/PortableCatalog.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aca4d72..5503ba4 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.3.0) + description: Release version to build/publish (for example 0.5.0) required: true type: string publish_nuget: diff --git a/Cargo.lock b/Cargo.lock index 43a3a20..2fe9150 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2001,7 +2001,7 @@ dependencies = [ [[package]] name = "psign" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "assert_cmd", @@ -2038,7 +2038,7 @@ dependencies = [ [[package]] name = "psign-authenticode-trust" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "authenticode", @@ -2062,7 +2062,7 @@ dependencies = [ [[package]] name = "psign-azure-kv-rest" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "base64", @@ -2077,7 +2077,7 @@ dependencies = [ [[package]] name = "psign-codesigning-rest" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "base64", @@ -2089,7 +2089,7 @@ dependencies = [ [[package]] name = "psign-digest-cli" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "assert_cmd", @@ -2115,7 +2115,7 @@ dependencies = [ [[package]] name = "psign-opc-sign" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "base64", @@ -2125,7 +2125,7 @@ dependencies = [ [[package]] name = "psign-portable-core" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "authenticode", @@ -2149,7 +2149,7 @@ dependencies = [ [[package]] name = "psign-portable-ffi" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "psign-portable-core", @@ -2159,7 +2159,7 @@ dependencies = [ [[package]] name = "psign-sip-digest" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "authenticode", diff --git a/Cargo.toml b/Cargo.toml index 12e69a4..c1ad27b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ repository = "https://github.com/Devolutions/psign" [package] name = "psign" -version = "0.4.0" +version = "0.5.0" edition = "2024" description = "Rust port of the Windows SDK signtool.exe (Authenticode sign/verify/timestamp) with portable digest helpers." license.workspace = true diff --git a/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 b/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 index b16b3ef..5d840be 100644 --- a/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 +++ b/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'Devolutions.Psign.psm1' - ModuleVersion = '0.4.0' + ModuleVersion = '0.5.0' GUID = 'e6e50e4b-bf25-4ed6-a343-49f904e79f8f' Author = 'Devolutions' CompanyName = 'Devolutions' @@ -12,7 +12,9 @@ FormatsToProcess = @('Devolutions.Psign.Format.ps1xml') CmdletsToExport = @( 'Get-PsignSignature', + 'New-PsignFileCatalog', 'Set-PsignSignature', + 'Test-PsignFileCatalog', 'Test-PsignModule', 'Protect-PsignModule', 'Unprotect-PsignSignature' diff --git a/PowerShell/Devolutions.Psign/Devolutions.Psign.psm1 b/PowerShell/Devolutions.Psign/Devolutions.Psign.psm1 index 9fc4379..b5da8f1 100644 --- a/PowerShell/Devolutions.Psign/Devolutions.Psign.psm1 +++ b/PowerShell/Devolutions.Psign/Devolutions.Psign.psm1 @@ -69,3 +69,10 @@ Register-ArgumentCompleter -CommandName Test-PsignModule -ParameterName Policy - [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } } + +Register-ArgumentCompleter -CommandName New-PsignFileCatalog -ParameterName CatalogVersion -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + @('1', '2') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } +} diff --git a/PowerShell/tests/PsignFileCatalog.Cmdlets.Tests.ps1 b/PowerShell/tests/PsignFileCatalog.Cmdlets.Tests.ps1 new file mode 100644 index 0000000..9e6328b --- /dev/null +++ b/PowerShell/tests/PsignFileCatalog.Cmdlets.Tests.ps1 @@ -0,0 +1,91 @@ +Set-StrictMode -Version Latest + +function script:Ensure-PsignModule { + Remove-Module Devolutions.Psign -Force -ErrorAction SilentlyContinue + $repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $modulePath = Join-Path (Join-Path $repoRoot 'PowerShell\Devolutions.Psign') 'Devolutions.Psign.psd1' + Import-Module $modulePath -Force +} + +Describe 'Portable file catalog cmdlets' { + BeforeAll { + Ensure-PsignModule + $script:TempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "psign-catalog-$([System.Guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Force -Path $script:TempRoot | Out-Null + } + + AfterAll { + if ($script:TempRoot) { + Remove-Item -LiteralPath $script:TempRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + BeforeEach { + $script:CaseRoot = Join-Path $script:TempRoot ([System.Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Force -Path $script:CaseRoot | Out-Null + } + + It 'creates and validates a recursive SHA256 catalog' { + New-Item -ItemType Directory -Force -Path (Join-Path $script:CaseRoot 'sub') | Out-Null + Set-Content -LiteralPath (Join-Path $script:CaseRoot 'a.txt') -Value 'alpha' -NoNewline -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $script:CaseRoot 'sub\b.txt') -Value 'bravo' -NoNewline -Encoding UTF8 + $catalogPath = Join-Path $script:CaseRoot 'catalog.cat' + + $created = New-PsignFileCatalog -Path $script:CaseRoot -CatalogFilePath $catalogPath + $created | Should -BeOfType ([System.IO.FileInfo]) + $created.Exists | Should -BeTrue + + Test-PsignFileCatalog -CatalogFilePath $catalogPath -Path $script:CaseRoot -SkipTrust | Should -Be 'Valid' + + $detailed = Test-PsignFileCatalog -CatalogFilePath $catalogPath -Path $script:CaseRoot -SkipTrust -Detailed + $detailed.HashAlgorithm | Should -Be 'SHA256' + $detailed.CatalogItems.Path | Should -Contain 'a.txt' + $detailed.CatalogItems.Path | Should -Contain 'sub/b.txt' + $detailed.PathItems.Status | Should -Not -Contain 'HashMismatch' + $detailed.Signature.Status | Should -Be 'NotSigned' + } + + It 'reports tampered files and honors FilesToSkip' { + Set-Content -LiteralPath (Join-Path $script:CaseRoot 'a.txt') -Value 'alpha' -NoNewline -Encoding UTF8 + $catalogPath = Join-Path $script:CaseRoot 'catalog.cat' + New-PsignFileCatalog -Path $script:CaseRoot -CatalogFilePath $catalogPath | Out-Null + Set-Content -LiteralPath (Join-Path $script:CaseRoot 'a.txt') -Value 'tampered' -NoNewline -Encoding UTF8 + + Test-PsignFileCatalog -CatalogFilePath $catalogPath -Path $script:CaseRoot -SkipTrust | Should -Be 'ValidationFailed' + $detailed = Test-PsignFileCatalog -CatalogFilePath $catalogPath -Path $script:CaseRoot -SkipTrust -Detailed + ($detailed.PathItems | Where-Object Path -EQ 'a.txt').Status | Should -Be 'HashMismatch' + + Test-PsignFileCatalog -CatalogFilePath $catalogPath -Path $script:CaseRoot -SkipTrust -FilesToSkip 'a.txt' | Should -Be 'Valid' + } + + It 'rejects duplicate filenames for multiple unrelated paths' { + $left = Join-Path $script:CaseRoot 'left' + $right = Join-Path $script:CaseRoot 'right' + New-Item -ItemType Directory -Force -Path $left, $right | Out-Null + Set-Content -LiteralPath (Join-Path $left 'same.txt') -Value 'left' -NoNewline -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $right 'same.txt') -Value 'right' -NoNewline -Encoding UTF8 + + { New-PsignFileCatalog -Path $left, $right -CatalogFilePath (Join-Path $script:CaseRoot 'catalog.cat') -ErrorAction Stop } | + Should -Throw -ExpectedMessage '*duplicate subject file name*' + } + + It 'supports WhatIf without creating a catalog' { + Set-Content -LiteralPath (Join-Path $script:CaseRoot 'a.txt') -Value 'alpha' -NoNewline -Encoding UTF8 + $catalogPath = Join-Path $script:CaseRoot 'catalog.cat' + + New-PsignFileCatalog -Path $script:CaseRoot -CatalogFilePath $catalogPath -WhatIf + + Test-Path -LiteralPath $catalogPath | Should -BeFalse + } + + It 'reports the embedded signature status for signed catalog fixtures' { + $repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $catalogPath = Join-Path $repoRoot 'tests\fixtures\generated-signed\catalog\sample.cat' + $memberPath = Join-Path $repoRoot 'tests\fixtures\generated-unsigned\catalog\member.sys' + + $detailed = Test-PsignFileCatalog -CatalogFilePath $catalogPath -Path $memberPath -SkipTrust -Detailed + + $detailed.Signature.Status | Should -Be 'Valid' + $detailed.Signature.SignatureType | Should -Be ([System.Management.Automation.SignatureType]::Catalog) + } +} diff --git a/README.md b/README.md index e2060d2..c8e79a1 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ dotnet tool run psign-tool -- --help Create local dotnet tool packages from prebuilt release artifacts: ```powershell -pwsh ./nuget/pack-psign-dotnet-tool.ps1 -Version 0.3.0 -ArtifactsRoot ./dist -OutputDir ./dist/nuget +pwsh ./nuget/pack-psign-dotnet-tool.ps1 -Version 0.5.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 36b51fc..eece0b6 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.4.0" +version = "0.5.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 240a604..a53c847 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.4.0" +version = "0.5.0" edition = "2024" description = "Azure Key Vault certificate metadata + keys/sign REST (portable, blocking HTTP)" license.workspace = true diff --git a/crates/psign-codesigning-rest/Cargo.toml b/crates/psign-codesigning-rest/Cargo.toml index 5486974..28df401 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.4.0" +version = "0.5.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 896332b..a0a22b3 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.4.0" +version = "0.5.0" edition = "2024" description = "Linux/macOS-friendly CLI over portable Authenticode SIP digests (psign-sip-digest)" license.workspace = true diff --git a/crates/psign-opc-sign/Cargo.toml b/crates/psign-opc-sign/Cargo.toml index 1aed67e..e70b68e 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.4.0" +version = "0.5.0" edition = "2024" description = "Portable OPC, VSIX, and NuGet package signing primitives" license.workspace = true diff --git a/crates/psign-portable-core/Cargo.toml b/crates/psign-portable-core/Cargo.toml index d855d2f..0b2ba66 100644 --- a/crates/psign-portable-core/Cargo.toml +++ b/crates/psign-portable-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-portable-core" -version = "0.4.0" +version = "0.5.0" edition = "2024" description = "Reusable portable Authenticode signing and inspection APIs for psign" license.workspace = true diff --git a/crates/psign-portable-core/src/lib.rs b/crates/psign-portable-core/src/lib.rs index 51e74ca..5c6be20 100644 --- a/crates/psign-portable-core/src/lib.rs +++ b/crates/psign-portable-core/src/lib.rs @@ -7,6 +7,7 @@ use anyhow::{Context, Result, bail}; use authenticode::SpcIndirectDataContent; use base64::Engine as _; use der::Encode as _; +use der::asn1::ObjectIdentifier; use picky::key::PrivateKey; use picky::pkcs12::{ Pfx, Pkcs12CryptoContext, Pkcs12ParsingParams, SafeBag, SafeBagKind, SafeContentsKind, @@ -24,8 +25,8 @@ use psign_sip_digest::verify_pe::{ pe_nth_pkcs7_signed_data_der, verify_pe_authenticode_digest_consistency, }; use psign_sip_digest::{ - cab_digest, msi_digest, msix_digest, pe_digest, pe_embed, pkcs7, ps_script, rdp, timestamp, - verify_script_digest_consistency, zip_authenticode, + cab_digest, catalog_digest, msi_digest, msix_digest, pe_digest, pe_embed, pkcs7, ps_script, + rdp, timestamp, verify_script_digest_consistency, zip_authenticode, }; use serde::{Deserialize, Serialize}; use sha2::Digest as _; @@ -92,6 +93,23 @@ pub enum PortableRevocationMode { Require, } +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum PortableCatalogValidationStatus { + Valid, + ValidationFailed, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum PortableCatalogItemStatus { + Valid, + Missing, + HashMismatch, + NotInCatalog, + Skipped, +} + impl From for RevocationMode { fn from(value: PortableRevocationMode) -> Self { match value { @@ -155,6 +173,62 @@ impl PortableGetSignatureRequest { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortableNewFileCatalogRequest { + pub catalog_file_path: PathBuf, + #[serde(default)] + pub paths: Vec, + #[serde(default)] + pub catalog_version: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortableTestFileCatalogRequest { + pub catalog_file_path: PathBuf, + #[serde(default)] + pub paths: Vec, + #[serde(default)] + pub files_to_skip: Vec, + #[serde(default)] + pub trusted_certificate_paths: Vec, + #[serde(default)] + pub trusted_certificates_der_base64: Vec, + #[serde(default)] + pub anchor_directory: Option, + #[serde(default)] + pub authroot_cab: Option, + #[serde(default)] + pub as_of: Option, + #[serde(default)] + pub prefer_timestamp_signing_time: bool, + #[serde(default)] + pub require_valid_timestamp: bool, + #[serde(default)] + pub online_aia: bool, + #[serde(default)] + pub online_ocsp: bool, + #[serde(default)] + pub revocation_mode: PortableRevocationMode, +} + +impl PortableTestFileCatalogRequest { + fn signature_request(&self) -> PortableGetSignatureRequest { + PortableGetSignatureRequest { + path: self.catalog_file_path.clone(), + trusted_certificate_paths: self.trusted_certificate_paths.clone(), + trusted_certificates_der_base64: self.trusted_certificates_der_base64.clone(), + anchor_directory: self.anchor_directory.clone(), + authroot_cab: self.authroot_cab.clone(), + as_of: self.as_of.clone(), + prefer_timestamp_signing_time: self.prefer_timestamp_signing_time, + require_valid_timestamp: self.require_valid_timestamp, + online_aia: self.online_aia, + online_ocsp: self.online_ocsp, + revocation_mode: self.revocation_mode, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PortableValidatePowerShellRequest { pub source_path_or_extension: PathBuf, @@ -340,6 +414,43 @@ pub struct PortableSignatureResponse { pub diagnostics: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortableCatalogItem { + pub path: String, + pub hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortableCatalogPathItem { + pub path: String, + pub hash: Option, + pub status: PortableCatalogItemStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortableNewFileCatalogResponse { + pub schema_version: u32, + pub catalog_file_path: PathBuf, + pub catalog_version: u32, + pub hash_algorithm: String, + pub item_count: usize, + pub catalog_items: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortableTestFileCatalogResponse { + pub schema_version: u32, + pub catalog_file_path: PathBuf, + pub status: PortableCatalogValidationStatus, + pub hash_algorithm: String, + pub catalog_items: Vec, + pub path_items: Vec, + pub skipped_items: Vec, + pub signature: PortableSignatureResponse, +} + fn is_zero(value: &usize) -> bool { *value == 0 } @@ -495,6 +606,163 @@ pub fn portable_clear_signature( } } +pub fn portable_new_file_catalog( + request: PortableNewFileCatalogRequest, +) -> Result { + let catalog_file_path = resolve_catalog_output_path(&request.catalog_file_path); + let catalog_version = effective_catalog_version(request.catalog_version)?; + let hash_algorithm = catalog_hash_algorithm_for_version(catalog_version)?; + let subjects = collect_catalog_subjects(&request.paths, Some(&catalog_file_path))?; + let inputs = subjects + .into_iter() + .map(|subject| { + let bytes = std::fs::read(&subject.path) + .with_context(|| format!("read catalog subject {}", subject.path.display()))?; + Ok(catalog_digest::CatalogSubjectInput { + name: subject.member_name, + bytes, + }) + }) + .collect::>>()?; + let catalog = catalog_digest::create_unsigned_catalog_pkcs7_der(&inputs, hash_algorithm) + .with_context(|| format!("create file catalog {}", catalog_file_path.display()))?; + std::fs::write(&catalog_file_path, &catalog.pkcs7_der) + .with_context(|| format!("write {}", catalog_file_path.display()))?; + let catalog_items = catalog + .members + .iter() + .map(portable_catalog_item_from_member) + .collect::>(); + Ok(PortableNewFileCatalogResponse { + schema_version: SCHEMA_VERSION, + catalog_file_path, + catalog_version, + hash_algorithm: catalog_hash_algorithm_label(hash_algorithm).to_string(), + item_count: catalog_items.len(), + catalog_items, + }) +} + +pub fn portable_test_file_catalog( + request: PortableTestFileCatalogRequest, +) -> Result { + let catalog_file_path = request.catalog_file_path.clone(); + let catalog_bytes = std::fs::read(&catalog_file_path) + .with_context(|| format!("read catalog {}", catalog_file_path.display()))?; + let members = catalog_digest::catalog_members_bytes(&catalog_bytes) + .with_context(|| format!("parse catalog members {}", catalog_file_path.display()))?; + let catalog_items = members + .iter() + .map(portable_catalog_item_from_member) + .collect::>(); + let hash_algorithm = members + .first() + .map(|m| catalog_hash_algorithm_label_for_oid(m.digest_algorithm_oid).to_string()) + .unwrap_or_else(|| "Unknown".to_string()); + let mut member_by_name = std::collections::HashMap::with_capacity(members.len()); + for member in members { + if let Some(name) = member.subject_name.as_ref() { + member_by_name.insert(normalize_catalog_member_key(name), member); + } + } + + let skip_keys = request + .files_to_skip + .iter() + .flat_map(|skip| catalog_skip_keys(skip)) + .collect::>(); + let subjects = collect_catalog_subjects(&request.paths, Some(&catalog_file_path))?; + let mut seen_path_keys = std::collections::HashSet::with_capacity(subjects.len()); + let mut skipped_items = Vec::new(); + let mut path_items = Vec::new(); + + for subject in subjects { + let member_key = normalize_catalog_member_key(&subject.member_name); + if skip_keys.contains(&member_key) + || skip_keys.contains(&normalize_catalog_member_key( + &subject.path.to_string_lossy(), + )) + || subject + .path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| skip_keys.contains(&normalize_catalog_member_key(name))) + { + skipped_items.push(subject.member_name); + continue; + } + + seen_path_keys.insert(member_key.clone()); + let Some(member) = member_by_name.get(&member_key) else { + path_items.push(PortableCatalogPathItem { + path: subject.member_name, + hash: None, + status: PortableCatalogItemStatus::NotInCatalog, + message: Some("File is not listed in the catalog.".to_string()), + }); + continue; + }; + let bytes = std::fs::read(&subject.path).with_context(|| { + format!("read catalog validation subject {}", subject.path.display()) + })?; + let actual = catalog_digest::catalog_member_digest_for_subject(member, &bytes) + .with_context(|| { + format!("hash catalog validation subject {}", subject.path.display()) + })?; + let actual_hex = hex_lower(&actual); + let status = if actual == member.digest { + PortableCatalogItemStatus::Valid + } else { + PortableCatalogItemStatus::HashMismatch + }; + let message = (status == PortableCatalogItemStatus::HashMismatch) + .then(|| "File hash does not match the catalog.".to_string()); + path_items.push(PortableCatalogPathItem { + path: subject.member_name, + hash: Some(actual_hex), + status, + message, + }); + } + + for (key, member) in member_by_name { + if seen_path_keys.contains(&key) || skip_keys.contains(&key) { + continue; + } + path_items.push(PortableCatalogPathItem { + path: member + .subject_name + .unwrap_or_else(|| hex_lower(&member.subject_identifier)), + hash: None, + status: PortableCatalogItemStatus::Missing, + message: Some("Catalog member was not found under the supplied path.".to_string()), + }); + } + + path_items.sort_by(|a, b| catalog_path_sort_key(&a.path).cmp(&catalog_path_sort_key(&b.path))); + skipped_items.sort_by_key(|item| catalog_path_sort_key(item)); + let signature = portable_get_signature(request.signature_request())?; + let status = if path_items + .iter() + .all(|item| item.status == PortableCatalogItemStatus::Valid) + { + PortableCatalogValidationStatus::Valid + } else { + PortableCatalogValidationStatus::ValidationFailed + }; + + Ok(PortableTestFileCatalogResponse { + schema_version: SCHEMA_VERSION, + catalog_file_path, + status, + hash_algorithm, + catalog_items, + path_items, + skipped_items, + signature, + }) +} + pub fn portable_validate_powershell_script( request: PortableValidatePowerShellRequest, ) -> Result { @@ -589,6 +857,247 @@ fn script_extension_for(path: &Path) -> String { .unwrap_or_else(|| "ps1".to_string()) } +#[derive(Debug, Clone)] +struct CatalogSubjectPlan { + path: PathBuf, + member_name: String, +} + +fn resolve_catalog_output_path(path: &Path) -> PathBuf { + if path.is_dir() { + path.join("catalog.cat") + } else { + path.to_path_buf() + } +} + +fn effective_catalog_version(version: u32) -> Result { + match version { + 0 => Ok(2), + 1 | 2 => Ok(version), + _ => bail!("catalog_version must be 1 or 2"), + } +} + +fn catalog_hash_algorithm_for_version( + version: u32, +) -> Result { + match version { + 1 => Ok(catalog_digest::CatalogHashAlgorithm::Sha1), + 2 => Ok(catalog_digest::CatalogHashAlgorithm::Sha256), + _ => bail!("catalog_version must be 1 or 2"), + } +} + +fn catalog_hash_algorithm_label(algorithm: catalog_digest::CatalogHashAlgorithm) -> &'static str { + match algorithm { + catalog_digest::CatalogHashAlgorithm::Sha1 => "SHA1", + catalog_digest::CatalogHashAlgorithm::Sha256 => "SHA256", + catalog_digest::CatalogHashAlgorithm::Sha384 => "SHA384", + catalog_digest::CatalogHashAlgorithm::Sha512 => "SHA512", + } +} + +fn catalog_hash_algorithm_label_for_oid(oid: ObjectIdentifier) -> &'static str { + if oid == ObjectIdentifier::new_unwrap("1.3.14.3.2.26") { + "SHA1" + } else if oid == ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.2.1") { + "SHA256" + } else if oid == ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.2.2") { + "SHA384" + } else if oid == ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.2.3") { + "SHA512" + } else { + "Unknown" + } +} + +fn portable_catalog_item_from_member( + member: &catalog_digest::CatalogMember, +) -> PortableCatalogItem { + PortableCatalogItem { + path: member + .subject_name + .clone() + .unwrap_or_else(|| hex_lower(&member.subject_identifier)), + hash: hex_lower(&member.digest), + } +} + +fn collect_catalog_subjects( + paths: &[PathBuf], + catalog_file_path: Option<&Path>, +) -> Result> { + let effective_paths = if paths.is_empty() { + vec![std::env::current_dir().context("resolve current directory for catalog paths")?] + } else { + paths.to_vec() + }; + let single_root_directory = effective_paths.len() == 1 && effective_paths[0].is_dir(); + let mut subjects = Vec::new(); + if single_root_directory { + collect_catalog_directory_subjects( + &effective_paths[0], + Some(&effective_paths[0]), + catalog_file_path, + &mut subjects, + )?; + } else { + for path in &effective_paths { + if path.is_dir() { + collect_catalog_directory_subjects(path, None, catalog_file_path, &mut subjects)?; + } else if path.is_file() { + if is_same_existing_path(path, catalog_file_path) { + continue; + } + subjects.push(CatalogSubjectPlan { + path: path.clone(), + member_name: catalog_file_name(path)?, + }); + } else { + bail!( + "catalog path does not exist or is not a file/directory: {}", + path.display() + ); + } + } + } + subjects.sort_by(|a, b| { + catalog_path_sort_key(&a.member_name) + .cmp(&catalog_path_sort_key(&b.member_name)) + .then_with(|| a.path.cmp(&b.path)) + }); + reject_duplicate_catalog_member_names(&subjects)?; + if subjects.is_empty() { + bail!("catalog requires at least one subject file"); + } + Ok(subjects) +} + +fn collect_catalog_directory_subjects( + directory: &Path, + relative_base: Option<&Path>, + catalog_file_path: Option<&Path>, + subjects: &mut Vec, +) -> Result<()> { + let mut entries = std::fs::read_dir(directory) + .with_context(|| format!("read directory {}", directory.display()))? + .collect::, _>>() + .with_context(|| format!("read directory entry {}", directory.display()))?; + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + let path = entry.path(); + let file_type = entry + .file_type() + .with_context(|| format!("stat directory entry {}", path.display()))?; + if file_type.is_dir() { + collect_catalog_directory_subjects(&path, relative_base, catalog_file_path, subjects)?; + } else if file_type.is_file() { + if is_same_existing_path(&path, catalog_file_path) { + continue; + } + let member_name = match relative_base { + Some(base) => catalog_relative_name(base, &path)?, + None => catalog_file_name(&path)?, + }; + subjects.push(CatalogSubjectPlan { path, member_name }); + } + } + Ok(()) +} + +fn catalog_file_name(path: &Path) -> Result { + path.file_name() + .and_then(|name| name.to_str()) + .map(str::to_owned) + .ok_or_else(|| { + anyhow::anyhow!( + "catalog subject path has no UTF-8 file name: {}", + path.display() + ) + }) +} + +fn catalog_relative_name(base: &Path, path: &Path) -> Result { + let relative = path + .strip_prefix(base) + .with_context(|| format!("make {} relative to {}", path.display(), base.display()))?; + let mut parts = Vec::new(); + for component in relative.components() { + let std::path::Component::Normal(part) = component else { + continue; + }; + let Some(part) = part.to_str() else { + bail!( + "catalog relative path contains non-UTF-8 component: {}", + path.display() + ); + }; + parts.push(part); + } + if parts.is_empty() { + bail!("catalog relative path is empty for {}", path.display()); + } + Ok(parts.join("/")) +} + +fn reject_duplicate_catalog_member_names(subjects: &[CatalogSubjectPlan]) -> Result<()> { + let mut seen = std::collections::HashSet::with_capacity(subjects.len()); + for subject in subjects { + let key = normalize_catalog_member_key(&subject.member_name); + if !seen.insert(key) { + bail!( + "catalog contains duplicate subject file name {}", + subject.member_name + ); + } + } + Ok(()) +} + +fn is_same_existing_path(path: &Path, other: Option<&Path>) -> bool { + let Some(other) = other else { + return false; + }; + let Ok(left) = path.canonicalize() else { + return false; + }; + let Ok(right) = other.canonicalize() else { + return false; + }; + left == right +} + +fn normalize_catalog_member_key(path: &str) -> String { + path.replace('\\', "/").to_ascii_lowercase() +} + +fn catalog_skip_keys(skip: &str) -> Vec { + let normalized = normalize_catalog_member_key(skip); + let file_name = normalized + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .map(str::to_owned); + match file_name { + Some(file_name) if file_name != normalized => vec![normalized, file_name], + _ => vec![normalized], + } +} + +fn catalog_path_sort_key(path: &str) -> String { + normalize_catalog_member_key(path) +} + +fn hex_lower(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + use std::fmt::Write as _; + let _ = write!(&mut out, "{b:02x}"); + } + out +} + pub fn portable_error_response( code: PortableErrorCode, error: impl std::fmt::Display, @@ -2304,6 +2813,19 @@ fn inspect_pkcs7_file( format: PortableFileFormat, ) -> Result { let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?; + if pkcs7::parse_pkcs7_signed_data_der(&data) + .ok() + .is_some_and(|sd| sd.signer_infos.0.is_empty()) + { + let mut response = base_response( + path.to_path_buf(), + format, + PortableSignatureStatus::NotSigned, + "PKCS#7 SignedData has no SignerInfo.", + ); + response.pkcs7_der_base64 = Some(base64::engine::general_purpose::STANDARD.encode(&data)); + return Ok(response); + } match inspect_authenticode_pkcs7_der(&data) { Ok(report) => { let mut summary = summarize_pkcs7_reports(std::iter::once(report)); @@ -2496,6 +3018,7 @@ fn looks_unsigned(message: &str) -> bool { || lower.contains("not signed") || lower.contains("no certificate table") || lower.contains("no pkcs#7") + || lower.contains("no signerinfo") || lower.contains("digital signature stream") || lower.contains("signature comment not found") || lower.contains("appxsignature.p7x") @@ -2820,6 +3343,126 @@ mod tests { assert_eq!(response.format, PortableFileFormat::PowerShellScript); } + #[test] + fn creates_and_tests_portable_file_catalog_with_relative_paths() { + let temp_dir = std::env::temp_dir().join(format!( + "psign-file-catalog-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + std::fs::create_dir_all(temp_dir.join("sub")).expect("create temp dir"); + std::fs::write(temp_dir.join("a.txt"), b"alpha").expect("write a"); + std::fs::write(temp_dir.join("sub").join("b.txt"), b"bravo").expect("write b"); + let catalog_path = temp_dir.join("catalog.cat"); + + let created = portable_new_file_catalog(PortableNewFileCatalogRequest { + catalog_file_path: catalog_path.clone(), + paths: vec![temp_dir.clone()], + catalog_version: 2, + }) + .expect("create catalog"); + assert_eq!(created.catalog_version, 2); + assert_eq!(created.hash_algorithm, "SHA256"); + assert_eq!(created.item_count, 2); + assert!( + created + .catalog_items + .iter() + .any(|item| item.path == "a.txt") + ); + assert!( + created + .catalog_items + .iter() + .any(|item| item.path == "sub/b.txt") + ); + + let tested = portable_test_file_catalog(default_catalog_test_request( + catalog_path.clone(), + vec![temp_dir.clone()], + Vec::new(), + )) + .expect("test catalog"); + assert_eq!(tested.status, PortableCatalogValidationStatus::Valid); + assert_eq!(tested.signature.status, PortableSignatureStatus::NotSigned); + assert_eq!(tested.path_items.len(), 2); + + std::fs::remove_dir_all(temp_dir).expect("remove temp dir"); + } + + #[test] + fn file_catalog_reports_tamper_and_supports_skip() { + let temp_dir = std::env::temp_dir().join(format!( + "psign-file-catalog-tamper-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let file_path = temp_dir.join("a.txt"); + std::fs::write(&file_path, b"alpha").expect("write a"); + let catalog_path = temp_dir.join("catalog.cat"); + portable_new_file_catalog(PortableNewFileCatalogRequest { + catalog_file_path: catalog_path.clone(), + paths: vec![temp_dir.clone()], + catalog_version: 2, + }) + .expect("create catalog"); + std::fs::write(&file_path, b"tampered").expect("tamper a"); + + let failed = portable_test_file_catalog(default_catalog_test_request( + catalog_path.clone(), + vec![temp_dir.clone()], + Vec::new(), + )) + .expect("test tampered catalog"); + assert_eq!( + failed.status, + PortableCatalogValidationStatus::ValidationFailed + ); + assert!(failed.path_items.iter().any(|item| { + item.path == "a.txt" && item.status == PortableCatalogItemStatus::HashMismatch + })); + + let skipped = portable_test_file_catalog(default_catalog_test_request( + catalog_path.clone(), + vec![temp_dir.clone()], + vec!["a.txt".to_string()], + )) + .expect("test skipped catalog"); + assert_eq!(skipped.status, PortableCatalogValidationStatus::Valid); + assert_eq!(skipped.skipped_items, vec!["a.txt".to_string()]); + + std::fs::remove_dir_all(temp_dir).expect("remove temp dir"); + } + + fn default_catalog_test_request( + catalog_file_path: PathBuf, + paths: Vec, + files_to_skip: Vec, + ) -> PortableTestFileCatalogRequest { + PortableTestFileCatalogRequest { + catalog_file_path, + paths, + files_to_skip, + trusted_certificate_paths: Vec::new(), + trusted_certificates_der_base64: Vec::new(), + anchor_directory: None, + authroot_cab: None, + as_of: None, + prefer_timestamp_signing_time: false, + require_valid_timestamp: false, + online_aia: false, + online_ocsp: false, + revocation_mode: PortableRevocationMode::Off, + } + } + #[test] fn validates_signed_powershell_content_with_explicit_trust() { let temp_dir = std::env::temp_dir().join(format!( diff --git a/crates/psign-portable-ffi/Cargo.toml b/crates/psign-portable-ffi/Cargo.toml index 01f19e1..5d94558 100644 --- a/crates/psign-portable-ffi/Cargo.toml +++ b/crates/psign-portable-ffi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-portable-ffi" -version = "0.4.0" +version = "0.5.0" edition = "2024" description = "C ABI shared library for psign portable Authenticode operations" license.workspace = true diff --git a/crates/psign-portable-ffi/src/lib.rs b/crates/psign-portable-ffi/src/lib.rs index c6f87aa..bbe86ff 100644 --- a/crates/psign-portable-ffi/src/lib.rs +++ b/crates/psign-portable-ffi/src/lib.rs @@ -4,7 +4,8 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use psign_portable_core::{ PortableErrorCode, PortableErrorResponse, portable_clear_signature, portable_error_response, - portable_get_signature, portable_sign, portable_validate_powershell_script, version, + portable_get_signature, portable_new_file_catalog, portable_sign, portable_test_file_catalog, + portable_validate_powershell_script, version, }; use serde::Serialize; use serde::de::DeserializeOwned; @@ -65,6 +66,42 @@ pub unsafe extern "C" fn psign_core_get_signature( invoke_json(request_json_ptr, request_json_len, portable_get_signature) } +/// Create an unsigned portable Windows file catalog. +/// +/// # Safety +/// +/// `request_json_ptr` must point to `request_json_len` readable UTF-8 bytes for the duration +/// of the call. The returned buffer must be released with `psign_core_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn psign_core_new_file_catalog( + request_json_ptr: *const u8, + request_json_len: usize, +) -> PsignFfiResult { + invoke_json( + request_json_ptr, + request_json_len, + portable_new_file_catalog, + ) +} + +/// Validate files against a portable Windows file catalog. +/// +/// # Safety +/// +/// `request_json_ptr` must point to `request_json_len` readable UTF-8 bytes for the duration +/// of the call. The returned buffer must be released with `psign_core_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn psign_core_test_file_catalog( + request_json_ptr: *const u8, + request_json_len: usize, +) -> PsignFfiResult { + invoke_json( + request_json_ptr, + request_json_len, + portable_test_file_catalog, + ) +} + /// Validate a PowerShell script/module signature from in-memory content. /// /// # Safety @@ -233,4 +270,47 @@ mod tests { assert!(json.contains("PowerShellScript")); assert!(json.contains("NotSigned")); } + + #[test] + fn file_catalog_create_and_test_return_json() { + let dir = std::env::temp_dir().join(format!( + "psign-ffi-catalog-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("create temp dir"); + std::fs::write(dir.join("a.txt"), b"alpha").expect("write file"); + let catalog = dir.join("catalog.cat"); + let dir_string = dir.to_string_lossy().to_string(); + let catalog_string = catalog.to_string_lossy().to_string(); + + let create_request = serde_json::json!({ + "catalog_file_path": catalog_string.clone(), + "paths": [dir_string.clone()], + "catalog_version": 2 + }) + .to_string(); + let result = + unsafe { psign_core_new_file_catalog(create_request.as_ptr(), create_request.len()) }; + assert_eq!(result.status_code, STATUS_OK); + let json = unsafe { result_json(result) }; + assert!(json.contains("SHA256")); + + let test_request = serde_json::json!({ + "catalog_file_path": catalog_string, + "paths": [dir_string], + "files_to_skip": [] + }) + .to_string(); + let result = + unsafe { psign_core_test_file_catalog(test_request.as_ptr(), test_request.len()) }; + assert_eq!(result.status_code, STATUS_OK); + let json = unsafe { result_json(result) }; + assert!(json.contains("Valid")); + + std::fs::remove_dir_all(dir).expect("remove temp dir"); + } } diff --git a/crates/psign-sip-digest/Cargo.toml b/crates/psign-sip-digest/Cargo.toml index c609d56..e90f0b3 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.4.0" +version = "0.5.0" edition = "2024" description = "Portable Authenticode SIP digest recomputation (PE, CAB, MSI, MSIX, scripts, …) without Win32" license.workspace = true diff --git a/crates/psign-sip-digest/src/catalog_digest.rs b/crates/psign-sip-digest/src/catalog_digest.rs index 291faa9..12428a3 100644 --- a/crates/psign-sip-digest/src/catalog_digest.rs +++ b/crates/psign-sip-digest/src/catalog_digest.rs @@ -13,7 +13,7 @@ use anyhow::{Context, Result, anyhow}; use authenticode::{DigestInfo, SpcAttributeTypeAndOptionalValue, SpcIndirectDataContent}; use cms::signed_data::SignedData; use der::asn1::{Any, ObjectIdentifier, OctetString}; -use der::{Decode, Encode, SliceReader}; +use der::{Decode, Encode, SliceReader, Tag}; use digest::Digest; use rsa::RsaPrivateKey; use std::collections::HashSet; @@ -39,6 +39,11 @@ const OID_SHA384: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.16.840.1.10 const OID_SHA512: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.2.3"); const CATALOG_GENERIC_LINK_VALUE_DER: &[u8] = &[0xa2, 0x02, 0x80, 0x00]; +const CATALOG_PE_IMAGE_DATA_VALUE: &[u8] = &[ + 0x03, 0x01, 0x00, 0xa0, 0x20, 0xa2, 0x1e, 0x80, 0x1c, 0x00, 0x3c, 0x00, 0x3c, 0x00, 0x3c, 0x00, + 0x4f, 0x00, 0x62, 0x00, 0x73, 0x00, 0x6f, 0x00, 0x6c, 0x00, 0x65, 0x00, 0x74, 0x00, 0x65, 0x00, + 0x3e, 0x00, 0x3e, 0x00, 0x3e, +]; const CATALOG_THIS_UPDATE_UTC: &[u8] = b"700101000000Z"; const TAG_SEQUENCE: u8 = 0x30; @@ -74,6 +79,51 @@ pub struct CatalogSignResult { pub members: Vec, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CatalogHashAlgorithm { + Sha1, + Sha256, + Sha384, + Sha512, +} + +impl CatalogHashAlgorithm { + pub fn digest_algorithm_oid(self) -> ObjectIdentifier { + match self { + Self::Sha1 => OID_SHA1, + Self::Sha256 => OID_SHA256, + Self::Sha384 => OID_SHA384, + Self::Sha512 => OID_SHA512, + } + } + + fn pe_hash_kind(self) -> PeAuthenticodeHashKind { + match self { + Self::Sha1 => PeAuthenticodeHashKind::Sha1, + Self::Sha256 => PeAuthenticodeHashKind::Sha256, + Self::Sha384 => PeAuthenticodeHashKind::Sha384, + Self::Sha512 => PeAuthenticodeHashKind::Sha512, + } + } + + fn digest_algorithm_identifier(self) -> AlgorithmIdentifierOwned { + AlgorithmIdentifierOwned { + oid: self.digest_algorithm_oid(), + parameters: None, + } + } +} + +impl From for CatalogHashAlgorithm { + fn from(value: AuthenticodeSigningDigest) -> Self { + match value { + AuthenticodeSigningDigest::Sha256 => Self::Sha256, + AuthenticodeSigningDigest::Sha384 => Self::Sha384, + AuthenticodeSigningDigest::Sha512 => Self::Sha512, + } + } +} + #[derive(Clone, Copy, Debug)] struct Tlv<'a> { tag: u8, @@ -107,25 +157,12 @@ fn hash_econtent(kind: PeAuthenticodeHashKind, econtent: &[u8]) -> Vec { } } -fn hash_subject_bytes(kind: PeAuthenticodeHashKind, bytes: &[u8]) -> Vec { - match kind { - PeAuthenticodeHashKind::Sha1 => sha1::Sha1::digest(bytes).to_vec(), - PeAuthenticodeHashKind::Sha256 => sha2::Sha256::digest(bytes).to_vec(), - PeAuthenticodeHashKind::Sha384 => sha2::Sha384::digest(bytes).to_vec(), - PeAuthenticodeHashKind::Sha512 => sha2::Sha512::digest(bytes).to_vec(), - } -} - -fn digest_algorithm_identifier( - digest_algorithm: AuthenticodeSigningDigest, -) -> AlgorithmIdentifierOwned { - AlgorithmIdentifierOwned { - oid: match digest_algorithm { - AuthenticodeSigningDigest::Sha256 => OID_SHA256, - AuthenticodeSigningDigest::Sha384 => OID_SHA384, - AuthenticodeSigningDigest::Sha512 => OID_SHA512, - }, - parameters: None, +fn hash_subject_bytes(algorithm: CatalogHashAlgorithm, bytes: &[u8]) -> Vec { + match algorithm { + CatalogHashAlgorithm::Sha1 => sha1::Sha1::digest(bytes).to_vec(), + CatalogHashAlgorithm::Sha256 => sha2::Sha256::digest(bytes).to_vec(), + CatalogHashAlgorithm::Sha384 => sha2::Sha384::digest(bytes).to_vec(), + CatalogHashAlgorithm::Sha512 => sha2::Sha512::digest(bytes).to_vec(), } } @@ -193,10 +230,8 @@ fn catalog_subject_identifier(name: &str) -> Result> { if name.is_empty() { return Err(anyhow!("catalog subject name must not be empty")); } - if name.contains(['/', '\\', '\0']) { - return Err(anyhow!( - "catalog subject name must be a file name, not a path: {name}" - )); + if name.contains('\0') { + return Err(anyhow!("catalog subject name must not contain NUL bytes")); } let decorated = format!("<{name}>\0"); let mut out = Vec::with_capacity(decorated.len() * 2); @@ -216,7 +251,7 @@ fn catalog_list_identifier(members: &[CatalogMember]) -> Vec { } fn catalog_generic_spc_indirect_data( - digest_algorithm: AuthenticodeSigningDigest, + digest_algorithm: CatalogHashAlgorithm, subject_digest: &[u8], ) -> Result { let expected = digest_algorithm.pe_hash_kind().digest_output_len(); @@ -236,22 +271,49 @@ fn catalog_generic_spc_indirect_data( .map_err(|e| anyhow!("catalog generic SPC link Any: {e}"))?, }, message_digest: DigestInfo { - digest_algorithm: digest_algorithm_identifier(digest_algorithm), + digest_algorithm: digest_algorithm.digest_algorithm_identifier(), + digest, + }, + }) +} + +fn catalog_pe_spc_indirect_data( + digest_algorithm: CatalogHashAlgorithm, + pe_digest: &[u8], +) -> Result { + let expected = digest_algorithm.pe_hash_kind().digest_output_len(); + if pe_digest.len() != expected { + return Err(anyhow!( + "catalog PE digest length {} does not match {:?} ({expected} octets)", + pe_digest.len(), + digest_algorithm + )); + } + let digest = OctetString::new(pe_digest.to_vec()) + .map_err(|e| anyhow!("catalog PE SpcIndirectData digest OCTET STRING: {e}"))?; + Ok(SpcIndirectDataContent { + data: SpcAttributeTypeAndOptionalValue { + value_type: ID_SPC_PE_IMAGE_DATA, + value: Any::new(Tag::Sequence, CATALOG_PE_IMAGE_DATA_VALUE) + .map_err(|e| anyhow!("catalog PE image data Any: {e}"))?, + }, + message_digest: DigestInfo { + digest_algorithm: digest_algorithm.digest_algorithm_identifier(), digest, }, }) } fn catalog_subject_indirect_data( - digest_algorithm: AuthenticodeSigningDigest, + digest_algorithm: CatalogHashAlgorithm, subject: &[u8], ) -> Result<(SpcIndirectDataContent, ObjectIdentifier, Vec)> { let kind = digest_algorithm.pe_hash_kind(); if let Ok(pe_digest) = crate::pe_digest::pe_authenticode_digest(subject, kind) { - let indirect = crate::pkcs7::pe_spc_indirect_data(digest_algorithm, &pe_digest)?; + let indirect = catalog_pe_spc_indirect_data(digest_algorithm, &pe_digest)?; return Ok((indirect, ID_SPC_PE_IMAGE_DATA, pe_digest)); } - let digest = hash_subject_bytes(kind, subject); + let digest = hash_subject_bytes(digest_algorithm, subject); let indirect = catalog_generic_spc_indirect_data(digest_algorithm, &digest)?; Ok((indirect, ID_SPC_CAB_DATA, digest)) } @@ -274,9 +336,9 @@ fn catalog_ctl_entry_der( } /// Build Microsoft CTL `eContent` DER for a portable generic catalog. -pub fn create_catalog_ctl_econtent_der( +pub fn create_catalog_ctl_econtent_der_with_hash( subjects: &[CatalogSubjectInput], - digest_algorithm: AuthenticodeSigningDigest, + digest_algorithm: CatalogHashAlgorithm, ) -> Result<(Vec, Vec)> { if subjects.is_empty() { return Err(anyhow!( @@ -303,7 +365,7 @@ pub fn create_catalog_ctl_econtent_der( subject_name: decode_utf16le_subject_identifier(&subject_identifier), subject_identifier, data_oid, - digest_algorithm_oid: digest_algorithm_identifier(digest_algorithm).oid, + digest_algorithm_oid: digest_algorithm.digest_algorithm_oid(), digest, }); } @@ -320,6 +382,29 @@ pub fn create_catalog_ctl_econtent_der( Ok((ctl_info, members)) } +/// Build Microsoft CTL `eContent` DER for a portable generic catalog. +pub fn create_catalog_ctl_econtent_der( + subjects: &[CatalogSubjectInput], + digest_algorithm: AuthenticodeSigningDigest, +) -> Result<(Vec, Vec)> { + create_catalog_ctl_econtent_der_with_hash(subjects, digest_algorithm.into()) +} + +/// Create an unsigned portable generic catalog (`.cat`) from CTL members. +pub fn create_unsigned_catalog_pkcs7_der( + subjects: &[CatalogSubjectInput], + digest_algorithm: CatalogHashAlgorithm, +) -> Result { + let (econtent_der, members) = + create_catalog_ctl_econtent_der_with_hash(subjects, digest_algorithm)?; + let pkcs7_der = crate::pkcs7::create_pkcs7_unsigned_signed_data_der( + ID_MS_CTL, + &econtent_der, + digest_algorithm.digest_algorithm_identifier(), + )?; + Ok(CatalogSignResult { pkcs7_der, members }) +} + /// Create a signed portable generic catalog (`.cat`) from CTL members using an RSA private key. pub fn create_catalog_pkcs7_der_rsa( subjects: &[CatalogSubjectInput], @@ -418,7 +503,12 @@ fn decode_utf16le_subject_identifier(bytes: &[u8]) -> Option { .map(|b| u16::from_le_bytes([b[0], b[1]])) .take_while(|u| *u != 0) .collect(); - String::from_utf16(&units).ok() + String::from_utf16(&units).ok().map(|s| { + match s.strip_prefix('<').and_then(|v| v.strip_suffix('>')) { + Some(inner) => inner.to_string(), + None => s, + } + }) } fn spc_indirect_from_attribute_sequence( @@ -621,13 +711,25 @@ pub fn catalog_members_bytes(data: &[u8]) -> Result> { Ok(members) } -fn catalog_member_digest_for_subject(member: &CatalogMember, subject: &[u8]) -> Result> { +pub fn catalog_member_digest_for_subject( + member: &CatalogMember, + subject: &[u8], +) -> Result> { let kind = digest_kind_from_digest_alg_oid(member.digest_algorithm_oid)?; if member.data_oid == ID_SPC_PE_IMAGE_DATA { crate::pe_digest::pe_authenticode_digest(subject, kind) .context("compute PE Authenticode digest for catalog member") } else { - Ok(hash_subject_bytes(kind, subject)) + let algorithm = if kind == PeAuthenticodeHashKind::Sha1 { + CatalogHashAlgorithm::Sha1 + } else if kind == PeAuthenticodeHashKind::Sha256 { + CatalogHashAlgorithm::Sha256 + } else if kind == PeAuthenticodeHashKind::Sha384 { + CatalogHashAlgorithm::Sha384 + } else { + CatalogHashAlgorithm::Sha512 + }; + Ok(hash_subject_bytes(algorithm, subject)) } } diff --git a/crates/psign-sip-digest/src/pkcs7.rs b/crates/psign-sip-digest/src/pkcs7.rs index bb8f35e..e672715 100644 --- a/crates/psign-sip-digest/src/pkcs7.rs +++ b/crates/psign-sip-digest/src/pkcs7.rs @@ -693,6 +693,37 @@ pub fn create_pkcs7_signed_data_der_with_signed_attrs_and_rsa_signature( encode_pkcs7_content_info_signed_data_der(&sd) } +/// Create unsigned PKCS#7 `ContentInfo(SignedData)` DER for an arbitrary attached content type. +/// +/// Windows file catalogs created for later signing are CMS `SignedData` documents that can carry +/// Microsoft CTL content before any `SignerInfo` has been added. +pub fn create_pkcs7_unsigned_signed_data_der( + econtent_type: ObjectIdentifier, + econtent_der: &[u8], + digest_algorithm: AlgorithmIdentifierOwned, +) -> Result> { + let mut rd = SliceReader::new(econtent_der) + .map_err(|e| anyhow!("encapsulated content DER reader: {e}"))?; + let econtent = + Any::decode(&mut rd).map_err(|e| anyhow!("encapsulated content as CMS Any: {e}"))?; + rd.finish(()) + .map_err(|e| anyhow!("trailing octets after encapsulated content DER: {e}"))?; + let digest_algorithms = SetOfVec::try_from(vec![digest_algorithm]) + .map_err(|e| anyhow!("DigestAlgorithmIdentifiers SET: {e}"))?; + let sd = SignedData { + version: CmsVersion::V1, + digest_algorithms, + encap_content_info: EncapsulatedContentInfo { + econtent_type, + econtent: Some(econtent), + }, + certificates: None, + crls: None, + signer_infos: SignerInfos(SetOfVec::new()), + }; + encode_pkcs7_content_info_signed_data_der(&sd) +} + /// Attach a raw RFC3161 `timeStampToken` `ContentInfo` as a Microsoft Authenticode unsigned attribute. pub fn signed_data_add_rfc3161_timestamp_token( sd: &SignedData, diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/NewPsignFileCatalogCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/NewPsignFileCatalogCommand.cs new file mode 100644 index 0000000..51cca8a --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/NewPsignFileCatalogCommand.cs @@ -0,0 +1,63 @@ +using System.Management.Automation; +using Devolutions.Psign.PowerShell.Models; +using Devolutions.Psign.PowerShell.Native; + +namespace Devolutions.Psign.PowerShell.Cmdlets; + +[Cmdlet(VerbsCommon.New, "PsignFileCatalog", SupportsShouldProcess = true)] +[OutputType(typeof(FileInfo))] +public sealed class NewPsignFileCatalogCommand : PSCmdlet +{ + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string CatalogFilePath { get; set; } = string.Empty; + + [Parameter(Position = 1, ValueFromPipelineByPropertyName = true)] + public string[] Path { get; set; } = []; + + [Parameter] + [ValidateRange(1, 2)] + public int CatalogVersion { get; set; } = 2; + + protected override void ProcessRecord() + { + string catalogPath = ResolveCatalogPath(CatalogFilePath); + string[] paths = ResolveInputPaths(); + if (!ShouldProcess(catalogPath, $"Create portable file catalog from {paths.Length} path(s)")) + { + return; + } + + try + { + PortableNewFileCatalogResponse response = PsignNative.NewFileCatalog(new PortableNewFileCatalogRequest + { + CatalogFilePath = catalogPath, + Paths = paths, + CatalogVersion = CatalogVersion, + }); + WriteObject(new FileInfo(response.CatalogFilePath)); + } + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, "NewPsignFileCatalogFailed", ErrorCategory.NotSpecified, catalogPath)); + } + } + + private string[] ResolveInputPaths() + { + string[] inputs = Path.Length == 0 + ? [SessionState.Path.CurrentFileSystemLocation.ProviderPath] + : Path; + return inputs + .Select(p => SessionState.Path.GetUnresolvedProviderPathFromPSPath(p)) + .ToArray(); + } + + private string ResolveCatalogPath(string catalogFilePath) + { + string resolved = SessionState.Path.GetUnresolvedProviderPathFromPSPath(catalogFilePath); + return Directory.Exists(resolved) + ? System.IO.Path.Combine(resolved, "catalog.cat") + : resolved; + } +} diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/TestPsignFileCatalogCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/TestPsignFileCatalogCommand.cs new file mode 100644 index 0000000..1af4519 --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/TestPsignFileCatalogCommand.cs @@ -0,0 +1,122 @@ +using System.Globalization; +using System.Management.Automation; +using System.Security.Cryptography.X509Certificates; +using Devolutions.Psign.PowerShell.Models; +using Devolutions.Psign.PowerShell.Native; +using Devolutions.Psign.PowerShell.Trust; + +namespace Devolutions.Psign.PowerShell.Cmdlets; + +[Cmdlet(VerbsDiagnostic.Test, "PsignFileCatalog", SupportsShouldProcess = true)] +[OutputType(typeof(PsignCatalogValidationStatus), typeof(PortableTestFileCatalogResponse))] +public sealed class TestPsignFileCatalogCommand : PSCmdlet +{ + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string CatalogFilePath { get; set; } = string.Empty; + + [Parameter(Position = 1, ValueFromPipelineByPropertyName = true)] + public string[] Path { get; set; } = []; + + [Parameter] + public SwitchParameter Detailed { get; set; } + + [Parameter] + public string[] FilesToSkip { get; set; } = []; + + [Parameter] + public X509Certificate2[] TrustedCertificate { get; set; } = []; + + [Parameter] + public string[] TrustedCertificatePath { get; set; } = []; + + [Parameter] + public string? AnchorDirectory { get; set; } + + [Parameter] + public string? AuthRootCab { get; set; } + + [Parameter] + public DateTime? AsOf { get; set; } + + [Parameter] + public SwitchParameter PreferTimestampSigningTime { get; set; } + + [Parameter] + public SwitchParameter RequireValidTimestamp { get; set; } + + [Parameter] + public SwitchParameter OnlineAia { get; set; } + + [Parameter] + public SwitchParameter OnlineOcsp { get; set; } + + [Parameter] + [ValidateSet("Off", "BestEffort", "Require")] + public string RevocationMode { get; set; } = "Off"; + + [Parameter] + public SwitchParameter SkipTrust { get; set; } + + protected override void ProcessRecord() + { + string catalogPath = SessionState.Path.GetUnresolvedProviderPathFromPSPath(CatalogFilePath); + if (!ShouldProcess(catalogPath, "Test portable file catalog")) + { + return; + } + + try + { + PortableTestFileCatalogResponse response = PsignNative.TestFileCatalog(CreateRequest(catalogPath)); + WriteObject(Detailed.IsPresent ? response : response.Status); + } + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, "TestPsignFileCatalogFailed", ErrorCategory.NotSpecified, catalogPath)); + } + } + + private PortableTestFileCatalogRequest CreateRequest(string catalogPath) + { + string[] paths = Path.Length == 0 + ? [SessionState.Path.CurrentFileSystemLocation.ProviderPath] + : Path.Select(p => SessionState.Path.GetUnresolvedProviderPathFromPSPath(p)).ToArray(); + + string? resolvedAuthRootCab = AuthRootCab is null + ? null + : SessionState.Path.GetUnresolvedProviderPathFromPSPath(AuthRootCab); + if (!SkipTrust.IsPresent + && resolvedAuthRootCab is null + && AnchorDirectory is null + && TrustedCertificatePath.Length == 0 + && TrustedCertificate.Length == 0) + { + resolvedAuthRootCab = AuthRootCache.GetOrDownloadAuthRootCab(msg => WriteVerbose(msg)); + } + + return new PortableTestFileCatalogRequest + { + CatalogFilePath = catalogPath, + Paths = paths, + FilesToSkip = FilesToSkip, + TrustedCertificatePaths = TrustedCertificatePath + .Select(p => SessionState.Path.GetUnresolvedProviderPathFromPSPath(p)) + .ToArray(), + TrustedCertificatesDerBase64 = TrustedCertificate + .Select(c => Convert.ToBase64String(c.Export(X509ContentType.Cert))) + .ToArray(), + AnchorDirectory = AnchorDirectory is null + ? null + : SessionState.Path.GetUnresolvedProviderPathFromPSPath(AnchorDirectory), + AuthRootCab = resolvedAuthRootCab, + AsOf = AsOf is null + ? null + : AsOf.Value.ToUniversalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + PreferTimestampSigningTime = PreferTimestampSigningTime.IsPresent || RequireValidTimestamp.IsPresent, + RequireValidTimestamp = RequireValidTimestamp.IsPresent, + OnlineAia = OnlineAia.IsPresent, + OnlineOcsp = OnlineOcsp.IsPresent, + RevocationMode = RevocationMode, + }; + } +} diff --git a/dotnet/Devolutions.Psign.PowerShell/Models/PortableCatalog.cs b/dotnet/Devolutions.Psign.PowerShell/Models/PortableCatalog.cs new file mode 100644 index 0000000..2118514 --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Models/PortableCatalog.cs @@ -0,0 +1,92 @@ +using System.Text.Json.Serialization; + +namespace Devolutions.Psign.PowerShell.Models; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PsignCatalogValidationStatus +{ + Valid, + ValidationFailed, +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PsignCatalogItemStatus +{ + Valid, + Missing, + HashMismatch, + NotInCatalog, + Skipped, +} + +public sealed class PsignCatalogItem +{ + [JsonPropertyName("path")] + public string Path { get; init; } = string.Empty; + + [JsonPropertyName("hash")] + public string Hash { get; init; } = string.Empty; +} + +public sealed class PsignCatalogPathItem +{ + [JsonPropertyName("path")] + public string Path { get; init; } = string.Empty; + + [JsonPropertyName("hash")] + public string? Hash { get; init; } + + [JsonPropertyName("status")] + public PsignCatalogItemStatus Status { get; init; } + + [JsonPropertyName("message")] + public string? Message { get; init; } +} + +internal sealed class PortableNewFileCatalogResponse +{ + [JsonPropertyName("schema_version")] + public int SchemaVersion { get; init; } + + [JsonPropertyName("catalog_file_path")] + public string CatalogFilePath { get; init; } = string.Empty; + + [JsonPropertyName("catalog_version")] + public int CatalogVersion { get; init; } + + [JsonPropertyName("hash_algorithm")] + public string HashAlgorithm { get; init; } = string.Empty; + + [JsonPropertyName("item_count")] + public int ItemCount { get; init; } + + [JsonPropertyName("catalog_items")] + public PsignCatalogItem[] CatalogItems { get; init; } = []; +} + +public sealed class PortableTestFileCatalogResponse +{ + [JsonPropertyName("schema_version")] + public int SchemaVersion { get; init; } + + [JsonPropertyName("catalog_file_path")] + public string CatalogFilePath { get; init; } = string.Empty; + + [JsonPropertyName("status")] + public PsignCatalogValidationStatus Status { get; init; } + + [JsonPropertyName("hash_algorithm")] + public string HashAlgorithm { get; init; } = string.Empty; + + [JsonPropertyName("catalog_items")] + public PsignCatalogItem[] CatalogItems { get; init; } = []; + + [JsonPropertyName("path_items")] + public PsignCatalogPathItem[] PathItems { get; init; } = []; + + [JsonPropertyName("skipped_items")] + public string[] SkippedItems { get; init; } = []; + + [JsonPropertyName("signature")] + public PortableSignature Signature { get; init; } = new(); +} diff --git a/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs b/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs index 23ff3b4..54ce39b 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs @@ -130,6 +130,60 @@ internal sealed class PortableSignRequest public string? ArtifactSigningClientSecret { get; init; } } +internal sealed class PortableNewFileCatalogRequest +{ + [JsonPropertyName("catalog_file_path")] + public required string CatalogFilePath { get; init; } + + [JsonPropertyName("paths")] + public string[] Paths { get; init; } = []; + + [JsonPropertyName("catalog_version")] + public int CatalogVersion { get; init; } = 2; +} + +internal sealed class PortableTestFileCatalogRequest +{ + [JsonPropertyName("catalog_file_path")] + public required string CatalogFilePath { get; init; } + + [JsonPropertyName("paths")] + public string[] Paths { get; init; } = []; + + [JsonPropertyName("files_to_skip")] + public string[] FilesToSkip { get; init; } = []; + + [JsonPropertyName("trusted_certificate_paths")] + public string[] TrustedCertificatePaths { get; init; } = []; + + [JsonPropertyName("trusted_certificates_der_base64")] + public string[] TrustedCertificatesDerBase64 { get; init; } = []; + + [JsonPropertyName("anchor_directory")] + public string? AnchorDirectory { get; init; } + + [JsonPropertyName("authroot_cab")] + public string? AuthRootCab { get; init; } + + [JsonPropertyName("as_of")] + public string? AsOf { get; init; } + + [JsonPropertyName("prefer_timestamp_signing_time")] + public bool PreferTimestampSigningTime { get; init; } + + [JsonPropertyName("require_valid_timestamp")] + public bool RequireValidTimestamp { get; init; } + + [JsonPropertyName("online_aia")] + public bool OnlineAia { get; init; } + + [JsonPropertyName("online_ocsp")] + public bool OnlineOcsp { get; init; } + + [JsonPropertyName("revocation_mode")] + public string RevocationMode { get; init; } = "Off"; +} + internal sealed class PortableClearSignatureRequest { [JsonPropertyName("path")] diff --git a/dotnet/Devolutions.Psign.PowerShell/Native/PsignNative.cs b/dotnet/Devolutions.Psign.PowerShell/Native/PsignNative.cs index fc8ec77..5b107df 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Native/PsignNative.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Native/PsignNative.cs @@ -20,6 +20,16 @@ internal static PortableSignature GetSignature(PortableGetSignatureRequest reque return Invoke(request, psign_core_get_signature); } + internal static PortableNewFileCatalogResponse NewFileCatalog(PortableNewFileCatalogRequest request) + { + return Invoke(request, psign_core_new_file_catalog); + } + + internal static PortableTestFileCatalogResponse TestFileCatalog(PortableTestFileCatalogRequest request) + { + return Invoke(request, psign_core_test_file_catalog); + } + internal static PortableSignResponse Sign(PortableSignRequest request) { return Invoke(request, psign_core_sign); @@ -77,6 +87,12 @@ private static byte[] CopyResponse(PsignFfiBuffer buffer) [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] private static extern PsignFfiResult psign_core_get_signature(IntPtr requestJsonPtr, UIntPtr requestJsonLen); + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + private static extern PsignFfiResult psign_core_new_file_catalog(IntPtr requestJsonPtr, UIntPtr requestJsonLen); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + private static extern PsignFfiResult psign_core_test_file_catalog(IntPtr requestJsonPtr, UIntPtr requestJsonLen); + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] private static extern PsignFfiResult psign_core_sign(IntPtr requestJsonPtr, UIntPtr requestJsonLen); diff --git a/nuget/tool/Devolutions.Psign.Tool.csproj b/nuget/tool/Devolutions.Psign.Tool.csproj index 96ab2d2..971199f 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.4.0 + 0.5.0 Devolutions RID-specific dotnet tool wrapper around prebuilt psign-tool native executables. README.md From 1f6c0c39998a7bbb59a43f36e32504e0cdba42ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Tue, 26 May 2026 10:55:30 -0400 Subject: [PATCH 2/2] Fix portable catalog clippy warning Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psign-portable-core/src/lib.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/psign-portable-core/src/lib.rs b/crates/psign-portable-core/src/lib.rs index 5c6be20..427e69b 100644 --- a/crates/psign-portable-core/src/lib.rs +++ b/crates/psign-portable-core/src/lib.rs @@ -739,7 +739,7 @@ pub fn portable_test_file_catalog( }); } - path_items.sort_by(|a, b| catalog_path_sort_key(&a.path).cmp(&catalog_path_sort_key(&b.path))); + path_items.sort_by_key(|item| catalog_path_sort_key(&item.path)); skipped_items.sort_by_key(|item| catalog_path_sort_key(item)); let signature = portable_get_signature(request.signature_request())?; let status = if path_items @@ -962,10 +962,11 @@ fn collect_catalog_subjects( } } } - subjects.sort_by(|a, b| { - catalog_path_sort_key(&a.member_name) - .cmp(&catalog_path_sort_key(&b.member_name)) - .then_with(|| a.path.cmp(&b.path)) + subjects.sort_by_key(|subject| { + ( + catalog_path_sort_key(&subject.member_name), + subject.path.clone(), + ) }); reject_duplicate_catalog_member_names(&subjects)?; if subjects.is_empty() {