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..427e69b 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_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 + .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,248 @@ 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_key(|subject| { + ( + catalog_path_sort_key(&subject.member_name), + subject.path.clone(), + ) + }); + 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 +2814,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 +3019,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 +3344,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